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

# Webhooks

> Register a webhook to receive discovery revisions as push events instead of polling.

Webhooks push events to a URL you control instead of you polling for them. They're the server-to-server
equivalent of the discovery event stream, for callers that run batch jobs or don't hold a connection
open.

<Note>
  The only event a subscription can register today is `discovery.revision`. Media buy decisions, task
  completion and creative reviews don't send webhooks yet; poll [tasks](/guides/tasks) and media buy
  status for those. Audience and syndication outcomes arrive as notifications, and an audience sync can
  call your URL directly: see [Audience and syndication events](#audience-and-syndication-events).
</Note>

All paths are under `https://api.semicola.com/api/v2/buyer`.

## Register a subscription

```bash theme={null}
curl -X POST "https://api.semicola.com/api/v2/buyer/webhook-subscriptions" \
  -H "Authorization: Bearer $SEMICOLA_API_KEY" \
  -H "X-Account-Id: $ACCOUNT_ID" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/webhooks/semicola",
    "secret": "a-secret-at-least-16-characters-long",
    "eventTypes": ["discovery.revision"]
  }'
```

| Field        | Notes                                                             |
| ------------ | ----------------------------------------------------------------- |
| `url`        | The endpoint Semicola `POST`s events to (up to 2,048 characters). |
| `secret`     | 16–256 characters. Used to sign every delivery. Keep it private.  |
| `eventTypes` | One or more event types; today only `discovery.revision`.         |

```json theme={null}
{
  "data": {
    "subscription": {
      "id": "…",
      "url": "https://example.com/webhooks/semicola",
      "eventTypes": ["discovery.revision"],
      "status": "active",
      "failureCount": 0,
      "lastSuccess": null,
      "lastFailure": null,
      "createdAt": "2026-11-01T00:00:00.000Z",
      "updatedAt": "2026-11-01T00:00:00.000Z"
    }
  },
  "error": null
}
```

The secret is stored encrypted and never echoed back, so keep your own copy when you register.

## List and delete

* `GET /webhook-subscriptions` returns every subscription on the account, newest first, without secrets.
* `DELETE /webhook-subscriptions/{id}` stops deliveries at once and returns `{ "success": true, "id": … }`.
  An id that doesn't exist, or belongs to another account, returns `404`.

## Verify deliveries

Every delivery carries `X-Webhook-Signature`: the hex-encoded HMAC-SHA256 of the exact request body,
keyed with your subscription's `secret`. Recompute it over the raw body and compare before trusting the
payload.

```javascript theme={null}
import crypto from 'node:crypto';

function isValidSignature(rawBody, signatureHeader, secret) {
  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
  return (
    expected.length === signatureHeader.length &&
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signatureHeader))
  );
}
```

There's no timestamp header and no `sha256=` prefix, and the signed message is the body alone. Hash the
raw bytes you received, not a re-serialized copy: key order or whitespace changes the signature.

## Audience and syndication events

These outcomes are raised as [notifications](/guides/notifications) (in-app, and by email if you turn
email on for the type):

| Type                    | When                                                                                                                                      |
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `audience.synced`       | An audience sync finished. `data` has the `operationId`, the `advertiserId` and the per-audience results.                                 |
| `audience.sync_failed`  | An audience sync failed.                                                                                                                  |
| `syndication.completed` | An agent took a shared resource ("Shared audience … with …"). `data.syndication` is the [status record](/buy/syndication#status-records). |
| `syndication.failed`    | A share failed; the message carries the reason.                                                                                           |

Webhook subscriptions can't list these types yet (`eventTypes` accepts only `discovery.revision`), so
read them from notifications or poll [`syndication-status`](/buy/syndication#query-status).

### The audience sync callback

An audience sync (`POST /advertisers/{advertiserId}/audiences/sync` or `sync_audiences`) can call
your URL when it finishes: pass `pushNotificationConfig`. The details are under
[Audiences](/buy/property-lists#the-sync-callback).

## Delivery and failures

Each event is one `POST` with a 5-second timeout. A response outside `2xx` is a failure, and it isn't
retried: discovery revisions are time-sensitive, and a late copy would be stale.

* Each failure adds one to `failureCount`; a success resets it to zero.
* After 10 consecutive failures the subscription's `status` becomes `failed` and deliveries stop.
  Check `status`, `failureCount` and `lastFailure`, then delete and re-register once your endpoint is
  healthy.

Treat webhooks as an optimization, not your only source of truth. An endpoint that was down can catch
up by reading the discovery's products (`GET /discovery/{id}/products`).

## `discovery.revision`

Fires when a progressive discovery's snapshot advances: the same moment the event stream
(`GET /discovery/{id}/events`) emits a revision.

```json theme={null}
{
  "event": "discovery.revision",
  "deliveryId": "…",
  "occurredAt": "2026-11-01T00:00:12.500Z",
  "data": {
    "discoveryId": "…",
    "campaignId": "…",
    "revision": 2,
    "resultsComplete": false,
    "pendingAgents": ["sample-publisher-network"],
    "sellersRequested": 4,
    "sellersResponded": 3
  }
}
```

`pendingAgents` lists the sellers still answering, by slug. The payload says how far the discovery
has got; read the products themselves from the discovery. Each revision goes to the subscriptions that
are active when it happens; earlier revisions aren't replayed to a new subscription.

## Related

* [Product discovery](/buy/product-discovery)
* [Tasks](/guides/tasks)
