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.
The envelope
Section titled “The envelope”{ "eventId": "uuid-v4", "category": "trade.closed", "tenantId": "acme", "ts": "2026-05-24T18:30:00Z", "payload": { }}Headers
Section titled “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
Section titled “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
Section titled “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
Section titled “Writing a receiver”- Verify
x-webhook-signaturebefore parsing the body. Recompute HMAC-SHA256 over the raw body bytes with your signing secret, hex-encode it, prefixsha256=, and compare in constant time. This is what proves the request came from us —x-api-keyis a static bearer that anyone who captures one delivery can replay, so check it too, but not instead. - Deduplicate on
eventId. Retries are at-least-once; the sameeventIdcan arrive more than once, andx-webhook-attempttells you it is a repeat. - Return 200 fast, then work. A slow receiver looks like a failing one and earns a retry you didn’t need.
- 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.
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)})