Webhooks
Signed, at-least-once deliveries backed by a transactional outbox. The event is written in the same transaction as the state change it describes, so an engine that crashes before delivering still delivers.
Events
| Event | Fires when | Typical action |
|---|---|---|
deposit.detected | A transfer is first seen in a block. | Show "pending" to the customer. Do not credit. |
deposit.confirmed | The confirmation threshold is reached. | Credit the balance. This is the one that matters. |
deposit.reorged | A confirmed deposit's block was orphaned. | Reverse the credit, or flag for review. |
payout.signed | Custody signed the payload. | Move your withdrawal to "processing". |
payout.broadcast | The transaction was sent. | Show the transaction hash to the customer. |
payout.settled | Confirmed to the required depth. | Mark the withdrawal complete. |
payout.refused | Policy refused to sign. | Return the funds to the customer's balance and alert. |
treasury.hot_low | Hot float fell below its threshold. | Page whoever tops it up. |
Verifying a delivery
Each delivery carries three headers. The signature covers five lines, mirroring the request scheme, with the delivery ID bound in:
POST /webhooks/payments // the path you registered, query string included 1756500000 // X-Webhook-Timestamp dlv_01H8XK… // X-Webhook-Id — binding this is what makes a replay detectable <sha256 of the raw body> // hex
import { verifyWebhookSignature } from '@payments/sdk'; const result = verifyWebhookSignature({ secret: process.env.PAYMENTS_WEBHOOK_SECRET, signatureHeader: request.headers['x-webhook-signature'], // 'v1=…' timestampHeader: request.headers['x-webhook-timestamp'], eventIdHeader: request.headers['x-webhook-id'], endpointPath: '/webhooks/payments', rawBody // the bytes, not a re-serialised object }); if (!result.isValid) return response.status(401).end();
JSON.stringify(req.body) rejects every authentic delivery, because re-serialising changes key order and whitespace while the signature covers what was actually sent. In Express that means express.raw() on this route, before any JSON parser.The comparison must be constant-time. A === on the hex leaks the expected signature one byte at a time to anyone who can measure your response.
At-least-once, and what that requires of you
An engine that crashed between sending a delivery and recording that it sent one will send again. That is the correct trade: the alternative is at-most-once, which loses a deposit confirmation.
X-Webhook-Id. Store processed IDs with a unique constraint and let the database reject the duplicate. Do not deduplicate by event content — two genuinely distinct deposits of the same amount to the same address look identical.// Insert first; if the ID is already there, this delivery is a duplicate. const inserted = await db.query( `INSERT INTO processed_webhooks (webhook_id) VALUES ($1) ON CONFLICT DO NOTHING RETURNING webhook_id`, [webhookId] ); if (inserted.rowCount === 0) return response.status(200).end(); // already handled await creditCustomer(event); // same transaction as the insert, ideally
Why the outbox matters
The event row is written inside the same transaction as the state change. Either both happen or neither does. The alternative — commit, then send — has a window where the deposit is confirmed in the database and the notification was never sent, and nothing in the system can later notice.
A separate relay loop in the worker process picks up unsent rows with FOR UPDATE SKIP LOCKED, so several workers share the queue without duplicating work or blocking each other.
A complete receiver
import express from 'express'; import { verifyWebhookSignature } from '@payments/sdk'; const app = express(); // express.raw, not express.json: the signature covers the bytes. app.post('/webhooks/payments', express.raw({ type: '*/*' }), async (request, response) => { const rawBody = request.body.toString('utf8'); const check = verifyWebhookSignature({ secret: process.env.PAYMENTS_WEBHOOK_SECRET, signatureHeader: request.get('x-webhook-signature') ?? '', timestampHeader: request.get('x-webhook-timestamp') ?? '', eventIdHeader: request.get('x-webhook-id') ?? '', endpointPath: '/webhooks/payments', rawBody }); if (!check.isValid) return response.sendStatus(401); const event = JSON.parse(rawBody); // Acknowledge fast, then work. A slow handler causes redeliveries, // which is safe but wasteful — and it holds a worker on our side. response.sendStatus(200); await enqueue(event); });
When your endpoint is down
Deliveries retry with exponential backoff. After the configured attempts the delivery moves to a dead-letter table — visible in the operator console, replayable once you are healthy. Nothing is dropped silently.
2xx means "received", not "processed". Acknowledge as soon as the delivery is durably yours; do the work afterwards. A handler that acknowledges only after crediting a customer will time out under load and cause redeliveries of work that already happened — which is exactly why the ID-based deduplication above is not optional.