Guide

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:

TypeScript
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:

  1. In one database transaction, insert the payment row and an outbox row, such as call_psp_confirm.
  2. Commit.
  3. 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.
  4. 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 work

The 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

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

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