all_lessons / system_design / cases / C32 C32 / C44

Design a payment system

A payment is not an API call — it's a long-lived state machine wrapped around money that moves through a system you don't control. The whole design exists to survive the gap between "we asked the provider to charge the card" and "we know for certain whether it happened."

Source: Vol. 2 Ch. 11, archive Case drill Trade-off first
First principle
Every other system in this track owns its own data. A payment system does not — the authoritative event ("the card was charged") happens inside Visa / a bank / Stripe, asynchronously, and you learn about it through a flaky webhook that may arrive late, twice, or out of order. That single fact is forcing: you cannot make the charge atomic with your database write, so you must treat your DB as a claim about money that is continuously reconciled against the provider's truth. Correctness here means the design survives retries, partial failures, and duplicate delivery without ever charging a card twice or losing a charge.

The hinge: you don't own the truth

The moment that makes this problem hard is not throughput — payment QPS is small. It's that the operation crosses a trust boundary into a system with its own latency, its own retries, and its own idea of what happened. Card authorization through a provider takes on the order of 1–2 seconds end to end (network to the acquirer, the acquirer to the card network, the issuing bank's fraud and funds check, and back). During that window your request is in flight: you have committed to charging the user but you do not yet know the outcome. If the user's connection drops, or your process crashes, or the response is lost on the wire, you are left holding a record that says "maybe." The architecture is the set of mechanisms that make "maybe" safe.

The trade-off you must name out loud
The naive design couples the user's HTTP request to the provider's auth latency: hold the connection open for 2 seconds, return success or failure synchronously. It feels simple, and for fast card auth it can work — but it makes the user's latency hostage to the provider's tail, and it gives you nowhere to stand when the response is lost. The senior move is to go async: persist a PENDING attempt, return immediately, and resolve the outcome through webhooks plus reconciliation. You trade a slightly more complex UX (a pending state) for a system that can actually recover from the 1% of charges that go sideways.

Clarify the contract

Treat the prompt as a product contract before a box diagram. The system must:

And explicitly what it need not do here: it need not be the provider — we do not touch the card network, run fraud scoring, or settle funds between banks. It need not store raw PAN (card numbers) — that is the provider's tokenization job and a PCI scope we deliberately avoid. And the two mechanisms most likely to dominate this conversation — idempotency-key plumbing and the double-entry ledger — are taught in depth elsewhere; we link to them and cover only the payment-flow delta (see Related lessons).

Put numbers on it

The numbers don't size a cluster here — they size the in-flight problem, which is the real constraint. Take a busy merchant platform at 1000 payments/s and a provider auth latency of 2 s. By Little's Law, the number of charges simultaneously waiting on the provider is:

in-flight PENDING = arrival rate × auth latency = 1000/s × 2 s = 2000 concurrent attempts

So at any instant there are ~2000 records that say "we asked, we don't know yet." That is the population your timeout/retry/reconciliation logic must manage — not a cache to be evicted but money in limbo. Two more numbers fall out. Webhook delivery is at-least-once, so empirically 1–5% of deliveries are duplicates (a provider retries when it doesn't see your 2xx fast enough): design for 1000/s × 0.03 ≈ 30 duplicate webhooks/s that must be no-ops. And the ledger appends 2 entries per payment (double-entry: one debit, one credit), so the ledger write rate is 1000/s × 2 = 2000 rows/s — append-only, never updated.

Payments / s
1000
In-flight PENDING (λ×latency)
2000
Duplicate webhooks / s (~3%)
~30
Ledger appends / s (2× payments)
2000

The takeaway the numbers force: throughput is trivial (a single Postgres handles 2000 append rows/s easily), but the 2000 in-flight uncertain charges and the steady drizzle of duplicate webhooks are what the design must be built around. Storage is cheap relative to financial ambiguity — keep every event forever.

Data model & API

The data model is the architecture. The central decision is separating intent (what the customer wants — one purchase) from attempt (what we tried — possibly several provider calls).

EntityRole
payment_intentOne per customer purchase: amount, currency, status, the customer's idempotency key. The thing the user sees.
payment_attemptOne per provider charge call, child of an intent. Holds the provider's request id. Multiple attempts can hang off one intent (retry after a transient failure) without ever creating a second intent.
provider_eventOne row per webhook, keyed by the provider's event id. INSERT-if-absent is the dedup gate. This table is the bridge to the provider's truth.
ledger_entryAppend-only, double-entry. The source of truth for money. See C33.

API surface, kept small so it expresses the product op and not the internals: POST /intents (create), POST /intents/:id/confirm (carries an Idempotency-Key header), POST /webhooks/provider (the provider calls us), POST /intents/:id/refund.

Linearized design

Follow one payment from tap to settled, exposing the bottleneck (the in-flight gap) where it naturally appears.

  1. Create intent. Client calls POST /intents; we persist a CREATED intent with amount and the client's idempotency key.
  2. Confirm (idempotent). Client calls confirm with Idempotency-Key. We INSERT-if-absent on that key (the mechanics live in C35). First time: create a payment_attempt and move the intent to PENDING. A retry with the same key returns the same in-flight result — no new attempt, no second charge.
  3. Charge the provider — from a durable record. Only after the attempt row is committed do we call the provider. This ordering matters: if we crash mid-call, the durable attempt lets us recover and ask "what happened to this one?" rather than losing it.
  4. Return immediately; user sees PENDING. We do not block on the 2 s auth. The user gets a "processing" state; this is the async choice from the hinge.
  5. Webhook resolves the outcome. Provider posts charge.authorized / captured / failed to our webhook. We dedup on the provider event id, advance the state machine, and on success/capture append the ledger entries.
  6. Reconcile. A daily job pulls the provider's settlement file and compares it against our intents + ledger, surfacing any intent we think is PENDING but the provider actually charged (or vice versa).
PAYMENT STATE MACHINE (provider events drive transitions; self-loops = duplicate webhooks) confirm (idempotency key) ┌────────┐ ──────────────────────────▶ ┌─────────┐ │ CREATED│ │ PENDING │ ◀─┐ duplicate "pending" └────────┘ └────┬────┘ ──┘ webhook ⇒ no-op │ ┌──────────────────────────────────┼───────────────────────┐ │ charge.authorized │ charge.failed │ ▼ ▼ │ ┌────────────┐ capture ┌──────────┐ │ ┌─▶ │ AUTHORIZED │ ───────────────────▶│ FAILED │ ◀────────────────┘ dup │ └─────┬──────┘ └──────────┘ (terminal) auth ─┘ │ charge.captured ▼ ┌───────────┐ refund.succeeded ┌──────────┐ ┌─▶ │ CAPTURED │ ────────────────────▶│ REFUNDED │ ◀─┐ duplicate dup │ └───────────┘ └──────────┘ ──┘ refund ⇒ no-op cap ─┘ (terminal: money moved + ledger written) Every inbound webhook: INSERT provider_event(event_id) → if row already exists, STOP (no-op). The transition is only attempted on a FIRST-time event. This makes the whole machine idempotent.

The self-loops are the point. Because delivery is at-least-once, the same charge.captured event will arrive more than once. Dedup on the provider event id collapses every duplicate into a no-op, so a state is only ever entered once and the ledger is only ever written once per real transition. This is DDIA Ch. 11's event sourcing: the provider events are an ordered (well, mostly — see below) log, and our intent state is a fold over that log.

Deep dives

1. Sync confirmation vs. async pending — why you almost always go async

The seductive design returns the charge result on the confirm response: the user clicks Pay, you call the provider, you hold the HTTP request open for the 1–2 s auth, and you return "succeeded." Two things kill it. First, latency coupling: lesson 02's tail dominates — if the provider's p99 auth is 5 s, your p99 checkout is 5 s, and a provider hiccup becomes a checkout outage. Second, and worse, the lost response: if the network drops the provider's reply, or your server crashes after the provider charged but before you recorded it, the synchronous model has nowhere to put "we charged but don't know it." The user retries, you charge again.

The async model decouples the two. Confirm persists a PENDING attempt and returns instantly; the user sees "processing." The real outcome arrives later by webhook, and the UX shows the final state (a push notification, a polled status, an emailed receipt). The user's latency is now bounded by your system, not the provider's tail. And because the attempt is durable before the provider call, a crash is recoverable: on restart you have a record that says PENDING and you go ask the provider (or wait for reconciliation) instead of guessing. The cost is real — you must build pending UX and handle the small window where the user has paid but hasn't seen confirmation — but it's the cost of a system that survives the 1% of charges that go wrong, and that's the whole job.

2. The ledger is the source of truth — status is not

A tempting shortcut is a mutable balance column and a status field on the intent. Don't. Balances must be derived from immutable, append-only double-entry ledger entries, not from a mutable counter. The reason is auditability: when finance asks "why does this account show $40," the answer must be a list of entries that sum to $40, each traceable to a payment, refund, or correction — not "because the column says so." Status is for workflow (drive the state machine, show the user a spinner); the ledger is for money truth. They serve different masters and must not be conflated.

The payment-flow delta is just when we append: a single ledger transaction of two entries (debit the customer's receivable, credit the merchant payable, for instance) is written exactly once, on the first-time captured transition, inside the same DB transaction that advances the state machine (DDIA Ch. 7 atomicity — both or neither). A refund appends a new compensating pair; we never edit or delete the original. The double-entry mechanics — debit/credit invariants, available vs. posted balances, snapshotting — are the subject of C33; here we only need that the append is atomic with the state transition and happens once.

3. Reconciliation against provider events

Webhooks are best-effort; reconciliation is the safety net that makes the system actually correct. Every provider gives you a daily settlement report — the authoritative list of what they charged, captured, and settled. A batch job (DDIA Ch. 10 territory) joins that report against your intents and ledger and classifies every discrepancy:

The philosophy: webhooks make the system fast (near-real-time updates), reconciliation makes it correct (eventually, every charge is accounted for). You need both. A design that relies on webhooks alone is wrong because at-least-once delivery still permits zero deliveries when a webhook endpoint is down for an hour — only the daily reconcile catches that.

The at-least-once dedup, concretely

Static walk-through of why the dedup gate matters. Fire N duplicate confirms (or N copies of one webhook) and watch whether the card gets charged once or N times:

ScenarioInboundDedup gateProvider chargesLedger pairs written
No idempotency / no dedup5× confirm, same intentnone5 — double-charge bug5
Idempotency key on confirm5× confirm, same Idempotency-KeyINSERT-if-absent on key11
No event dedupcaptured webhook (provider retries)none1 (already charged)4 — ledger inflated 4×
provider_event dedupcaptured webhookINSERT-if-absent on event id11

The pattern is identical on both edges: an INSERT-if-absent on a stable key (the client's idempotency key inbound, the provider's event id outbound) turns "at-least-once" into "effectively-once." This is DDIA Ch. 9's recipe — you can't get exactly-once delivery, so you get at-least-once delivery plus idempotent processing, which is observably equivalent.

Trade-offs

ChoiceBuysCostsChoose when
Sync confirmation vs. async pendingsimple UX, no pending statecouples user latency to provider tail; no recovery for lost responsesfast card auth, low stakes, and you accept the double-charge risk on retry — rarely the right call
Mutable balance vs. ledgertrivially easy readszero auditability; impossible to explain a balancetoy systems only
One PSP vs. multiple PSPssimplicity, one integrationno failover; no cost/approval-rate routingearly stage; add a second once a provider outage has cost you money
Immediate capture vs. auth-then-capturefaster settlement, fewer statescan't adjust amount after auth (shipping, tips)digital goods that ship instantly; auth-then-capture for physical goods

The sharpest of these is the first, and it's worth narrating: candidates reach for sync because it demos cleanly. The senior signal is to say "I'd return PENDING and resolve async, because the provider owns the latency and the truth, and a synchronous design has nowhere to stand when the response is lost — which at 1000/s and a 2 s auth is happening to ~2000 charges at any instant." Naming the in-flight number is what separates a memorized answer from a derived one.

Failure modes

FailureMitigation
Double charge on retryIdempotency key on confirm (C35); intent/attempt separation so retries reuse the intent.
Duplicate / out-of-order webhookINSERT-if-absent on provider event id; idempotent state-machine transitions (self-loops are no-ops).
Provider outageQueue PENDING attempts and retry with backoff (lesson 13); route to a backup PSP if one is configured.
Webhook endpoint down for hoursDaily reconciliation against the settlement report catches everything webhooks missed.
Provider charged, our DB write failedProvider event id is the source of truth; reconcile and replay. See the senior Q below.

Interview prompts you should be ready for

  1. The provider charged the card but your DB write failed before you recorded it. Reconcile this. (senior answer) You can't make the external charge atomic with your local write, so don't try — make the external charge recoverable. The provider's event id is the source of truth; the charge is never lost, only your knowledge of it. Three layers catch it: (a) the durable payment_attempt written before the provider call means on restart you have a PENDING record to ask about; (b) the provider re-delivers the webhook (at-least-once), which our INSERT-if-absent processes whenever we recover; (c) the daily reconciliation against the settlement report is the backstop that guarantees every provider charge eventually lands in our ledger. To emit our own downstream events atomically with the DB write, use the outbox pattern (DDIA Ch. 11; mechanics in C35) — write the event to an outbox table in the same transaction, relay it after commit. The mantra: never hold money state only in a volatile place, and always have a batch truth to converge to.
  2. Why separate intent from attempt? (senior answer) The customer wants one purchase; the system may make several provider calls (transient failure, retry, fallback PSP). If retries created new intents, you'd get duplicate business orders. Intent is the stable, idempotent-keyed unit the user sees; attempts are the individual provider interactions hung off it. This is what lets a retry be safe.
  3. Why async + PENDING instead of returning the result synchronously? (senior answer) Provider auth is 1–2 s and owns the tail; coupling user latency to it makes a provider hiccup a checkout outage. Worse, a lost response in the sync model has nowhere to live, so the user retries and double-charges. Async persists a durable PENDING attempt, returns instantly, and resolves via webhook + reconcile — recoverable by construction.
  4. A webhook arrives out of order — captured before authorized. What happens? (senior answer) The state machine must tolerate it: either treat the transitions as a monotonic lattice (captured implies authorized) and advance to the furthest valid state, or buffer and apply once the prerequisite arrives. Either way dedup on event id and idempotency mean replaying in any order converges to the same final state — that's the event-sourcing guarantee (DDIA Ch. 11).
  5. Where does the idempotency key come from and how long does it live? (senior answer) The client generates it per logical purchase and sends it on confirm; we INSERT-if-absent and store the response so retries return the same result. TTL is a real decision — long enough to cover client retry windows (hours), not forever. The full mechanics (key + request hash to detect a reused key with a different body, TTL, response caching) are C35; here it just guarantees one charge per intent.
  6. How do you know your throughput numbers are fine but something else isn't? (senior answer) 2000 ledger rows/s and 1000 payments/s are trivial for one Postgres. The real load-bearing number is the ~2000 in-flight PENDING attempts (λ×latency) — that population sizes your timeout, retry, and reconciliation logic. If a provider slows from 2 s to 6 s, in-flight triples to 6000; that's the metric to alert on (lesson 16), not CPU.
  7. Refund correctness — how do you avoid refunding twice? (senior answer) Same idempotency pattern: a refund carries its own key, advances CAPTURED→REFUNDED only on the first-time event, and appends a new compensating ledger pair — never edits the original entries. Duplicate refund webhooks self-loop to no-ops.
  8. Do you need distributed transactions / 2PC across you and the provider? (senior answer) No — and you can't, the provider won't enlist in your transaction. You use the saga / event-sourced pattern (DDIA Ch. 11, lesson 12): local atomic writes plus idempotent compensations and reconciliation. 2PC across a trust boundary you don't control is neither available nor offered.

Related lessons

This case is deliberately a satellite: two of its hardest mechanisms are anchored elsewhere, and you should link rather than re-derive. The idempotency-key and outbox mechanicsINSERT-if-absent, key TTL, request-hash to catch a reused key with a different body, transactional outbox relay — are taught in full in C35 (idempotency anchor); here we used the key only as a gate and pointed at it. The double-entry ledger — debit/credit invariants, available-vs-posted balances, balance snapshotting — is anchored in C33 (ledger anchor); we needed only that the append is atomic with the state transition. The general delivery-semantics / exactly-once-via-dedup framing is anchored in C27, which is where at-least-once + idempotent processing is derived once for the whole set. On foundations: lesson 12 (distributed transactions) gives the saga/outbox spine this case leans on, and lesson 09 (consistency) is why webhooks are eventually-consistent and reconciliation is the convergence mechanism.

C33 · ledger anchor C35 · idempotency anchor C27 · delivery semantics Lesson 12 · transactions Lesson 09 · consistency Lesson 13 · retries/backoff
Takeaway
A payment system is a state machine wrapped around money that lives in a system you don't own. Because the authoritative event happens inside the provider and reaches you through an at-least-once webhook, you cannot make the charge atomic with your write — so you build for recovery: a durable attempt before every provider call, an INSERT-if-absent dedup gate on both the client key and the provider event id, an append-only ledger as the money truth, and a daily reconciliation that converges your claims to the provider's truth. Go async with a PENDING state so user latency isn't hostage to the provider tail. At 1000 payments/s and 2 s auth, the load-bearing number isn't QPS — it's the 2000 in-flight uncertain charges, and the entire design exists to make every one of them safe.