Idempotency keys, immutable records, explicit states, webhooks, reconciliation
coreadvancedA payment flow is the standard example of a workflow you cannot make atomic: your database and the payment provider are two systems with no shared transaction, and money has already moved by the time you find out whether your own write succeeded. Six building blocks handle that, and they are not independent options — each one closes a gap the others leave open. An idempotency key, sent by you with the charge request, lets the provider recognise a repeated request as the same request and return the original result instead of charging again, which is what makes a retry after a timeout safe. Immutable transaction records mean you append what happened rather than updating a row in place, so a refund is a new row and the original charge is still readable — an audit trail you can reconcile against, rather than a current value you have to trust. Explicit payment states (`requires_payment_method`, `processing`, `succeeded`, `failed`) replace a boolean `paid` flag, so the very common in-between state has a name and the code has somewhere to put it. Webhook processing is how you learn the outcome of anything asynchronous, because a payment can complete minutes after the customer closed the tab. Reconciliation is a scheduled job that compares your records against the provider's and reports the differences, on the assumption that some will exist. And retry-safe operations means every step in the flow can run twice without a second effect — which is what the previous five blocks together are for.
Think of it as
Treat the payment provider as a system you can send instructions to and never fully observe. Every request you make has three possible outcomes, not two: it worked, it failed, or you do not know. That third outcome is the entire design problem, and it is not rare — a timeout, a dropped connection, or a deploy mid-request all produce it. The six building blocks exist so that "I do not know" is always recoverable: the idempotency key lets you ask again safely, the explicit state gives the unknown a name, the webhook eventually tells you, the immutable log records what you learned and when, and reconciliation catches the cases where none of that worked. A payment system is not code that charges cards; it is a bookkeeping system that happens to charge cards.
What we're doing: Trace one order through an ambiguous timeout, a duplicate webhook, and the reconciliation job that checks the result.
- 6
- This is the state the whole design exists for. The request neither succeeded nor failed from your side, and marking it failed here is the single most common way to charge a customer for an order you then tell them did not go through.
- 10
- The retry is safe only because of the idempotency key. Without it this exact line is a second charge, and the customer sees two identical amounts on their statement.
- 22
- Deduplication is on the provider's event id, recorded before the effect is applied — so a redelivery finds the id already present and does nothing. Deduplicating on order id instead would wrongly discard a genuinely different later event for the same order.
- 28
- The reconciliation job is designed around finding a difference, not around proving there is none. One discrepancy in 1,204 is a normal day; the job exists so that the number is known and investigated rather than discovered by an accountant a quarter later.
Why this works: Every one of the six blocks appears in this trace doing exactly one job: the explicit state gives t1's unknown outcome somewhere to live, the idempotency key makes t2 safe, the immutable event rows at t0/t3 make the history readable, webhook dedupe handles t5, and reconciliation at t6 catches what the first five missed. Remove any one and the trace has a hole a real customer eventually falls into.
Generating a fresh idempotency key on each retry
Wrong
Better
What you see: A customer is charged twice or three times for one order, and only during periods when the provider was slow. The logs show a successful charge on the third attempt and no error at all, because from the code's point of view the first two attempts genuinely did time out.
Why: An idempotency key identifies a logical operation, not an attempt. A fresh key on each retry tells the provider "this is a new charge", which is precisely the opposite of what a retry means — the key must be derived from something stable about the operation, and it must be generated before the first attempt, not inside the retry loop.
- Your service → Your database: record intent (state: processing)
- Your service → Provider: POST charge, Idempotency-Key: order_8814
- Provider → Your service: connection times out — outcome unknown (money may or may not have moved)
- Your service → Provider: POST charge again, SAME idempotency key
- Provider → Your service: original charge returned, not a second one
- Provider → Your service: webhook: payment_intent.succeeded (may arrive before or after the retry)
- Your service → Your database: append event, advance state to succeeded
Six building blocks and the specific failure each one closes
Three webhook properties you must design for, not hope against
Remember: Six blocks, each closing a gap the others leave: idempotency keys make retrying an ambiguous timeout safe; immutable event rows keep history reconcilable; explicit states give the in-between outcome a name; webhook processing carries asynchronous results, deduplicated on the provider's event id and claimed before the effect is applied; reconciliation is a scheduled job that assumes discrepancies exist; and retry safety is the property all five together produce. The hard case is not failure — it is not knowing.
See also: never trust the frontend redirect · separating payment state from order state · idempotency keys for post requests · idempotent consumer design · inbox deduplication · choosing enforcement mechanisms

