Skip to content

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.

{
"eventId": "uuid-v4",
"category": "trade.closed",
"tenantId": "acme",
"ts": "2026-05-24T18:30:00Z",
"payload": { }
}
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.

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.

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.

  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.
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)
})