Case studies
Webhooks and outbox
How to receive PSP webhooks safely, with signature checks, fast acks and deduplication, and how the transactional outbox makes calls to the PSP survive a crash.
A PSP tells your system about payment outcomes through webhooks: HTTP calls it makes to an endpoint you own. PSPs deliver at least once, so a webhook may arrive twice, late or out of order. Your endpoint must verify it, acknowledge it quickly and process it safely. Separately, your own calls to the PSP must survive a crash. The outbox pattern handles that.
The big idea
Receive a webhook in two steps. First, verify it and record its event ID. Then acknowledge it and process it asynchronously. A duplicate is detected by its event ID and ignored.
The outbox works in the other direction. Calling the PSP inside a database transaction is impossible, because the PSP call cannot be rolled back with the transaction. Instead, record the intent to call in the same transaction as the business change. A worker makes the call later, and can retry it safely.
Walking through it
This sketch shows the receiving side. It assumes a stripe client, a db helper and an outbox table:
async function handleStripeWebhook(rawBody: Buffer, sig: string) {
const event = stripe.webhooks.constructEvent(rawBody, sig, secret) // verify before parsing
const inserted = await db.webhookEvents.insertIgnore(event.id)
if (!inserted) return 200 // duplicate delivery, already recorded
await db.outbox.enqueue({ type: event.type, payload: event.data })
return 200 // ack fast, process later
}Three rules sit behind this code. Verify the signature before you parse the body. Acknowledge quickly, because slow responses cause the PSP to retry, which adds load. Process the event with a worker whose handler is idempotent.
Now the outbox for outgoing calls, in four steps:
- In one database transaction, insert the payment row and an outbox row, such as
call_psp_confirm. - Commit.
- A publisher hands the row to a worker. The worker calls the PSP with an idempotency key, stores the result, and marks the outbox row done.
- If the worker crashes after the PSP succeeded but before it saved the result, the retry uses the same key. The PSP returns the earlier result, and your local state catches up.
Webhook evt_abc (charge.refunded) arrives → insert evt_abc → new → post refund journal
Webhook evt_abc arrives again → insert ignored → duplicate → 200, no workThe outbox is the same idea described in distributed transactions, applied to money.
Common mistakes
- Trusting the browser redirect. It is a UX step. The webhook or an explicit status check is the truth.
- Slow webhook handlers. A slow response makes the PSP retry, and the retries pile up.
- Logging full webhook bodies. They can contain personal data. Log the event ID and type.
- Calling the PSP inside a database transaction. The call cannot be rolled back, so use the outbox.
Check yourself
-
Why acknowledge a webhook before doing the work?
Answer: a slow or failed response makes the PSP retry, which adds load on your side. Record the event, return 200, and process it later.
-
Why can't the PSP call happen inside the payment's database transaction?
Answer: an external call cannot be rolled back with the transaction. The outbox records the intent in the transaction, and a worker makes the call afterward.
Key takeaways
- Verify each webhook's signature, record its event ID, and acknowledge it quickly.
- Deduplicate on the event ID, and make the handler idempotent.
- Use an outbox to record outgoing PSP calls in the same transaction as the business change.
- A retry with the same idempotency key lets a worker recover from a crash safely.
Further learning
Next, see how the ledger is checked against the PSP's own records, in reconciliation.
Related
Case study: payment system
Moving money correctly, with payment intents, idempotency at every hop, a double-entry ledger, webhooks and reconciliation when the network lies. Start here for the payments map.
Distributed transactions
Keeping data consistent when one business operation spans several databases or services, using two-phase commit, sagas, the transactional outbox and reservations, and redrawing boundaries first.
Idempotency and retries
Making operations safe to retry, with idempotency keys, deduplicating consumers and jittered backoff, so at-least-once delivery never means a double charge.
Message queues
A durable buffer between the code that creates work and the code that does it. Start here for the map of queue concepts.