Design a payment system
A payment system handles only a few requests a second next to a feed or a chat app, yet it is one of the hardest interview designs, because every failure costs money. A response lost on the network can charge a customer twice; a crash at the wrong moment can take money with no record of why. The whole design is about making every step safe to retry and every cent accounted for. It follows the interview framework phase by phase.
1. The prompt
"Design the payment system for an online shop" (or Stripe's side of it, or a marketplace's payouts): customers pay for orders by card; sellers are paid out later; finance needs records that always add up.
The numbers are small. The design effort goes into correctness across systems you don't control: the customer's flaky network, your own crashes, and a payment provider that sometimes times out after doing the work.
2. Frame the problem
Do we process cards ourselves?
No: we use a payment service provider (PSP) such as Stripe or Adyen.
The PSP talks to card networks; we orchestrate, record and reconcile.
How many payments?
About 2M orders a day, peaks around 10× average.
Small enough for one relational database: we can use real transactions.
Must the customer get an instant answer?
Usually yes for cards; some methods (bank transfers, 3-D Secure) finish later.
We need a "processing" state and webhooks, not just a synchronous call.
Refunds and seller payouts?
Yes to both; payouts on a schedule.
Money flows both ways, so a ledger, not a status column.
Currencies?
Several, but no currency exchange.
Store amounts as integers in minor units with a currency code.
Who needs these records?
Finance and auditors.
Append-only history and daily reconciliation are requirements, not extras.
3. What it must do
- Charge a customer for an order through a PSP.
- Record every movement of money and show payment status to the customer and the shop.
- Refund payments and pay sellers out on a schedule.
Not now:
- Building card networks ourselves.
- Fraud models (mention them).
- Currency exchange.
4. How well it must do it
| Target | Why this number |
|---|---|
| Exactly-once effect: never charge twice for one order, never lose a payment consistency | Every mistake is real money and a support ticket. |
| Auditable: every balance explained by immutable records durability | Finance must be able to prove any number. |
| Secure: card numbers never touch our servers security | Keeps most of the PCI DSS scope with the PSP. |
| Correct before fast: a few seconds is fine consistency over latency | A slow payment is better than a wrong one. |
5. Numbers that change the design
| Quantity | Working | → so we… |
|---|---|---|
| Payments | 2M orders a day; peaks ~10× average | ~23 a second average, ~230 at peak → so one relational database with real transactions |
| Ledger rows | ~4 entries per payment (charge, fee, payout, …) × 2M | 8M rows a day, ~3B a year → so partition by time; archive, never delete |
| PSP latency | Card authorisation takes ~1–3 s and sometimes times out | Timeouts are normal → so every call must be safe to retry |
| Retries | Even 0.1% of requests retried = 2,000 a day | → so a double charge is a daily event without idempotency, not a rare one |
6. The nouns
- Payment: one attempt to collect money for an order.
- Idempotency record: a key and the saved result of the first request.
- Ledger entry: one side of a money movement; never updated.
- Outbox event: a message saved with the data, published later.
payments payment_id, order_id, amount (minor units), currency, status, psp_ref, idempotency_key idempotency key → request_hash, status: in_progress | done, saved_response, created_at ledger entry_id, txn_id, account, direction: debit | credit, amount, currency, created_at (append-only) outbox event_id, type, payload, created_at, published_at psp_events psp_event_id (unique), received_at (webhook dedup)
7. The contract
POST /v1/payments Idempotency-Key: 7c9e… {order_id, amount: 4000, currency: "USD", payment_token: "tok_…"}
→ 201 {payment_id, status: "succeeded"} (a retry with the same key returns this same response)
GET /v1/payments/{id} → status
POST /v1/payments/{id}/refunds Idempotency-Key: …
POST /psp/webhooks (signed by the PSP) → 200, processed asynchronously
Amounts are integers in the currency's smallest unit (4000 = $40.00), never floating point.
Data flow: (1) the card goes straight to the PSP, which returns a token → (2) the app calls our API with the token and a key → (3) we save the key, then charge the PSP with our own key → (4) one transaction saves the status, ledger entries and an outbox event → (5) the event tells the order service → (6) reconciliation checks it all the next day.
8. First working version
A payment API, a PSP, and a payments table with a status column.
8.1 Paying
Version one charges the PSP and then saves the result. The happy path works; the unhappy one charges twice.
8.2 Showing status
Read the payment row by id.
8.3 Refunds and payouts
Call the PSP's refund API and set the status to refunded; pay sellers by summing their payments.
Parked for later (section 9 fixes each)
- Retries (the customer's and ours) can charge twice.
- A status column can't explain balances, fees or partial refunds.
- Late or duplicate PSP events can move a payment backwards.
- A crash between "charged" and "saved" leaves money with no record.
- Card numbers in our systems put us in full PCI scope.
9. Make it hold up
9.1 Idempotency, end to end exactly-once effect
| Approach | Why | |
|---|---|---|
| ✗ Bad | Disable the Pay button and hope | Network retries, app restarts and our own internal retries still send the request twice. |
| ~ Good | The client sends an idempotency key; our API stores it with the result and returns the saved result for a repeat | Stops the double click. But if our call to the PSP times out and we retry it, the PSP may still charge twice. |
| ✓ Great | Pass an idempotency key to the PSP as well (derived from our payment id), save the key before calling out, and reconcile with the PSP daily | Every hop is safe to retry. Stripe, for example, saves the result of the first request with a key and returns it for retries with that key; keys can be pruned after 24 hours. |
Two details interviewers probe: save the key as in progress before calling the PSP, so a concurrent repeat waits or gets "still processing" instead of charging again; and store a hash of the request with the key, so the same key with a different amount is rejected rather than replayed.
9.2 A double-entry ledger auditable
Never store "balance = 120" and update it. Record every movement as a transaction of entries that sum to zero: a $40 payment debits what the PSP owes us and credits what we owe the shop; the PSP's $1.20 fee is a further pair. Balances are sums of entries. Entries are append-only: a mistake is fixed with a new reversing transaction, so history explains every number.
| Transaction | Account | Debit | Credit |
|---|---|---|---|
| pay_81 charge | PSP receivable | 40.00 | |
| pay_81 charge | Shop payable | 40.00 | |
| pay_81 fee | Fees expense | 1.20 | |
| pay_81 fee | PSP receivable | 1.20 |
9.3 A state machine and webhooks exactly-once effect
- States: created → processing → succeeded / failed, then refunded. Transitions are conditional updates (
WHERE status = 'processing'), so a late or duplicate message can't move a payment backwards. - Webhooks: some outcomes arrive later (bank confirmations, disputes). PSPs retry webhook delivery and may send an event more than once or out of order, so verify the signature, record the event id, and ignore ones already processed.
9.4 The outbox: saved and announced together never lose a payment
The payment status, ledger entries and a "PaymentSucceeded" event are written in one database transaction; a relay publishes the event to the order service (Module 22). There's no "saved but never announced" gap and no "announced but never saved" one. Delivery is at-least-once, so consumers ignore duplicates by event id.
9.5 Reconciliation: trust, but verify auditable
Every day the PSP sends a settlement report of what it actually charged and paid out. A reconciliation job matches it line by line against the ledger. Mismatches (a charge we never recorded because we crashed, a refund the PSP never got) go to an automated fix or a person. This is the last line of defence, and real payment teams rely on it. Payments stuck in processing are also checked during the day by asking the PSP for their status, using our key.
9.6 Security secure
Use the PSP's hosted payment page or embedded fields so card numbers go straight from the browser to the PSP; we only see a token. That keeps most of the PCI DSS requirements with the PSP. Encrypt data at rest, restrict who can issue refunds, require two people for large manual adjustments, and log every action for audit.
9.7 The whole design after the deep dives
One payment from tap to books, including a retry that doesn't charge twice. Press Play or drag the timeline.
10. Interviewer follow-ups
The PSP is down. What now?
Route to a second PSP if you have one (each with its own keys and reconciliation); otherwise keep the payment in processing, tell the customer, and retry with the same key when it recovers.
How do payouts work?
The same problem in reverse: a scheduled job sums each seller's payable balance from the ledger, creates idempotent transfers, records ledger entries, and reconciles against bank statements.
Partial refunds.
A refund is its own record with its own key and ledger transaction; the payment's refundable amount is the charge minus the sum of refunds, checked in the same transaction.
How long do you keep idempotency keys?
At least as long as clients might retry (a day or more); the PSP has its own window. Ledger and payment records are kept for years.
Why not a microservice per step with a saga?
Within our system, one database transaction is simpler and stronger. A saga is for steps across systems that can't share a transaction: the PSP, the order service, payouts.
Fraud.
A risk check before charging (rules plus a model), 3-D Secure for risky payments, and dispute handling via webhooks feeding the ledger.
Scale to 100×.
Partition payments by merchant or payment id; make the ledger its own service with strict invariants; reconciliation becomes a streaming job.
11. When things break
| Failure | What users see | How we detect it | What limits the damage |
|---|---|---|---|
| PSP call times out | "Processing" | Timeout metrics | Retry with the same PSP key; status check |
| We crash after charging | "Processing" | Stuck-payment sweep; reconciliation | Ask the PSP by key; record the result |
| Duplicate webhook | Nothing | Event id already seen | Ignored |
| Outbox relay down | Orders ship late | Outbox age | Events wait in the table; nothing lost |
| Ledger doesn't balance | Nothing (internal) | Sum-to-zero check per transaction | The write is rejected; alert |
12. Wrap-up and the clock
Trade-offs we made:
- Synchronous vs asynchronous confirmation: waiting for the PSP gives the customer an instant answer; some methods finish later and need webhooks and a "processing" state in the UI.
- One PSP vs several: routing between PSPs improves availability and cost, but each adds integration, reconciliation and idempotency handling.
- A ledger over a status column: more rows, but every number can be explained.
- Correctness over latency everywhere.
What we'd watch:
- Reconciliation mismatches (should be ~0).
- Payments stuck in processing.
- PSP error and timeout rates.
- Outbox age.
- Refund rate.
At 10×:
- Partition by merchant.
- A ledger service.
- Multiple PSPs.
- Streaming reconciliation.
A 45-minute interview
| 0–4 | Frame the problem (PSP, volume, async methods) |
| 4–9 | Must do, how well, numbers |
| 9–12 | Nouns, contract, data flow |
| 12–18 | First working version, parked list |
| 18–40 | Deep dives: idempotency (9.1), ledger (9.2), states and webhooks (9.3), reconciliation (9.5) |
| 40–45 | Follow-ups and wrap-up |
A 60-minute interview
| 0–5 | Frame the problem |
| 5–11 | Must do, how well, numbers |
| 11–15 | Nouns, contract, data flow |
| 15–23 | First working version |
| 23–50 | Deep dives 9.1–9.6 and the final picture |
| 50–60 | Follow-ups: PSP outage, payouts, refunds; wrap-up |
| Level | What a strong answer shows |
|---|---|
| Mid-level | A payment service calling a PSP, a payments table with statuses, tokens instead of card numbers. |
| Senior | Idempotency keys at both hops, the timeout-then-retry double charge, state machine with conditional transitions, double-entry ledger, webhooks with dedup, outbox. |
| Staff+ | Reconciliation as a first-class system, ledger invariants and audits, multi-PSP routing, payouts, failure handling between systems you don't control, compliance scope. |
Common mistakes:
- Idempotency only at the API, not the PSP.
- Floating-point amounts.
- Updating balances in place.
- Trusting webhook order.
- No reconciliation.
13. Try it, check yourself, read more
Choose what goes wrong and how well the system is protected, then watch the messages play out. Count how many times the customer is charged.
Our API already deduplicates client retries with an idempotency key, yet a customer was charged twice. What most likely happened?
What is the core rule of a double-entry ledger?
We crashed after the PSP charged but before we saved anything. What finds this?
A "failed" webhook arrives after the payment already succeeded. Why doesn't it flip the status?
| Resource | Why |
|---|---|
| Idempotent requests (Stripe docs) | How a PSP stores and replays results for idempotency keys. |
| Webhooks (Stripe docs) | Retries, duplicates, ordering and signature checks. |
| Accounting for developers (Modern Treasury) | Double-entry bookkeeping explained for engineers. |