SurfacedBySurfacedBy Docs

Console Webhooks

Register an HTTPS endpoint for your Console account, verify signed deliveries, and understand retries.

Ask an AI:Open in ChatGPTOpen in Claude

A webhook endpoint receives a signed HTTP POST each time an event it subscribes to occurs. Register endpoints from Webhooks in the Console or through the API. Console webhooks are not limited by plan.

An endpoint registered in the Console receives events about your Console account's domains, and only those: when a Console check completes or fails, it receives scan.completed or scan.failed for that check. Events about domains tracked in a SurfacedBy dashboard workspace never reach it, and Console endpoints do not appear in the dashboard's webhook list. Polling GET https://api.surfacedby.com/api/v1/console/scans/{scan_id} works as well; see Triggering a check.

Registering an endpoint

curl -X POST https://api.surfacedby.com/api/v1/console/webhooks \
  -H "X-API-Key: sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://yourapp.example.com/surfacedby/hook",
    "events": ["scan.completed", "scan.failed"],
    "description": "Production receiver"
  }'
  • url must be an https:// URL without a username or password.
  • events is a non-empty list of event types. An unknown type returns 422 with the list of supported types.
  • description (optional, up to 200 characters) and enabled (optional, default true).

The response includes a signing_secret (64 hex characters). It is returned only once: store it right away. There is no rotate endpoint; to change a secret, register a new endpoint, move your receiver to it, then delete the old one.

Event types

events accepts the event types a Console domain produces:

  • scan.progress, scan.completed, and scan.failed for each check.
  • citation.changed when a completed check's citation count differs from the previous check of the same domain.
  • competitor.discovered when a check finds a new competitor.

alert.triggered, domain.added, and report.ready come from dashboard features. A Console endpoint that lists them is refused with 422 validation_error, and supported_events in the response lists the accepted types.

Payload shape

Every delivery body uses the same envelope. A scan.completed for a Console check looks like this:

{
  "id": "5b0f3c9e-2d7a-4e61-9f0a-8c1d2e3f4a5b",
  "type": "scan.completed",
  "created_at": "2026-09-01T12:34:56.789012+00:00",
  "livemode": true,
  "data": {
    "scan_id": "0d4e6f7a-1b2c-4d3e-8f9a-0b1c2d3e4f5a",
    "domain_id": "3f6c2a1e-8b4d-4c1a-9e2f-7a5b6c8d9e0f",
    "domain": "yourbrand.com",
    "measurement_scope": "full_benchmark",
    "measurement_label": "Full Check",
    "visibility_score": 58,
    "is_first_check": false,
    "started_at": "2026-09-01T12:28:22.004411+00:00",
    "completed_at": "2026-09-01T12:33:40.123456+00:00",
    "results_summary": {
      "brand_coverage_pct": 42.5,
      "total_citations": 17,
      "duration_seconds": 318
    },
    "results_url": "https://api.surfacedby.com/api/v1/console/scans/0d4e6f7a-1b2c-4d3e-8f9a-0b1c2d3e4f5a/results",
    "workspace_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
    "workspace_slug": "yourbrand"
  }
}

id, type, created_at, livemode, and data are always present. The fields inside data depend on the event type; data.workspace_id and data.workspace_slug are always included. Keys arrive sorted alphabetically, so parse the JSON rather than relying on key order.

results_url is carried by scan.completed for Console checks only. It is the absolute URL of the check's results; fetch it with your API key (see Read the results). A scan.failed event carries error_summary, one of a fixed set of customer-facing messages. Webhook events lists every field of every event.

Request headers

HeaderValue
X-SurfacedBy-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256>
X-SurfacedBy-TimestampThe same unix timestamp as t
X-SurfacedBy-Event-IdThe event id (same as id in the body)
X-SurfacedBy-Event-TypeThe event type
X-SurfacedBy-Delivery-IdThe id of this delivery
X-SurfacedBy-AttemptThe attempt number, starting at 1

Signature verification

The v1 value is an HMAC-SHA256, keyed with your signing secret, over the timestamp, a period, and the raw request body: f"{t}.{raw_body}". Verify it against the raw bytes you received, before parsing the JSON.

import hashlib
import hmac
import time
 
def verify(raw_body: bytes, signature_header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(item.split("=", 1) for item in signature_header.split(","))
    timestamp = int(parts["t"])
    if abs(time.time() - timestamp) > tolerance:
        return False
    signed = f"{timestamp}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts["v1"])

Use a constant-time comparison such as hmac.compare_digest. Rejecting old timestamps protects you against replayed requests. See Signatures for Node.js and PHP versions.

Retries

A delivery succeeds when your endpoint answers with a 2xx status. Any other status, a connection error, or a timeout (about 10 seconds) counts as a failure, and the delivery is retried after 10 seconds, 30 seconds, 1 minute, 5 minutes, 30 minutes, 2 hours, and 6 hours. After 8 failed attempts in total (roughly 8.5 hours) the delivery is abandoned.

Answer quickly with a 200 and process the event asynchronously if the work takes longer.

Delivery ordering

Ordering is not guaranteed, and a retried delivery can arrive after a later event. Use created_at in the body, not the time you received it, to order events. Use the event id to ignore duplicates.

Testing

Point an endpoint at a request inspector such as webhook.site to see the exact headers and body before wiring your receiver.

On this page