# Webhooks

Webhooks are the push option for systems that shouldn't hold a socket open.
The platform POSTs JSON to a URL registered for your tenant.

:::note[Your operator provisions the subscription]
There is no public endpoint for creating a webhook subscription — registration,
the secrets, and rotation all live on the operator console. Give your operator
the destination URL and the categories you want; they hand back two values: the
`x-api-key` your receiver compares, and the **signing secret** it verifies the
HMAC with. Both rotate in place on the same subscription; a rotated signing
secret is shown once and takes effect immediately, with no grace window, so
verification fails until you store the new value. Everything below describes
what then arrives.
:::

## The envelope

```json
{
  "eventId": "uuid-v4",
  "category": "trade.closed",
  "tenantId": "acme",
  "ts": "2026-05-24T18:30:00Z",
  "payload": { }
}
```

## Headers

| Header | Meaning |
| --- | --- |
| `x-webhook-signature` | `sha256=<hex>` — HMAC-SHA256 over the exact request body, keyed with your subscription's signing secret. This is the provenance proof. |
| `x-api-key` | The shared secret for this subscription, set by your operator. |
| `x-webhook-event-id` | Matches `eventId` in the body. |
| `x-webhook-event-category` | Matches `category`. |
| `x-webhook-attempt` | 1-based attempt counter. |
| `x-webhook-request-id` | A fresh UUID per attempt. |
| `content-type` | Always `application/json`. |
| `authorization` | Only when your subscription is configured with one — the value your operator set, passed through verbatim. |

## Categories

These six have live producers and are the only ones you can subscribe to:

| Category | Fires when |
| --- | --- |
| `account.created` | A trading account is provisioned. |
| `account.balance_changed` | An account's balance moves. |
| `account.status_changed` | An account is enabled, disabled, or a prop account changes state. |
| `trade.closed` | An order intent reaches a terminal state — `filled`, `cancelled`, `rejected`, or `expired`. Despite the name it is not fill-only and carries no PnL; filter on the payload's `toState`. |
| `tenant.plan_changed` | Your billing plan changes. |
| `tenant.kyb_changed` | Your KYB verification status changes. |

The contract also declares `position.overnight`, `subscription.created` and
`subscription.updated`. **Do not build handlers for these** — they have no
producer and will never arrive. They exist for forward compatibility, and the
subscription surface refuses to register them, so there is no way to subscribe
by mistake. If one gains a producer it moves into the list above.

## Delivery and retries

Return any **2xx** on success. Anything else — including a connection failure —
is retried with exponential backoff: 1s, 4s, 30s, 5m, 30m, 2h, 6h, 24h. Once
the attempt budget is exhausted (8 by default) the subscription
**auto-disables** — so an endpoint that is down for a day needs re-enabling,
not just fixing.

## Writing a receiver

1. **Verify `x-webhook-signature`** before parsing the body. Recompute
   HMAC-SHA256 over the **raw body bytes** with your signing secret, hex-encode
   it, prefix `sha256=`, and compare in constant time. This is what proves the
   request came from us — `x-api-key` is a static bearer that anyone who
   captures one delivery can replay, so check it too, but not instead.
2. **Deduplicate on `eventId`.** Retries are at-least-once; the same
   `eventId` can arrive more than once, and `x-webhook-attempt` tells you it
   is a repeat.
3. **Return 200 fast**, then work. A slow receiver looks like a failing one
   and earns a retry you didn't need.
4. **Don't infer ordering.** Events can arrive out of order after a retry.
   Reconcile against the account reads rather than replaying webhooks as a
   sequence.

```ts
import { createHmac, timingSafeEqual } from 'node:crypto'

// Mount with a raw-body parser: the HMAC is over the bytes as sent.
app.post('/hooks/troncharts', express.raw({ type: 'application/json' }), async (req, res) => {
  const expected = 'sha256=' + createHmac('sha256', process.env.HOOK_SIGNING_SECRET).update(req.body).digest('hex')
  const got = req.header('x-webhook-signature') ?? ''
  if (got.length !== expected.length || !timingSafeEqual(Buffer.from(got), Buffer.from(expected))) {
    return res.sendStatus(401)
  }
  if (req.header('x-api-key') !== process.env.HOOK_SECRET) return res.sendStatus(401)

  const { eventId, category, payload } = JSON.parse(req.body.toString())
  if (await alreadyHandled(eventId)) return res.sendStatus(200)
  res.sendStatus(200)
  await handle(category, payload)
})
```