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

# Webhooks

> How platform partners receive a signed HTTPS POST when a scan finishes or a score or sentiment threshold is crossed.

Webhooks push results to your system, so you do not have to poll for them. Surfais sends a signed HTTPS `POST` to a URL you register, on the day a scan finishes.

<Note>
  Webhooks are for **platform-partner keys only**. An organisation key gets `403 partner_only` on every webhook route. Every call that creates, changes or deletes something also needs the key's `write` scope. Without it the answer is `403 write_scope_required`. See [Authentication](/api/authentication).
</Note>

You work with three resources:

* An **endpoint** is an HTTPS URL of yours, plus the secret Surfais signs its requests with.
* A **subscription** tells an endpoint which event type to receive, for which organisations and own brands, and at what threshold.
* A **delivery** is one event queued for one endpoint. It keeps the attempt count and the outcome of the last attempt, so you can inspect it and replay it.

There are four [event types](/api/webhooks/events): `scan.completed`, `score.threshold_crossed`, `sentiment.threshold_crossed` and the `webhook.test` verification ping.

## Set up an endpoint

<Steps>
  <Step title="Register an endpoint">
    Call `POST /webhook-endpoints` with your receiver's `url` and an optional `description`.

    ```bash cURL theme={null}
    curl -X POST https://api.surfais.com/v1/webhook-endpoints \
      -H "Authorization: Bearer $SURFAIS_API_KEY" \
      -H "Content-Type: application/json" \
      -H "Idempotency-Key: $(uuidgen)" \
      -d '{
        "url": "https://hooks.partner.example/surfais",
        "description": "Production receiver"
      }'
    ```

    Replace the URL with your own. It must:

    * use `https`
    * contain no credentials (no `user:password@`)
    * resolve to a public address

    A URL that breaks a rule is refused with `400 validation_error`, and `details[].code` names the rule: `url_invalid` (not an absolute URL), `url_not_https`, `url_has_credentials` or `url_not_public`. A private or cloud-metadata address is `url_not_public`. So is a hostname that does not resolve in time.

    The body is strict. Any field other than `url` and `description` is refused with `400 validation_error`, so you cannot choose your own secret.

    The `201` response carries the endpoint and its signing `secret`.

    The secret is `whsec_` followed by 43 URL-safe characters: letters, digits, `-` and `_`.

    ```json theme={null}
    {
      "data": {
        "id": "e5f1a9c3-2d6b-4f80-a7c4-1b9e3d5f7a20",
        "url": "https://hooks.partner.example/surfais",
        "status": "pending_verification",
        "description": "Production receiver",
        "consecutive_failures": 0,
        "last_success_at": null,
        "last_failure_at": null,
        "created_at": "2026-09-20T09:14:03Z",
        "updated_at": "2026-09-20T09:14:03Z",
        "secret": "whsec_…"
      }
    }
    ```

    <Warning>
      The `secret` is **shown once**. No later call returns it. Store it in your secret manager before you do anything else. If the response is lost, retry with the same `Idempotency-Key` to get the same response again. See [Idempotency](/api/idempotency).
    </Warning>
  </Step>

  <Step title="Subscribe to events">
    Call `POST /webhook-endpoints/{endpointId}/subscriptions` once per event type. Send `event_type`, an optional `org_id` and `brand_id` to narrow the scope, and the thresholds the event type takes. This body is strict too.

    ```bash cURL theme={null}
    # Every scan of every own brand, in every organisation linked to you
    curl -X POST https://api.surfais.com/v1/webhook-endpoints/e5f1a9c3-2d6b-4f80-a7c4-1b9e3d5f7a20/subscriptions \
      -H "Authorization: Bearer $SURFAIS_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{ "event_type": "scan.completed" }'

    # An AIS Score drop of 5 points or more, in one organisation
    curl -X POST https://api.surfais.com/v1/webhook-endpoints/e5f1a9c3-2d6b-4f80-a7c4-1b9e3d5f7a20/subscriptions \
      -H "Authorization: Bearer $SURFAIS_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "event_type": "score.threshold_crossed",
        "org_id": "7b1e6c1a-3f52-4d0e-9a57-2c8f1e0b4d11",
        "min_delta": 5,
        "direction": "drop"
      }'
    ```

    Each call answers `201` with the subscription:

    ```json theme={null}
    {
      "data": {
        "id": "9f3b7d21-6a4c-4e58-b1d9-0c7e5a3f8b62",
        "endpoint_id": "e5f1a9c3-2d6b-4f80-a7c4-1b9e3d5f7a20",
        "event_type": "score.threshold_crossed",
        "org_id": "7b1e6c1a-3f52-4d0e-9a57-2c8f1e0b4d11",
        "brand_id": null,
        "min_delta": 5,
        "direction": "drop",
        "sentiment_floor": null,
        "active": true,
        "created_at": "2026-09-20T09:16:40Z"
      }
    }
    ```

    You can subscribe before the endpoint is verified. Nothing is sent for a subscription until its endpoint is `active`. The rules for each event type are in [Subscription rules](#subscription-rules).
  </Step>

  <Step title="Verify the endpoint">
    A new endpoint starts in `pending_verification` and receives only test pings. Deploy your receiver, make sure it [verifies signatures](/api/webhooks/verify-signatures), then ask for a ping:

    ```bash cURL theme={null}
    curl -X POST https://api.surfais.com/v1/webhook-endpoints/e5f1a9c3-2d6b-4f80-a7c4-1b9e3d5f7a20/test \
      -H "Authorization: Bearer $SURFAIS_API_KEY"
    ```

    The `202` response means the ping is queued, not that it has arrived:

    ```json theme={null}
    {
      "data": {
        "event_id": "evt_01M2Z1VZCWX4G7Q2N8V5D1F9K6",
        "delivery_id": "f1d7b3a9-5c2e-4e86-8a40-6b9c2d0e7f35",
        "endpoint_id": "e5f1a9c3-2d6b-4f80-a7c4-1b9e3d5f7a20",
        "status": "queued"
      }
    }
    ```

    Surfais sends a signed [`webhook.test`](/api/webhooks/events) event to your URL. When your receiver answers with a `2xx`, the endpoint becomes `active`. Read `status` on `GET /webhook-endpoints/{endpointId}` to confirm, or look the ping up in the [delivery log](/api/webhooks/delivery#the-delivery-log).

    Real events are only queued for `active` endpoints. A ping that fails leaves the endpoint in `pending_verification`. Enough failed pings in a row make it `failing`, like any other endpoint, and the fix is the same: a ping your receiver answers with a `2xx`. The failed ping is retried like any other delivery, and every call to `POST …/test` sends a new ping with its own event id.
  </Step>
</Steps>

## Endpoint states

`status` on an endpoint is one of:

| Status                 | What it means                                                                                                                                                                                                                                                                                                                                            |
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `pending_verification` | Every new endpoint starts here. It receives only `webhook.test` pings. It becomes `active` when your receiver answers a ping with a `2xx`.                                                                                                                                                                                                               |
| `active`               | Events are delivered.                                                                                                                                                                                                                                                                                                                                    |
| `failing`              | The circuit breaker tripped: attempts to this endpoint failed repeatedly, one after another, with no success in between. New events are no longer queued for it. Deliveries already queued keep retrying on their [schedule](/api/webhooks/delivery#retry-schedule). The first `2xx` makes it `active` again, whether it answers a retry or a test ping. |
| `disabled`             | Set by Surfais. No API call sets or clears it, and `POST …/test` answers `409 endpoint_disabled`. Contact [support](/api/support).                                                                                                                                                                                                                       |

<Warning>
  Events from scans that finish while an endpoint is `failing` are never queued for it, and they are not sent later. After an outage, read what you missed from the API. Start with `GET /orgs/{orgId}/brands/summary`.
</Warning>

A `failing` endpoint recovers by itself when one of its queued retries gets a `2xx`. To recover sooner than the next retry, or when nothing is left in the queue, send a test ping.

`GET /webhook-endpoints/{endpointId}` also returns `consecutive_failures` (failed attempts since the last success, across all deliveries to the endpoint), `last_success_at` and `last_failure_at`.

## Subscription rules

Each event type takes different threshold fields. A body that breaks a rule is refused with `400 validation_error`, and `details[].code` names the rule.

| `event_type`                  | Threshold fields                                                                                              | Refused with `details[].code`                                                                            |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| `scan.completed`              | None. It fires on every scan.                                                                                 | `threshold_not_applicable` if you send `min_delta`, `direction` or `sentiment_floor`                     |
| `score.threshold_crossed`     | `min_delta` is required and must be 0 or more. `direction` is `drop`, `rise` or `any`, and defaults to `any`. | `min_delta_required` without `min_delta`; `sentiment_floor_not_applicable` if you send `sentiment_floor` |
| `sentiment.threshold_crossed` | `min_delta`, `sentiment_floor` (0 to 100), or both. `direction` as above.                                     | `threshold_required` if you send neither                                                                 |

`webhook.test` cannot be subscribed to. It is sent only by `POST …/test`.

`min_delta` is the smallest move that fires, inclusive, in the subscription's `direction`. `sentiment_floor` fires when mean sentiment falls below the floor. [Threshold semantics](/api/webhooks/events#threshold-semantics) has the detail.

### Scope

* **No `org_id`**: every organisation linked to your partner account. The set is worked out again at each scan, so a newly linked organisation is included and a revoked one stops at once.
* **`org_id`**: one organisation. It must be linked to your partner account. Anything else is `404 not_found`. The API never confirms that an organisation exists.
* **`brand_id`**: one own brand of that organisation. An id that is not an own brand of `org_id` is `404 not_found`, and that includes a competitor's id. `brand_id` needs `org_id`. Without it the answer is `400 validation_error` with `details[].code` set to `org_id_required`.

When several subscriptions on one endpoint match the same event, the endpoint gets **one** delivery. See [One delivery per endpoint](/api/webhooks/events#one-delivery-per-endpoint).

### List and delete subscriptions

`GET /webhook-endpoints/{endpointId}/subscriptions` lists an endpoint's subscriptions. `DELETE /webhook-endpoints/{endpointId}/subscriptions/{subscriptionId}` answers `204`. Deliveries already queued from a deleted subscription are kept and still sent. Their `subscription_id` becomes `null`.

## Rotate the secret

`POST /webhook-endpoints/{endpointId}/rotate-secret` replaces the secret immediately. The `200` response has the same shape as the `201` above and carries the new `secret`, shown once.

```bash cURL theme={null}
# Keep this value: a retry of this call with the same key replays the same new secret.
IDEMPOTENCY_KEY=$(uuidgen)

curl -X POST https://api.surfais.com/v1/webhook-endpoints/e5f1a9c3-2d6b-4f80-a7c4-1b9e3d5f7a20/rotate-secret \
  -H "Authorization: Bearer $SURFAIS_API_KEY" \
  -H "Idempotency-Key: $IDEMPOTENCY_KEY"
```

Attempts that start after the call are signed with the new secret. An attempt already in flight finishes with the old one.

<Warning>
  Always send an `Idempotency-Key` with this call. If the response is lost and you retry with the **same** key, you get the first response again, with the same secret. Without a key, every retry rotates again, and a secret you did receive may already have been replaced. See [Idempotency](/api/idempotency).
</Warning>

## Change a URL or delete an endpoint

There is no update route. To change a URL, register a new endpoint, give it the same subscriptions, verify it, and then delete the old one. While both are active, each receives its own delivery of every event, with the same event id, so [dedupe on the event id](/api/webhooks/delivery#at-least-once-delivery).

`DELETE /webhook-endpoints/{endpointId}` answers `204`. It is permanent. It removes the endpoint's subscriptions and its delivery history with it.

`GET /webhook-endpoints` lists your endpoints. It never returns a secret. Another partner's endpoint, subscription or delivery id is `404 not_found`, the same answer as an id that never existed.

## Next steps

<CardGroup cols={2}>
  <Card title="Verify signatures" icon="shield-halved" href="/api/webhooks/verify-signatures">
    The signing algorithm, with complete Node.js and Python receivers.
  </Card>

  <Card title="Webhook events" icon="list" href="/api/webhooks/events">
    The envelope, each event's `data`, and how thresholds are judged.
  </Card>

  <Card title="Delivery, retries and replay" icon="rotate" href="/api/webhooks/delivery">
    At-least-once delivery, the retry schedule, the delivery log and replay.
  </Card>

  <Card title="Idempotency" icon="key" href="/api/idempotency">
    Retry a write safely, including a secret rotation.
  </Card>
</CardGroup>
