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

# SSE & Webhooks

> Subscribe a token once, get pushed every verdict change. No polling.

Instead of polling [`/audit-contract`](/api-reference/audit-contract) to keep a token's verdict
fresh, **subscribe it once** and we push every change to you. Whenever a verdict actually moves
(a safe↔unsafe flip, an owner change, a gate or honeypot flipping, or a closed-source token
re-audited on its now-verified source), we deliver it over a live SSE stream or a webhook.

Polling still works exactly as before; push delivery is purely additive. A subscription costs a
one-time **2 credits** per token (only genuinely-new tokens are charged), and any re-audit the
watcher triggers is **free** (included). See [Credits & Billing](/credits).

**Two ways to receive the pushes (use either or both):**

* **Live stream (SSE).** Hold an outbound `GET /api/stream` open and we push changes down it. No
  inbound port to expose: it's a normal long-lived HTTPS request with your API key. See
  [Live stream](#live-stream-sse) below.
* **Webhook.** We `POST` each change to an HTTPS URL you host.

Same events, same per-token subscriptions, same billing. The transport is your choice.

<Note>
  Subscribing and delivery are independent. You can subscribe tokens **without** a stream or webhook
  configured. You're still charged, and you pull the changes yourself from
  [`GET /api/events`](#delivery-guarantees). A webhook or an open stream only controls whether
  changes are **pushed** to you; neither is a prerequisite for subscribing.
</Note>

## Live stream (SSE)

You open an outbound connection to us and we push events over it. **Nothing to expose, no inbound
port.** It's a long-lived HTTPS `GET`, so a locked-down environment accepts it like any other API
call.

**Just connect.** Hold the request open and events arrive as they happen. Nothing to enable;
it's a plain authenticated endpoint, and you only receive changes for tokens you're subscribed to:

```bash theme={null}
curl -N https://www.serializedaudit.io/api/stream -H "X-Auth-Key: $KEY"
```

<Warning>
  **A silent stream almost always means you have no subscriptions yet.** The connection opens and
  authenticates even with zero subscribed tokens, and then stays quiet forever, because there is
  nothing to push. If all you see is the opening `ready` event followed by `:` keepalives, check
  [`GET /api/subscriptions`](#subscribe-tokens) before debugging anything else. Subscribe as you go
  with `subscribe=true` on your audit calls, or in bulk with
  [`import-history`](#subscribe-tokens).
</Warning>

You get [Server-Sent Events](https://developer.mozilla.org/docs/Web/API/Server-sent_events): each
change is one `event: audit.changed` frame whose `data:` is the same payload we deliver to a webhook
(see [The event you receive](#the-event-you-receive)) and whose SSE `id:` is that event's sequential
paging id (what you resume from, and **not** the `event_id` UUID inside the payload).

```
event: ready
data: {"since": 766042}

event: audit.changed
id: 766083
data: { "type": "audit.changed", "chain": "ROBINHOOD", "audit": { ... }, "diff": { ... }, ... }
```

* **Resume with no gaps.** On reconnect, send the last id back. The browser `EventSource` does it
  automatically via `Last-Event-ID`; from code, pass the header or `?since=<id>`. We replay every
  event after it, so a dropped connection never loses a change. A fresh connect (no id) streams
  **new** events only; `?since=0` replays your whole history.
* **It's scoped to your active subscriptions**, exactly like a webhook and `/events`.
* **Keepalive.** We send a `:` comment every \~25s so proxies keep the connection open. Reconnect on
  any disconnect (add your own backoff).
* **Both at once is fine.** If you also have a webhook set, you'll receive each change on both, so
  dedupe on `event_id`.

Browser example:

```js theme={null}
const es = new EventSource('https://www.serializedaudit.io/api/stream', {
  headers: { 'X-Auth-Key': KEY },   // server-side EventSource libs support headers
})
es.addEventListener('audit.changed', (e) => update(JSON.parse(e.data)))
// reconnection + Last-Event-ID resume are automatic
```

## Set up a webhook

Register your HTTPS URL and get a signing secret (returned **once**, so store it):

```bash theme={null}
curl -X PUT https://www.serializedaudit.io/api/webhook \
  -H "X-Auth-Key: $KEY" -H 'Content-Type: application/json' \
  -d '{"url":"https://your-backend.example.com/serialized-webhook"}'
# -> { "ok": true, "url": "...", "secret": "whsec_..." }
```

Updating the URL keeps the secret; pass `{"rotate": true}` to force a new one. `POST
/api/webhook/test` sends a signed `ping` so you can confirm wiring before subscribing anything.

`GET /api/webhook` returns the current config (never the secret) plus 24h delivery health:

```json theme={null}
{
  "webhook_url": "https://your-backend.example.com/serialized-webhook",
  "has_secret": true,
  "deliveries_24h": { "delivered": 128, "pending": 2, "dead": 1 },
  "subscriptions": 1543
}
```

The `ping` sent by `/api/webhook/test` has `type: "ping"` (not `"audit.changed"`) and a non-UUID
`event_id` / `x-serialized-event: ping`. Ignore it or `200` it; don't parse it as a real event.

## Subscribe tokens

```bash theme={null}
# one, or bulk (up to 5000)
curl -X POST https://www.serializedaudit.io/api/subscriptions \
  -H "X-Auth-Key: $KEY" -H 'Content-Type: application/json' \
  -d '{"tokens":[{"chain":"ROBINHOOD","address":"0x..."}]}'
```

A subscribe returns `{ "ok": true, "subscribed": <total>, "charged": <newly-created> }`. Only
`charged` tokens (genuinely new) cost credits.

To backfill, `POST /api/subscriptions/import-history` subscribes every token you've called in the
last 15 days that is already at the current audit version. It's a **two-step** flow so you never
get a surprise bill: a bare call is a **dry-run quote** (creates/charges nothing):

```json theme={null}
{ "dry_run": true, "window_days": 15, "current_version_only": true,
  "would_subscribe": 1543, "credits_per_token": 2, "credits_required": 3086,
  "confirm_required": true, "hint": "POST again with ?confirm=true to subscribe and be charged." }
```

Re-POST with `?confirm=true` to actually subscribe and be charged (returns the same
`{ ok, subscribed, charged }` shape as above).

<Warning>
  A bulk subscribe (`POST /api/subscriptions` or `import-history?confirm=true`) is cap-gated: if
  the projected cost would cross your allowance it is denied with `402`/`429` (`action:
      "raise_cap"`, plus `required_credits` / `remaining_credits`) **before** anything is subscribed.
  Nothing partial happens. Run the dry-run first to size the spend.
</Warning>

**Fire an audit and get the result pushed, no polling:** call `/audit-contract` with
`async=true&subscribe=true`. It starts the audit, subscribes the token, and pushes the finished
verdict to you (stream or webhook) when it lands. See the [`subscribe`](/api-reference/audit-contract)
parameter.

## The event you receive

Every change (on the stream or your webhook) carries the same JSON (`type: "audit.changed"`):

```json theme={null}
{
  "type": "audit.changed",
  "event_id": "a816aafb-...",
  "chain": "ROBINHOOD", "address": "0x...", "audit_id": 766083,
  "created_at": "2026-07-24T18:46:59.000Z",
  "change": {
    "is_safe": { "before": true, "after": false, "changed": true },
    "owner_changed": false,
    "vulns": { "added": 1, "removed": 0, "changed": 0 }
  },
  "audit": { "isSafe": false, "vulnerabilities": [ ], "name": "…", "sourceType": "verified", "…": "…" },
  "diff": { "added": [ ], "removed": [ ], "changed": [ ] }
}
```

* **`audit`** is the complete new verdict: **the exact same object you get back from
  [`/audit-contract`](/api-reference/audit-contract)** for this token. Do `store[address] =
  payload.audit` and you're done; no re-fetch. (It's `null` only if the change left no audit,
  e.g. a manual lock removed.)
* **`change`** is a compact summary for cheap routing/alerting (badge flips, "a critical was
  added") without parsing the full verdict.
* **`diff`** tells you *which* findings moved: `added` / `removed` are full vuln objects,
  `changed` is `{ before, after, fields }`.

| Field              | Type           | Notes                                                                                                    |
| ------------------ | -------------- | -------------------------------------------------------------------------------------------------------- |
| `type`             | string         | always `"audit.changed"`                                                                                 |
| `event_id`         | string (UUID)  | identity of the event, stable across redeliveries. **Dedupe on this.**                                   |
| `chain`, `address` | string         | the token this change is about                                                                           |
| `audit_id`         | number \| null | our audit row id. `null` when the change left no audit row                                               |
| `created_at`       | string         | ISO 8601 UTC instant, e.g. `"2026-07-24T18:46:59.000Z"`                                                  |
| `change`           | object         | compact summary: `is_safe {before, after, changed}`, `owner_changed`, `vulns {added, removed, changed}`  |
| `audit`            | object \| null | the complete new verdict, identical to an `/audit-contract` response. `null` if the change left no audit |
| `diff`             | object         | `added` / `removed` are full vuln objects, `changed` is `{before, after, fields}`                        |
| `id`               | number         | **only on `/api/events` elements**, not in the stream payload: the sequential paging id                  |

<Note>
  **`event_id` and the paging id are two different values.** `event_id` is a UUID that identifies
  the event itself and never changes, even if it is delivered twice — **dedupe on it**. The paging
  id is a separate sequential number: it is the SSE frame's `id:`, the `id` field on each
  [`/api/events`](#delivery-guarantees) element, and what you pass back as `since` — **page on it**.
</Note>

On a **webhook**, this JSON is the POST body, with headers `x-serialized-signature:
t=<unix>,v1=<hmac>` and `x-serialized-event: <event_id>`. On the **stream**, it's the SSE frame's
`data:`, and the frame's `id:` is the sequential paging id (what `Last-Event-ID` resumes from).

## Verify the webhook signature

Webhook POSTs are HMAC-signed (the stream needs no verification; it's authenticated by your API key
on the connection). `v1 = HMAC_SHA256(secret, "<t>.<raw_body>")`. Verify against the **raw body
bytes**, in constant time, and reject if `t` is more than 300s off from now:

```ts theme={null}
import crypto from 'node:crypto'
function verify(rawBody: string, header: string, secret: string): boolean {
  const m = /t=(\d+),v1=([0-9a-f]+)/.exec(header || '')
  if (!m) return false
  const [, t, v1] = m
  if (Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > 300) return false
  const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex')
  const a = Buffer.from(expected), b = Buffer.from(v1)
  return a.length === b.length && crypto.timingSafeEqual(a, b)
}
```

## Delivery guarantees

* **At-least-once.** We retry once (\~30s after the first failure) on any non-2xx/timeout, then
  dead-letter. A push can arrive more than once, so **dedupe on `event_id`**.
* **Respond 2xx fast** (10s timeout, webhook). Verify → enqueue → `200`; do your work async.
* **Nothing is lost.** Every change is written to a durable outbox. Replay anything you missed:
  `GET /api/events?since=<last_id>&limit=1000` → `{ "events": [...], "cursor": <id> }`. Page with
  `since=cursor`. This is your catch-up path after downtime (and what the stream's `Last-Event-ID`
  resume draws on). `/events` is scoped to your **currently-active** subscriptions: unsubscribing a
  token (`DELETE`) also hides its past events from replay, so drain what you need before
  unsubscribing.

  Each element is the same `audit.changed` object shown above, plus an `id` — the sequential
  paging id. `cursor` is the `id` of the last element (a number, echoing `since` on an empty page).

  ```json theme={null}
  { "events": [ { "id": 406045, "type": "audit.changed", "event_id": "8067cc0a-…", "…": "…" } ],
    "cursor": 406045 }
  ```

  <Warning>
    **`since` means the opposite here than on the stream.** On `/api/events`, omitting `since` is
    the same as `since=0` and replays your **whole** history from the beginning. On `/api/stream`,
    omitting it gives you **new events only**. In practice, always call `/api/events` with
    `since=<the last id you processed>`.
  </Warning>
* **We pace webhook delivery** (\~3 pushes/sec to one endpoint), so a big initial catch-up is spread
  out, never dumped at once. Steady-state is well under 1/sec.

## Endpoints

| Method         | Path                                                        | Purpose                                                |
| -------------- | ----------------------------------------------------------- | ------------------------------------------------------ |
| `GET`          | `/api/stream`                                               | hold open for a live SSE feed of your subscriptions    |
| `PUT`          | `/api/webhook`                                              | set URL `{url, rotate?}` → secret (once)               |
| `GET`          | `/api/webhook`                                              | config + 24h delivery health                           |
| `POST`         | `/api/webhook/test`                                         | send a signed `ping`                                   |
| `GET` / `POST` | `/api/subscriptions`                                        | list / subscribe (`{chain,address}` or `{tokens:[…]}`) |
| `POST`         | `/api/subscriptions/import-history`                         | backfill from your 15-day history (`?confirm=true`)    |
| `DELETE`       | `/api/subscriptions/:chain/:address` · `/api/subscriptions` | unsubscribe one / all                                  |
| `GET`          | `/api/events?since=&limit=`                                 | replay outbox events (catch-up)                        |

All authenticate with your API key in `X-Auth-Key`.
