System Design · step by step

Design a Payment System

Step 1 / 9
The numbers to beat1charge per keysafeto retrystoredresult

In the interview room

How you’d open this design in an interview

Before any boxes: agree what it must do, pin the qualities that shape everything, then build — naming each trade-off as you make it. The walkthrough above is that exact order.

Functional requirements

What it must do — agree on these before drawing a single box.

  • Charge a card: authorize (hold) then capture (take) the amount, returning a clear succeeded/failed.
  • Never double-charge: an idempotency key makes a retried charge produce exactly one charge.
  • Track every cent: record each movement in an immutable double-entry ledger; balances are derived sums.
  • Handle cards safely: tokenize via a PCI vault so the system passes only tokens, never raw card numbers.
  • Notify asynchronously: webhook the merchant the final outcome — succeeded, failed, refunded — off the request path.

Non-functional requirements

The qualities that shape the whole design — each one names the mechanism that buys it.

Reason about partial failures
Model a payment as an explicit state machine — created → authorized → captured → settled — so a network failing mid-flight has a state to retry from, not a lost boolean.
Retries can’t repeat the effect
An idempotency key per logical charge: the first request does the work and stores the result; any retry with the same key returns that stored result without charging again.
Money is auditable, can’t silently corrupt
An immutable, append-only double-entry ledger where every debit equals a credit, so the books always sum to zero — a continuous built-in correctness check.
Shrink the breach blast radius
Tokenization confines raw card data to one PCI-compliant vault; the API, workers and ledger handle only meaningless tokens.
Fast API over slow, flaky banks
Emit state changes to a durable event stream and webhook the final outcome with retries, so the response never blocks on bank latency you don’t control.
Prove the books actually agree
A daily reconciliation job compares the ledger line-by-line against the PSP’s settlement statements and flags every mismatch.

The trade-offs you say out loud

Senior signal isn’t the boxes — it’s naming what you gave up and why it was the right price.

A state machine (authorize→capture)over one synchronous paid/not-paid call

A boolean call can’t express “funds held but not yet taken,” can’t survive the card networks failing mid-flight, and leaves nowhere to retry from. Explicit states let you reason about partial failure.

An idempotency keyover a fuzzy “looks like a duplicate” check

Two legitimate identical charges look the same and a slow first charge may not be visible yet, so guessing is racy. An explicit key makes repeating a request unable to repeat its effect.

An immutable double-entry ledgerover a mutable balance field

A single incremented balance has no history and corrupts under concurrency. Append-only entries with debits = credits sum to zero, so corruption shows up as books that don’t balance.

Tokenize in a PCI vaultover encrypting cards in your own database

Even encrypted, raw card data drags every service into the strictest PCI scope and is catastrophic if leaked. A vault confines PANs to one hardened component and slashes the blast radius.

Async events + webhooksover blocking the API until the bank settles

Settlement can take minutes or arrive later as a delayed confirmation, and you don’t control bank latency — blocking couples your response to the slowest external party.

What this teaches

Learn system design by building a payment system like Stripe step by step. An interactive guide covering the charge flow, idempotency to prevent double-charges, a double-entry ledger, card tokenization, async processing with webhooks, reconciliation against PSP statements, and consistency at scale.

Key takeaways

  • A payment modeled as a state machine: authorize then capture via the PSP.
  • Idempotency keys make every charge safe to retry — exactly one charge per key.
  • An immutable double-entry ledger keeps every cent auditable and balanced.
  • Tokenization confines card data to a PCI vault, shrinking the blast radius.
  • Async events + webhooks decouple the fast API from slow, external banks.
  • Daily reconciliation against PSP statements proves the books actually agree.
  • A durable, retryable worker plus refunds-as-ledger-entries handle failure and scale.

Concepts covered

  • What makes payments hard?
  • Authorize, then capture
  • Idempotency
  • The double-entry ledger
  • Tokenization & PCI
  • Async events & webhooks
  • Reconciliation
  • The sharp edges

Design a Payment System — read the full walkthrough as text

the same steps, decisions & trade-offs, for reading, reference & search

The big idea

What makes payments hard?

Moving money looks like a simple write, but it’s the opposite: it touches external banks you don’t control, must never double-charge or lose a cent, has to survive every retry and crash, and must prove afterward that the books balance to the penny.

Treat a payment as a state machine recorded in an immutable double-entry ledger, made safe against retries by idempotency, run asynchronously around slow processors, and reconciled against the bank’s own records. Correctness first, everywhere.

How to read this: We add one piece at a time, problem then fix, and the diagram grows. Hit Begin.

Step 1 · The flow

Authorize, then capture

“Charge this card $50” isn’t one step. The money has to be checked and held, then actually taken — and the request leaves your system entirely to reach the card networks, which can be slow or fail.

Design decision: "Charge this card $50" — but the request leaves your system to reach slow card networks. How do you model it?

The call: A state machine: authorize (hold) then capture (take), via the PSP. — Splitting auth from capture lets you reserve funds now and finalize later, and explicit states (created→authorized→captured→settled) let you reason about partial failures and retries.

The Payment API hands the charge to a Payment Worker that drives it through states: authorize (hold funds) then capture (take them), talking to the PSP / card networks. Splitting auth from capture lets you reserve now and finalize later.

A payment is a state machine: created → authorized → captured → settled (or failed/refunded). Modeling explicit states — not a boolean “paid?” — is what lets you reason about partial failures and retries correctly.

Step 2 · Never charge twice

Idempotency

Networks time out. The merchant retries the charge, unsure if the first one went through. Naively, that’s two charges for one purchase — the cardinal sin of payments.

Design decision: A charge times out and the merchant retries, unsure if the first went through. How do you avoid double-charging?

The call: Require an idempotency key per charge; replay returns the stored result. — The first request does the work and stores the result under the key; any retry with the same key returns that same result without charging again. Make repeating a request unable to repeat its effect.

Require an idempotency key per logical charge. The first request does the work and stores the result under that key; any retry with the same key returns the same result without charging again. Safe to retry, always.

Make retries free: Because failures are ambiguous (did it succeed before the timeout?), the only safe design is one where repeating a request can’t repeat its effect. Idempotency keys turn “maybe” into “exactly once”.

Step 3 · Track every cent

The double-entry ledger

A single “balance” field that you increment is impossible to audit and easy to corrupt under concurrency. When money goes missing, you need to know exactly where — and prove it.

Design decision: How do you record balances so money is auditable and can’t be silently corrupted under concurrency?

The call: An immutable double-entry ledger; balances are derived sums. — Every transaction debits one account and credits another by equal amounts, append-only, never edited. Debits must equal credits so the books always sum to zero — a continuous built-in correctness check.

Record every movement in an immutable, double-entry Ledger: each transaction debits one account and credits another by equal amounts, and rows are never edited — only new entries appended. Balances are derived by summing entries.

Debits equal credits: Double-entry has kept money honest for 500 years. Every cent leaving one account arrives in another, so the books must always sum to zero — a built-in, continuous correctness check.

Step 4 · Don’t hold the cards

Tokenization & PCI

Storing raw card numbers makes you a giant target and drags your entire system into the strictest PCI compliance scope. One leak is catastrophic — and most of your services never need the real number anyway.

Design decision: You need to charge cards repeatedly but storing card numbers makes you a giant breach target. What do you do?

The call: Send cards to a PCI vault that returns a token; store only tokens. — Card data goes straight into a hardened vault that returns a token; your API, workers and ledger handle only meaningless tokens. Raw PANs live solely in the vault and at the network — shrinking PCI scope and blast radius.

Send card data straight into a PCI-compliant Token Vault, which returns a token. Everything else — your API, workers, ledger — stores and passes only tokens; raw card numbers exist solely inside the vault and at the network.

Shrink the blast radius: Tokenization confines sensitive data to one hardened component. The rest of the system handles meaningless tokens, slashing both PCI scope and the damage any breach could do.

Step 5 · Slow banks, fast API

Async events & webhooks

Authorization and settlement can take seconds — or arrive minutes later (a bank confirmation, a delayed failure). Blocking the merchant’s request until everything finishes is both slow and impossible.

Design decision: Authorization and settlement can take seconds or arrive minutes later. How does the API respond fast?

The call: Emit state changes to an event stream; webhook the final outcome. — The API returns the current state immediately; a durable event stream drives webhooks that notify the merchant of the final result (with retries) once it arrives. Slow external steps run off the request path.

Emit each state change to a durable Payment Events stream. The API responds quickly with the current state; Webhooks later notify the merchant of the final outcome (with retries). Slow, external steps run asynchronously off the request path.

Decouple from the slow world: You don’t control the banks’ latency, so don’t couple your response to it. Events + webhooks turn an inherently asynchronous, multi-party process into something each side can handle at its own pace.

Step 6 · Prove it balances

Reconciliation

Your ledger says one thing; the processor’s settlement may say another, because of fees, declines, chargebacks, or a missed event. Over millions of payments, tiny discrepancies hide real lost money.

Design decision: Your ledger and the processor’s settlement can disagree (fees, declines, chargebacks, a missed event). How do you catch lost money?

The call: Reconcile the ledger against PSP statements daily, flag mismatches. — A daily job compares your ledger line-by-line against the bank’s settlement statements — external truth — and flags every mismatch for investigation. It catches whatever the live path missed.

A daily Reconciliation job compares your Ledger against the PSP’s settlement statements, line by line, and flags every mismatch for investigation. The bank’s record is treated as external truth your internal books must agree with.

Trust, then verify: Distributed systems drift; money systems can’t. Reconciliation is the safety net that catches whatever the live path missed, turning “we think it’s right” into “we’ve proven it’s right”.

Step 7 · Consistency & scale

The sharp edges

A capture succeeds at the PSP but your ledger write fails — now your records and the bank disagree. And at scale, hot merchants and refunds/chargebacks add states and load that strain the system.

Make the worker a durable, retryable state machine (outbox pattern: persist intent, then act, then mark done) so it always converges even after crashes. Shard by merchant/account, and model refunds and chargebacks as first-class ledger transactions, never edits.

Converge, don’t assume: Across systems you can’t get atomicity, so design for eventual consistency with a relentless reconciler: record intent durably, retry until the external side agrees, and let the ledger be the immutable history of it all.

You did it

You just designed a payment system.

  • A payment modeled as a state machine: authorize then capture via the PSP.
  • Idempotency keys make every charge safe to retry — exactly one charge per key.
  • An immutable double-entry ledger keeps every cent auditable and balanced.
  • Tokenization confines card data to a PCI vault, shrinking the blast radius.
  • Async events + webhooks decouple the fast API from slow, external banks.
  • Daily reconciliation against PSP statements proves the books actually agree.
  • A durable, retryable worker plus refunds-as-ledger-entries handle failure and scale.
built to balance, not memorized — make the calls, drop the PSP, run the gauntlet.
Finished this one? 0 / 65 System Designs done

Explore the topic

See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.