> ## Documentation Index
> Fetch the complete documentation index at: https://docs.surfais.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhook events

> The envelope every webhook shares, the data each of the four event types carries, and how score and sentiment thresholds are judged.

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

```json theme={null}
{
  "id": "evt_01M319GVQ6C3N7P1D9F5H2K8A4",
  "type": "score.threshold_crossed",
  "api_version": "v1",
  "occurred_at": "2026-09-21T06:12:44.902Z",
  "partner_id": "3d9a2f64-8c1b-4e7a-b5d2-6f0e9c4a1b73",
  "org_id": "7b1e6c1a-3f52-4d0e-9a57-2c8f1e0b4d11",
  "org_external_ref": "GROUP-17",
  "brand_id": "c2a4e8f0-6b1d-4c3a-8e5f-9d7b2a1c4e60",
  "brand_name": "Hotel Aurora",
  "brand_external_ref": "PROP-01342",
  "country": null,
  "data": {
    "metric": "ais_score",
    "score_version": "v2",
    "run_date": "2026-09-21",
    "previous": { "value": 62.4, "date": "2026-09-17" },
    "current": { "value": 55.1, "date": "2026-09-21" },
    "delta": -7.3,
    "direction": "drop"
  },
  "threshold": {
    "subscription_id": "9f3b7d21-6a4c-4e58-b1d9-0c7e5a3f8b62",
    "min_delta": 5,
    "direction": "drop",
    "sentiment_floor": null
  }
}
```

| Field                | Type             | Description                                                                                                                                                                                               |
| -------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`                 | string           | `evt_` followed by a ULID. Every retry and every replay of the event carries the same `id`, and it equals the `X-Surfais-Event-Id` header. [Dedupe on it](/api/webhooks/delivery#at-least-once-delivery). |
| `type`               | string           | `scan.completed`, `score.threshold_crossed`, `sentiment.threshold_crossed` or `webhook.test`.                                                                                                             |
| `api_version`        | string           | Always `v1`.                                                                                                                                                                                              |
| `occurred_at`        | string           | RFC 3339 timestamp of when Surfais produced the event. It does not change on a retry.                                                                                                                     |
| `partner_id`         | string           | Your partner account's id.                                                                                                                                                                                |
| `org_id`             | string or `null` | The organisation the event is about. `null` on `webhook.test`.                                                                                                                                            |
| `org_external_ref`   | string or `null` | **Your** reference for the organisation: the `external_ref` you set with `PATCH /orgs/{orgId}/link`. `null` if you never set one.                                                                         |
| `brand_id`           | string or `null` | The own brand the event is about. `null` on `webhook.test`.                                                                                                                                               |
| `brand_name`         | string or `null` | The own brand's name.                                                                                                                                                                                     |
| `brand_external_ref` | string or `null` | **Your** reference for the own brand: the `external_ref` you gave it when you [provisioned the property](/api/guides/provision-a-property). `null` if it has none.                                        |
| `country`            | `null`           | Reserved. Always `null` in v1.                                                                                                                                                                            |
| `data`               | object           | The event's own fields. They differ by `type` and are described below.                                                                                                                                    |
| `threshold`          | object           | Only on the two threshold events. The subscription rule that matched, frozen when the event was produced, so every retry carries the same block.                                                          |

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](/api/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.

```json theme={null}
{
  "id": "evt_01M319GV4Y8T5V6W7Y8ZQR4X2M",
  "type": "scan.completed",
  "api_version": "v1",
  "occurred_at": "2026-09-21T06:12:44.318Z",
  "partner_id": "3d9a2f64-8c1b-4e7a-b5d2-6f0e9c4a1b73",
  "org_id": "7b1e6c1a-3f52-4d0e-9a57-2c8f1e0b4d11",
  "org_external_ref": "GROUP-17",
  "brand_id": "c2a4e8f0-6b1d-4c3a-8e5f-9d7b2a1c4e60",
  "brand_name": "Hotel Aurora",
  "brand_external_ref": "PROP-01342",
  "country": null,
  "data": {
    "run_date": "2026-09-21",
    "ais_score": 55.1,
    "previous_score": 62.4,
    "avg_sentiment": 47.9,
    "prompts_scanned": 24,
    "scores_pending": null
  }
}
```

| `data` field      | Type                        | Description                                                                                                             |
| ----------------- | --------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `run_date`        | string                      | The scan date, `YYYY-MM-DD`.                                                                                            |
| `ais_score`       | number or `null`            | The brand's [AIS Score](/metrics/ais-score) out of 100 on this scan date, rounded to one decimal place.                 |
| `previous_score`  | number or `null`            | The AIS Score on the previous scored date, rounded to one decimal place. `null` before the brand's second scored date.  |
| `avg_sentiment`   | number or `null`            | Mean [sentiment](/metrics/sentiment) of the brand's mentions on this scan date, 0 to 100, rounded to one decimal place. |
| `prompts_scanned` | integer or `null`           | The number of prompts with at least one finalised run on this scan date.                                                |
| `scores_pending`  | `"alias_recount"` or `null` | Set when the metric fields are `null` because the numbers are being recomputed.                                         |

`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](/setup/brand-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.

```json theme={null}
{
  "run_date": "2026-09-21",
  "ais_score": null,
  "previous_score": null,
  "avg_sentiment": null,
  "prompts_scanned": null,
  "scores_pending": "alias_recount"
}
```

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.

<Note>
  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.
</Note>

`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](#the-envelope) is a `score.threshold_crossed` event.

| `data` field    | Type   | Description                                                                                                                       |
| --------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------- |
| `metric`        | string | Always `ais_score`.                                                                                                               |
| `score_version` | string | The scoring model that produced **both** readings. See [The score version is an opaque tag](#the-score-version-is-an-opaque-tag). |
| `run_date`      | string | The scan date, `YYYY-MM-DD`.                                                                                                      |
| `previous`      | object | `value` and `date` of the earlier reading.                                                                                        |
| `current`       | object | `value` and `date` of this scan's reading.                                                                                        |
| `delta`         | number | The move between the two readings, rounded to one decimal place.                                                                  |
| `direction`     | string | `drop` or `rise`: the way the score actually moved.                                                                               |

| `threshold` field | Type             | Description                                        |
| ----------------- | ---------------- | -------------------------------------------------- |
| `subscription_id` | string           | The subscription that matched.                     |
| `min_delta`       | number or `null` | That subscription's `min_delta`.                   |
| `direction`       | string           | That subscription's rule: `drop`, `rise` or `any`. |
| `sentiment_floor` | number or `null` | Always `null` on a score 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.

```json theme={null}
{
  "id": "evt_01M319GVY7B6R2T9W4E8J1M5S3",
  "type": "sentiment.threshold_crossed",
  "api_version": "v1",
  "occurred_at": "2026-09-21T06:12:45.127Z",
  "partner_id": "3d9a2f64-8c1b-4e7a-b5d2-6f0e9c4a1b73",
  "org_id": "7b1e6c1a-3f52-4d0e-9a57-2c8f1e0b4d11",
  "org_external_ref": "GROUP-17",
  "brand_id": "c2a4e8f0-6b1d-4c3a-8e5f-9d7b2a1c4e60",
  "brand_name": "Hotel Aurora",
  "brand_external_ref": "PROP-01342",
  "country": null,
  "data": {
    "metric": "avg_sentiment",
    "run_date": "2026-09-21",
    "previous": { "value": 52.4, "date": "2026-09-17", "mentions": null },
    "current": { "value": 47.9, "date": "2026-09-21", "mentions": 38 },
    "delta": -4.5,
    "direction": "drop"
  },
  "threshold": {
    "subscription_id": "4c8e1a57-9d3f-4b26-8a70-5e2f9c6b1d34",
    "min_delta": null,
    "direction": "any",
    "sentiment_floor": 50,
    "floor_breached": true
  }
}
```

`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`.

```json theme={null}
{
  "id": "evt_01M2Z1VZCWX4G7Q2N8V5D1F9K6",
  "type": "webhook.test",
  "api_version": "v1",
  "occurred_at": "2026-09-20T09:20:31.644Z",
  "partner_id": "3d9a2f64-8c1b-4e7a-b5d2-6f0e9c4a1b73",
  "org_id": null,
  "org_external_ref": null,
  "brand_id": null,
  "brand_name": null,
  "brand_external_ref": null,
  "country": null,
  "data": {
    "endpoint_id": "e5f1a9c3-2d6b-4f80-a7c4-1b9e3d5f7a20",
    "message": "Surfais webhook verification ping"
  }
}
```

`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](/api/guides/sync-prompts).
