Payments Engine

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

EventFires whenTypical action
deposit.detectedA transfer is first seen in a block.Show "pending" to the customer. Do not credit.
deposit.confirmedThe confirmation threshold is reached.Credit the balance. This is the one that matters.
deposit.reorgedA confirmed deposit's block was orphaned.Reverse the credit, or flag for review.
payout.signedCustody signed the payload.Move your withdrawal to "processing".
payout.broadcastThe transaction was sent.Show the transaction hash to the customer.
payout.settledConfirmed to the required depth.Mark the withdrawal complete.
payout.refusedPolicy refused to sign.Return the funds to the customer's balance and alert.
treasury.hot_lowHot 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:

canonical string
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
node — with the SDK
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();
Verify the raw bytes. A receiver that hashes 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.

Make your handler idempotent on 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.
node
// 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

express
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.

A 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.