Designing a payment webhook handler end to end

Every earlier lesson in this domain is a piece of the same machine. This one assembles them into a single, real webhook handler, in the order the pieces actually have to run.

Advanced

4 min read

The full pipeline, in order

Each numbered step is a lesson already covered in this domain. What matters here is that the order is not arbitrary — several of these steps would be actively wrong in a different position.

Why signature verification is step 1, unconditionally

Covered in its own lesson: nothing else should run against an unverified payload, including even logging it in detail — an attacker-controlled payload shouldn't be trusted enough to parse, store, or act on in any way before its origin is confirmed.

Why the fast response comes before the real work, not after

// Wrong: gateway may time out waiting, and retry an event
// that actually succeeded, creating exactly the duplicate-
// delivery scenario idempotency exists to handle
async function handleWebhook(req, res) {
  await doAllTheRealWork(req.body); // slow: DB writes, emails, etc.
  res.sendStatus(200);
}

// Right: acknowledge fast, do the real work async
async function handleWebhook(req, res) {
  if (!verifySignature(req)) return res.sendStatus(400);
  res.sendStatus(200);              // acknowledge immediately
  processEventAsync(req.body);      // don't await this in the response path
}

A slow response risks the gateway's own retry logic firing — the event this handler is currently processing gets redelivered while it's still mid-flight, purely because of a slow response, not because anything actually failed. This is exactly why idempotency (step 3) has to exist regardless of how careful the rest of the code is: even a perfectly correct handler can still receive the same event twice, for reasons entirely outside its control.

Why idempotency runs before parsing into a domain event

There's no reason to do the (comparatively expensive) work of interpreting what an event means if it's already known to be a duplicate. The audit-table-and-processed-flag check (or its equivalent) is cheap, runs early, and short-circuits everything after it for a repeat delivery.

Why the ledger entry and the state transition are related, but not identical

Not every state transition implies a ledger entry, and the reverse is also true in some designs — a PENDING to AUTHORIZED transition might just update a status column, while the transition to CAPTURED is the moment money is actually recognized as collected, which is when a journal entry (debiting Cash, crediting Revenue, per the double-entry lesson) gets posted. Conflating "update the status" with "post the accounting entry" as a single step is a common shortcut that becomes a real problem the first time a status needs to change for a reason that isn't a genuine movement of money (a manual correction, a status re-sync from the reconciliation job).

Why side effects come last, and should tolerate failing independently

Sending a confirmation email, provisioning access, notifying another internal system — none of these should be allowed to roll back the payment's own recorded state if they fail. A payment that was genuinely captured, with a correctly posted ledger entry, shouldn't be at risk of being un-recorded because an email provider had an outage a moment later. This is the same principle from the very first lessons in this domain, generalized: the parts of the system that can fail for unrelated reasons shouldn't be allowed to corrupt the part of the system recording what actually, financially happened.

Putting it together: the full mental model

Untrusted request
  -> proven authentic (signature)
  -> acknowledged quickly (fast 200)
  -> proven not-a-duplicate (idempotency)
  -> understood (parsed into a domain event)
  -> recorded (status transition + ledger entry, atomically)
  -> acted on (side effects, allowed to fail independently)

Every lesson in this domain is one link in that chain. None of them is optional in a system that genuinely moves money — this is, in a very real sense, the actual job.

Further reading

Check your understanding

A quick comprehension check — not tracked, not graded, just for you.

1. A webhook handler does all its database writes and sends a confirmation email before returning an HTTP 200. What's the risk?

2. Why does idempotency checking happen before parsing a webhook payload into a full domain event, rather than after?

3. A system always creates a ledger journal entry every time a payment's status field changes for any reason, including a manual admin correction or a reconciliation re-sync. What's the problem with this design?

4. A payment is successfully captured and its ledger entry is correctly posted, but the confirmation email fails to send due to an email provider outage. What should happen to the payment's recorded state?