Skip to main content
Surfais sends four event types. Three describe a scan: 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 accept null in every metric field. There are two cases, and scores_pending tells them apart.
  • scores_pending is null. Scoring degraded for this brand on this date and no score was stored. ais_score and avg_sentiment are both null. The scan still finished, so the event is still sent. avg_sentiment is also null on a date with no scored sentiment at all.
  • scores_pending is "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_score and avg_sentiment are null, and prompts_scanned can be null too.
When you see "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’s min_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 least min_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:
  • mentions is the number of mentions that carried a sentiment on that date: the evidence behind value. previous.mentions is always null, because the count behind an earlier mean is not kept. current.mentions is an integer, or null when the count could not be established. value and the crossing are still valid when mentions is null.
  • threshold.floor_breached is present when the subscription has a sentiment_floor. It is true when this delivery was caused by the floor: the mean is below the floor now and was at or above it on the previous date.
On a day when one platform mostly failed, that platform is left out of the mean but its mentions are still counted. Read mentions as an upper bound on the sample behind value. On a healthy scan the two agree.

webhook.test

Sent only when you call POST /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 a min_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. country is reserved in the envelope and is always null.
  • A competitor-overtaken event.
  • A prompt-sync-completed event. Read the result of PUT /orgs/{orgId}/brands/{brandId}/prompts from its response. See Sync prompts.