Webhook Events
The event types SurfacedBy delivers via outbound webhooks, with their payload fields.
Every webhook delivery is a JSON object with the same envelope. The envelope carries the event id, the event type, when the event was created, whether it is a real or test event, and the event-specific fields under data. Every data object also carries workspace_id and workspace_slug, so you can route the event to the right workspace without a follow-up API call.
Envelope
| Field | Meaning |
|---|---|
id | Event id (a UUID). The same event keeps the same id across retries and replays; deduplicate on it. |
type | Event type. Switch on it in your handler. |
created_at | When the event was created, as an ISO 8601 timestamp with a UTC offset. |
livemode | true for real events, false for events sent with the Send test event button. |
data.workspace_id | The workspace the event belongs to. Stable; route on it. |
data.workspace_slug | The workspace's current URL slug, for human-readable destinations such as channel names or filenames. It changes if the workspace is renamed, and can be null if it could not be resolved. |
All ids in payloads (scan_id, domain_id, alert_id, and so on) are UUIDs. Keys in the delivered body are sorted alphabetically; parse the JSON rather than relying on key order, and verify the signature on the raw bytes.
Event types
| Event | Description |
|---|---|
scan.completed | A check finished and its results are stored. |
scan.failed | A check stopped before it finished. It is final for that check. |
scan.progress | Progress while a check runs, at most one update every five seconds per check. |
alert.triggered | An alert fired for a domain, for example a Visibility Score drop or a competitor passing you. |
citation.changed | The total number of citations changed between two completed checks of a domain. |
domain.added | A domain was added to the workspace, from the dashboard or the Data API. |
competitor.discovered | A check found a new competitor in the AI answers. |
report.ready | A report finished generating. |
Every event about a check carries measurement_scope and measurement_label, which say what kind of check it was: daily_monitoring / Daily Check or full_benchmark / Full Check. See Measurement scope.
Which endpoints receive an event
An event about a domain goes only to endpoints on the same side as the domain. Events about a domain tracked in the dashboard go to the workspace's dashboard endpoints, and only while the workspace is on a paid plan (see Registering an endpoint). Events about a domain registered through the Console go only to that Console account's endpoints; see Console webhooks.
scan.completed
Fires once when a check finishes and its results are stored.
| Field | Type | Meaning |
|---|---|---|
scan_id | string | The check's id. |
domain_id | string | Domain the check ran for. |
domain | string | Domain name. |
measurement_scope | string | daily_monitoring for a Daily Check, full_benchmark for a Full Check. |
measurement_label | string | Daily Check or Full Check. |
visibility_score | integer | Visibility Score (0 to 100) computed from this check's own results when it finished. It can differ slightly from the headline score the dashboard shows; read the API if you need the served value. |
is_first_check | boolean | true when this is the domain's first completed Daily Check or Full Check, the check that brings its first results. |
started_at | string | When the check started (ISO 8601). |
completed_at | string | When the check finished (ISO 8601). |
results_summary.brand_coverage_pct | number | Brand Coverage, as a percentage. On a Full Check it reflects that check's own answers. On a Daily Check it reflects the current state across every tracked prompt, not only the prompts this check re-tested. |
results_summary.total_citations | integer | Total citations across all AI platforms, on the same basis as brand_coverage_pct. |
results_summary.duration_seconds | number | How long the check took. |
results_url | string | Console checks only: the absolute URL of the check's results, https://api.surfacedby.com/api/v1/console/scans/{scan_id}/results. Fetch it with your Console API key. Other checks do not carry this field. |
To fetch the full analytics for the check, call the Data API's analytics endpoint with the scan_id.
scan.failed
Fires when a check stops on an error. A failed check is final: no scan.completed follows for the same scan_id. Scheduled checks continue on their normal cadence.
| Field | Type | Meaning |
|---|---|---|
scan_id | string | The check's id. |
domain_id | string | Domain the check ran for. |
domain | string | Domain name. |
measurement_scope | string | daily_monitoring or full_benchmark. |
measurement_label | string | Daily Check or Full Check. |
error_summary | string | Why the check stopped, as one of the fixed messages below. |
error_summary is always one of these messages, where example.com stands for the domain name:
We could not finish this check.(any failure without a more specific message)This check was cancelled.This check stopped because the domain was removed.This check stopped because the account was deleted.This check stopped because the plan no longer includes checks.This check waited in the queue too long and was stopped.We could not open example.com just now. The site is limiting how often it can be checked. Wait a few minutes, then try again.We could not read enough from example.com to run the check. Make sure the site loads in your browser, then try again.
The last two name the domain, or say "that website" in its place. Match on the first words rather than the whole message if you branch on them.
scan.progress
Progress updates while a check runs, at most one per check every five seconds.
| Field | Type | Meaning |
|---|---|---|
scan_id | string | The check's id. |
domain_id | string | Domain the check ran for. |
domain | string | Domain name. |
measurement_scope | string | daily_monitoring or full_benchmark. |
measurement_label | string | Daily Check or Full Check. |
progress | object | The check's progress (fields below). |
progress carries:
| Field | Type | Meaning |
|---|---|---|
phase | string or null | The current step: preparing, generating_prompts, asking_platforms, reading_citations, scoring, writing_brief, or complete once the check has finished. Null when no named step is running, for example before the first one starts. |
percentage | integer | Progress from 0 to 100. |
phases | array | Every step this check runs, in order, each { "key", "status" } with status pending, running, done, or failed. A Full Check lists preparing, generating_prompts, asking_platforms, reading_citations, scoring, writing_brief; a Daily Check lists asking_platforms and reading_citations. A step made of several parts that run together is done when every part is done, and otherwise failed, running, or pending, in that order of precedence. |
platforms | array or null | One row per AI platform, { "name", "status", "completed", "total" }, once the platform set is known. name is the platform key (for example chatgpt); completed counts answers already stored for that platform. |
prompts_total | integer or null | Prompts the check asks each platform. |
platforms_total | integer or null | AI platforms the check asks. |
answers_completed | integer or null | Answers stored so far across all platforms. |
answers_total | integer or null | Total answers the check will collect. |
google_aio | string or null | State of the Google AI Overviews check (pending, running, done, failed), or null when the check does not include it. |
alert.triggered
Fires when an alert is generated. See Alerts for what each alert means.
| Field | Type | Meaning |
|---|---|---|
alert_id | string | Alert id. |
alert_type | string | One of first_scan_complete, visibility_drop, brand_coverage_change, platform_drop, prompt_set_changed, competitor_surpass, competitor_gap_widened, competitor_faded, competitor_passed, new_competitor, cited_in_ai, site_health_issue, site_health_resolved, theme_emerged, theme_decayed. |
severity | string | info, warning, or critical. |
title | string | Alert title, as shown in the dashboard. |
summary | string | Short description of what changed. |
domain_id | string | Domain the alert is about. |
scan_id | string or null | The check that produced the alert, when there is one. |
details | object | Type-specific context, such as previous and current values, the platform key, or the competitor. Coverage values are brand_coverage_pct and previous_brand_coverage_pct; score values are current_score and previous_score. Alerts that compare two checks carry measurement_scope and measurement_label. cited_in_ai carries the prompt and the platforms. Theme alerts (theme_emerged, theme_decayed) include theme_slug, the Brand Theme's identifier, and theme_name, its display name. They also include baseline_coverage_pct, coverage_pct, compared_responses (shared answer cells per check), and the supporting baseline_scan_ids and confirmation_scan_ids. Fading compares the lowest of three baselines with the highest of two confirmations; emergence compares the highest baseline with the lowest confirmation, so every check supports the change. Site health alerts carry check_key, check_label, finding_key (the same value on the issue and on its resolution) and the evidence for the finding. |
citation.changed
Fires when a check completes and the domain's total citation count differs from the previous completed check (Daily or Full). It does not fire for a domain's first check or when the count is unchanged.
| Field | Type | Meaning |
|---|---|---|
scan_id | string | The check that just completed. |
previous_scan_id | string | The previous completed check, used for the comparison. |
domain_id | string | Domain id. |
domain | string | Domain name. |
previous_count | integer | Citation count on the previous check. |
new_count | integer | Citation count on this check. |
delta | integer | new_count minus previous_count. |
measurement_scope | string | daily_monitoring or full_benchmark, for this check. |
measurement_label | string | Daily Check or Full Check. |
previous_measurement_scope | string | Scope of the previous check. |
comparison_scope | string | same_scope when both checks have the same scope, mixed_scope when one is a Daily Check and the other a Full Check. Treat mixed_scope deltas as directional only. |
domain.added
Fires when a domain is added to a workspace.
| Field | Type | Meaning |
|---|---|---|
domain_id | string | The new domain's id. |
domain | string | Domain name. |
source | string | dashboard when added in the dashboard, api when added through the Data API. |
competitor.discovered
Fires once for each competitor newly found for a domain at the end of a check.
| Field | Type | Meaning |
|---|---|---|
competitor_id | string | Competitor id. |
competitor_domain | string | The competitor's domain. |
domain_id | string | The tracked domain the competitor was found for. |
domain | string | Tracked domain name. |
discovery_source | string | detected when SurfacedBy found the competitor in AI answers, added_by_you when someone in your workspace entered it. |
report.ready
Fires when a report has been generated and is ready to fetch.
| Field | Type | Meaning |
|---|---|---|
report_id | string | Report id. |
domain_id | string | Domain the report covers. |
title | string | Report title. |
audience_preset | string | The audience preset the report was written for, for example executive, growth, or client. |
generated_by | string | manual when someone generated the report, scheduled when a report schedule produced it. |
Order
Events are not guaranteed to arrive in order, and retries can deliver an older event after a newer one. Use created_at when order matters to your handler.