Webhooks
Designing a webhook consumer
Last updated 14 September 2026
A webhook endpoint looks trivial and is where most integrations quietly go wrong. Here is a design that holds up.
The shape
receive → verify signature → enqueue → 200 OK
↓
worker: dedupe → fetch /transaction/{xtid} → actThe HTTP handler does as little as possible. The work happens in a worker off a queue.
1. Acknowledge fast
Return 200 as soon as the delivery is verified and queued. Do not call your ledger, your ERP or Slack inside the request. A slow handler times out, the delivery is retried, and you have two copies of the same event to deal with.
2. Verify over the raw body
The signature is computed over the timestamp plus the exact bytes of the body. Read the raw body before any JSON middleware touches it, and verify with the workspace public key. Reject deliveries whose timestamp is more than a few minutes old to prevent replay. The scheme and code are in Validate Webhooks.
app.post('/xace', express.raw({ type: '*/*' }), async (req, res) => {
const timestamp = req.get('Webhook-Timestamp');
const signature = req.get('Webhook-Signature');
const rawBody = req.body.toString('utf8');
if (!isValidWebhook({ rawBody, timestamp, signature, publicKeyPem }))
return res.status(401).end();
if (Math.abs(Date.now() - Number(timestamp) * 1000) > 5 * 60 * 1000)
return res.status(401).end();
await queue.push(JSON.parse(rawBody));
res.status(200).end();
});Check the header names against your workspace's delivery settings; the example uses the signature header documented in Validate Webhooks and a timestamp header alongside it.
3. Dedupe on the transaction
Deliveries can arrive more than once. Key your processing on xtid plus event type and record what you have handled. A second delivery for the same key is acknowledged and dropped.
4. Fetch the truth
The payload tells you something happened. Before you act, call GET /transaction/{xtid} and use that. It is current, complete, and cannot be forged by anyone who got past your signature check.
5. Act idempotently
Whatever the worker does, marking a payout complete, posting to the ledger, alerting a channel, must be safe to run twice. Combined with the dedupe in step 3, that gives you two layers against duplicates.
6. Watch it
- Alert on signature failures. One is a misconfiguration; a burst is someone probing you.
- Alert if no webhook has arrived on a busy account for longer than usual.
- Keep the transaction sync running as a daily reconciliation against the events you received. See Syncing transactions for reconciliation.
Do not parse then re-serialise before verifying
`JSON.stringify(JSON.parse(body))` changes whitespace and key order. The signature will not match. Verify the bytes you received.