InterviewKata
System design · Worked design D16 of 46 · Location and marketplaces · Long form

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

  1. Charge a customer for an order through a PSP.
  2. Record every movement of money and show payment status to the customer and the shop.
  3. Refund payments and pay sellers out on a schedule.

Not now:

4. How well it must do it

TargetWhy this number
Exactly-once effect: never charge twice for one order, never lose a payment consistencyEvery mistake is real money and a support ticket.
Auditable: every balance explained by immutable records durabilityFinance must be able to prove any number.
Secure: card numbers never touch our servers securityKeeps most of the PCI DSS scope with the PSP.
Correct before fast: a few seconds is fine consistency over latencyA slow payment is better than a wrong one.

5. Numbers that change the design

QuantityWorking→ so we…
Payments2M 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, …) × 2M8M rows a day, ~3B a year → so partition by time; archive, never delete
PSP latencyCard authorisation takes ~1–3 s and sometimes times outTimeouts are normal → so every call must be safe to retry
RetriesEven 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

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)

  1. Retries (the customer's and ours) can charge twice.
  2. A status column can't explain balances, fees or partial refunds.
  3. Late or duplicate PSP events can move a payment backwards.
  4. A crash between "charged" and "saved" leaves money with no record.
  5. 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

ApproachWhy
✗ BadDisable the Pay button and hopeNetwork retries, app restarts and our own internal retries still send the request twice.
~ GoodThe client sends an idempotency key; our API stores it with the result and returns the saved result for a repeatStops the double click. But if our call to the PSP times out and we retry it, the PSP may still charge twice.
✓ GreatPass an idempotency key to the PSP as well (derived from our payment id), save the key before calling out, and reconcile with the PSP dailyEvery 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.

TransactionAccountDebitCredit
pay_81 chargePSP receivable40.00
pay_81 chargeShop payable40.00
pay_81 feeFees expense1.20
pay_81 feePSP receivable1.20

9.3 A state machine and webhooks exactly-once effect

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

FailureWhat users seeHow we detect itWhat limits the damage
PSP call times out"Processing"Timeout metricsRetry with the same PSP key; status check
We crash after charging"Processing"Stuck-payment sweep; reconciliationAsk the PSP by key; record the result
Duplicate webhookNothingEvent id already seenIgnored
Outbox relay downOrders ship lateOutbox ageEvents wait in the table; nothing lost
Ledger doesn't balanceNothing (internal)Sum-to-zero check per transactionThe write is rejected; alert

12. Wrap-up and the clock

Trade-offs we made:

What we'd watch:

At 10×:

A 45-minute interview

0–4Frame the problem (PSP, volume, async methods)
4–9Must do, how well, numbers
9–12Nouns, contract, data flow
12–18First working version, parked list
18–40Deep dives: idempotency (9.1), ledger (9.2), states and webhooks (9.3), reconciliation (9.5)
40–45Follow-ups and wrap-up

A 60-minute interview

0–5Frame the problem
5–11Must do, how well, numbers
11–15Nouns, contract, data flow
15–23First working version
23–50Deep dives 9.1–9.6 and the final picture
50–60Follow-ups: PSP outage, payouts, refunds; wrap-up
LevelWhat a strong answer shows
Mid-levelA payment service calling a PSP, a payments table with statuses, tokens instead of card numbers.
SeniorIdempotency 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:

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?

Each hop needs its own protection; the key must travel to the PSP too.

What is the core rule of a double-entry ledger?

Money always moves from somewhere to somewhere, so the books always balance.

We crashed after the PSP charged but before we saved anything. What finds this?

Reconciliation compares what the PSP did with what we recorded, and flags differences.

A "failed" webhook arrives after the payment already succeeded. Why doesn't it flip the status?

UPDATE … WHERE status='processing' matches nothing once the payment has succeeded.
ResourceWhy
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.