scan.completed, score.threshold_crossed and sentiment.threshold_crossed. The fourth, webhook.test, is the verification ping. All four share one envelope.
Events are produced when a scan finishes, after that date’s scores are stored. Each scan event type is produced at most once per own brand per scan date, and every endpoint with a matching subscription gets one delivery of it. A second scan of the same brand on the same date does not produce a second set of events, although it does replace that date’s numbers: after it, read the current values from the API.
The envelope
Use
org_external_ref and brand_external_ref to route an event to the right record in your system without a lookup.
Additive changes, such as a new field, ship within v1. Write your parser so that it ignores fields it does not know. See Versioning.
scan.completed
Sent once per own brand per scan date, for every own brand the scan measured. A brand is measured when at least one of its prompts was run in that scan, even if that run failed. A brand whose prompts were all stopped before they ran gets no event. That happens when an organisation uses up its daily budget part-way through a scan.ais_score and avg_sentiment are the numbers GET /orgs/{orgId}/brands/summary serves for the same date, rounded to one decimal place — so an event and a read of the API at that moment agree to one decimal place. There is no threshold block.
When the metric fields are null
Your code must acceptnull in every metric field. There are two cases, and scores_pending tells them apart.
scores_pendingisnull. Scoring degraded for this brand on this date and no score was stored.ais_scoreandavg_sentimentare bothnull. The scan still finished, so the event is still sent.avg_sentimentis alsonullon a date with no scored sentiment at all.scores_pendingis"alias_recount". A change to the brand’s aliases has triggered a recount of its mentions, sentiment and scores. The numbers stored for this date are about to be rewritten, so the event withholds them:ais_score,previous_scoreandavg_sentimentarenull, andprompts_scannedcan benulltoo.
"alias_recount", do not store the nulls as results. Read the settled numbers from GET /orgs/{orgId}/brands/summary later, or take them from the next scan’s event. The event is not sent again for that date.
A threshold event needs a current reading. When
ais_score is null there is no score.threshold_crossed for that scan, and when avg_sentiment is null there is no sentiment.threshold_crossed. The suppressed events are not sent later, once the numbers settle. The next scan compares against the settled history.prompts_scanned can also be null on its own, next to real scores. That happens when the brand’s data changed while the event was being written, for example because a prompt was deleted, and the count could not be tied to the same data as avg_sentiment.
score.threshold_crossed
Sent when the brand’s AIS Score moved between two consecutive scored dates by at least a subscription’smin_delta, in the subscription’s direction. The example at the top of this page is a score.threshold_crossed event.
Score thresholds are judged on the brand’s overall AIS Score only.
sentiment.threshold_crossed
Sent when the brand’s mean sentiment moved by at leastmin_delta, or fell under the subscription’s sentiment_floor, between two consecutive scan dates that each recorded a mean.
data has the same fields as a score event, without score_version, and with metric set to avg_sentiment. Two things differ:
mentionsis the number of mentions that carried a sentiment on that date: the evidence behindvalue.previous.mentionsis alwaysnull, because the count behind an earlier mean is not kept.current.mentionsis an integer, ornullwhen the count could not be established.valueand the crossing are still valid whenmentionsisnull.threshold.floor_breachedis present when the subscription has asentiment_floor. It istruewhen this delivery was caused by the floor: the mean is below the floor now and was at or above it on the previous date.
mentions as an upper bound on the sample behind value. On a healthy scan the two agree.
webhook.test
Sent only when you callPOST /webhook-endpoints/{endpointId}/test. It is about no organisation and no brand, and it has no threshold block. Your 2xx makes a pending_verification or failing endpoint active.
data.endpoint_id names the endpoint that was pinged. Verify the signature on a test ping exactly as you do on a real event.
Threshold semantics
delta is rounded, the rule is not. delta is current.value minus previous.value, rounded to one decimal place for display. The rule compares the unrounded difference with min_delta, inclusively. So a move of 4.96 shows as 5 but does not fire a min_delta of 5, and a move of 0.04 fires a min_delta of 0.01 although its delta reads 0. previous.value and current.value are rounded too, each on its own, so on a sentiment event delta can differ by 0.1 from the difference of the two values you see. An event can carry "delta": 0 with "direction": "drop".
direction is the actual move. data.direction is drop or rise, taken from the sign of the unrounded difference. The subscription’s own rule is in threshold.direction, which can be any. Two equal readings are a flat move, and a flat move never fires.
A floor breach is always a drop. A delivery with floor_breached: true has "direction": "drop", even when the fall is too small to show in delta.
“Previous” is the most recent earlier date that carries a value. For a score event, that is the most recent earlier date with an AIS Score. A date on which scoring degraded is skipped. For a sentiment event, it is the most recent earlier scan date that recorded a mean. previous on GET /orgs/{orgId}/brands/summary is the date before the latest whether or not it carries a score, so on the scan after a degraded day the two can name different dates.
A score move across two scoring models, or two measurement panels, never fires. A score event is sent only when both readings were produced by the same scoring model and measured on the same panel of AI models. Readings that differ on either are not comparable. A move where either was not recorded never fires either. This is the rule GET /orgs/{orgId}/brands/summary applies when it returns "delta": null with a delta_reason, so polling and webhooks withhold the same comparisons. Sentiment events are not gated this way: a sentiment move is compared whenever both dates recorded a mean.
One delivery per endpoint
Several subscriptions on one endpoint can match the same event, for example amin_delta of 5 and a min_delta of 10 on the same brand. The endpoint then gets one delivery, not one per subscription. It carries the threshold block of the first subscription that matched. Do not rely on which one that is. If you need to tell rules apart, put them on separate endpoints: each endpoint gets its own delivery, with its own threshold.
The same event delivered to two of your endpoints has the same id on both.
The score version is an opaque tag
score_version names the scoring model that produced both readings. It is read from what was stored with each reading, not stamped when the event is sent. A body is written once and sent again unchanged, so a delivery that is still retrying, or one you replay later, carries the version from when it was written. That can be older than the model Surfais runs today.
Treat the value as an opaque tag. Compare readings only within one tag, and do not reject an event because the tag is not the one you expected.
Not in v1
- Per-country events.
countryis reserved in the envelope and is alwaysnull. - A competitor-overtaken event.
- A prompt-sync-completed event. Read the result of
PUT /orgs/{orgId}/brands/{brandId}/promptsfrom its response. See Sync prompts.