Design a Payment System
Take card payments through an external processor with no double charges or lost money: idempotency keys, a state machine, a ledger, reconciliation.
On this page
A customer presses "Pay $40" on an online shop. A second later they see "Thank you". In between, the shop's server asked a payment platform to charge the card, the platform asked a card processor, and the processor asked the card network and the bank. Any of those calls can time out after the money has moved. The system you design has to know, every time, whether this customer was charged, exactly once, and who is owed what.
Interviewers ask this question to see whether you change your instincts when money is involved. Most designs trade a little correctness for availability. This one does the opposite, and the deep parts are about uncertainty: retries, lost responses, late notifications and books that must balance. The steps follow A Framework for Any Design.
Clarify the problem
Design a payment system.
Whose payments, and who moves the money? Are we a platform that shops call to take card payments, using an external card processor, or are we building the processor itself?
A platform. Many online shops call your API; a third-party processor talks to the card networks. You keep a fee and pay the rest out to the shops.
How many payments a day, and do we need refunds?
About 5 million payments a day, averaging $40. Yes to refunds. Payouts happen daily.
Then my priorities are: never charge a customer twice, never lose track of a payment, and keep books that show exactly what each shop is owed. If I have to choose, I'll reject a payment rather than risk a double charge. I'll leave out fraud scoring, multiple currencies and the payout transfer itself.
- Functional: create a payment for an order and charge the card through the processor; report its status; refund all or part of it; keep a ledger of what each shop is owed; reconcile with the processor daily.
- Non-functional: a payment request retried any number of times charges at most once; no acknowledged payment is ever lost (zero data loss on a database failure); every movement of money is recorded and auditable for years; the API usually answers in about 2 seconds, mostly the processor's time, and gives up on the processor after 5; availability is high, but correctness wins any conflict.
- Out of scope: fraud detection, currency conversion, sending payouts to bank accounts, storing raw card numbers (the processor holds them and gives us a token).
Estimate the scale
A day has 86,400 seconds; call it about 100,000 for quick sums, but use the real number when it matters.
- Payments: 5 million ÷ 86,400 ≈ 58 a second on average. Sales days run hotter; plan for 10x, about 600 a second.
- Money: 5 million × $40 = $200 million a day. A bug that loses 0.1% is $200,000 a day, which is why this design spends its effort on correctness, not throughput.
- Payment records: 5 million × 365 ≈ 1.8 billion a year; at 1 KB each, about 1.8 TB a year.
- Uncertain outcomes: if 1 call in 1,000 to the processor times out, that is 5,000 payments a day whose result you do not know at first. They are a normal, daily case, not an edge case.
Each payment writes 3 ledger lines of about 200 bytes. How much ledger data do you keep after 7 years, the length of time auditors may ask for?
- 5 million payments a day
- 3 ledger lines per payment, about 200 bytes each
- Ledger lines are never deleted
- Keep 7 years
Show the working
- Lines per day: 5 million × 3 = 15 million
- Bytes per day: 15 × 106 × 200 B = 3 GB
- Per year: 3 GB × 365 ≈ 1.1 TB
- Seven years: 1.1 TB × 7 ≈ 7.7 TB
Estimate 7.7 TB (anything from 2 TB to 30 TB is a good answer)
Large but steady. Keep recent months in the main database and move older, closed periods to cheaper storage that can still be queried.
At 600 payments a second, throughput is easy for one well-sized relational database. The hard part is that each of those writes has to be exactly right.
Write the endpoint a shop calls to charge a customer, and the one to check a payment. How does the shop retry safely after a timeout?
Compare with a model answer
A create-payment call that carries an idempotency key chosen by the shop, a read by payment id, and a refund call that also takes a key. A retry sends the same key and gets the same payment back instead of a new charge.
Define the API
POST /v1/payments Idempotency-Key: ord-5521-try
body: { "amount": 4000, "currency": "USD", "card_token": "tok_...", "order_ref": "5521" }
201 { "id": "pay_88", "status": "succeeded" }
201 { "id": "pay_88", "status": "processing" } outcome not known yet
402 { "id": "pay_88", "status": "failed", "reason": "card_declined" }
409 Conflict a request with this key is still running
422 Unprocessable the same key was used with a different body
GET /v1/payments/{id}
200 { "id": "pay_88", "status": "succeeded", "refunded": 0 }
POST /v1/payments/{id}/refunds Idempotency-Key: ref-5521-1
body: { "amount": 1500 }
201 { "id": "re_9", "status": "processing" }
Webhooks we send to shops: payment.succeeded, payment.failed, refund.succeededThe decision worth arguing is the processing answer. When the processor does not reply in time, the honest response is "we don't know yet", not "failed". If the API said failed, the shop would ask the customer to pay again, and the first charge might still go through. So the API can return processing, and the shop learns the outcome from a webhook or by polling.
What records do you need for a payment, its history, and the money it moves? Which of them may be updated, and which must only ever be appended?
Compare with a model answer
A payment row with its current status (updated only by allowed transitions), an idempotency key with a unique constraint, an append-only history of status changes, and an append-only ledger of entries that sum to zero per transaction.
Model the data
payments
payment_id VARCHAR(32) PRIMARY KEY
merchant_id BIGINT
amount_cents BIGINT
status VARCHAR(16) -- created | processing | succeeded | failed | refunded
processor_ref VARCHAR(64) NULL
idem_key VARCHAR(64)
request_hash CHAR(64) -- detects a reused key with a new body
UNIQUE (merchant_id, idem_key)
payment_events -- append-only history
payment_id, seq, from_status, to_status, source, created_at
ledger_entries -- append-only, never updated
entry_id BIGINT PRIMARY KEY
txn_id BIGINT -- entries of one txn sum to zero
account VARCHAR(64) -- "processor_receivable", "merchant:42"
amount_cents BIGINT -- debit positive, credit negative
created_at TIMESTAMPMoney is stored as whole cents in integers, never floating point, which cannot represent most decimal fractions exactly. Access patterns are small, transactional and mixed: insert a payment and its key together, move it to a new status only from the right old status, and write ledger lines that must all commit or none. That is a relational database with ACID transactions, run as a single leader with a synchronous replica. Key-value stores are great at scale, but here the one-transaction guarantees matter more than horizontal growth, and 600 payments a second does not need that growth.
Sketch the boxes: where does the shop's request go, where does it reach the processor, how do late results come back, and what checks the books every day?
Compare with a model answer
A payment service writes to one database and calls the processor. A webhook handler receives the processor's late results and applies them to the same records. A reconciler compares the processor's daily settlement file with our ledger.
High-level design
request and replyasynchronous message
Not drawn: our own webhooks to shops. Write each one to an outbox table in the same transaction as the status change, and let a sender deliver it with retries. A crash between "succeeded" and "tell the shop" then delays the notice instead of losing it.
Step through a payment whose first reply is lost, which is the case the design exists for.
- status
- -
- customer charged
- 0
1The shop sends
POST /paymentsfor $40 with keyord-5521-try.- status
- created
- customer charged
- 0
2One transaction inserts the key and payment
pay_88ascreated. The unique constraint on (shop, key) means a duplicate request cannot create a second payment.- status
- processing
- customer charged
- 0
3It moves the payment to
processingbefore calling out, so a crash from here on leaves a record that says "ask the processor". Then it sends the charge, withpay_88as the processor's idempotency key.- status
- processing
- customer charged
- $40
4The processor charges the card, but its reply is lost. After 5 seconds the service stops waiting. The money moved; we do not know it yet.
- status
- processing
- customer charged
- $40
5The service answers
201 processing, not failed, and the payment stays inprocessing. The shop's HTTP client gave up after 3 seconds, though, so it never sees this answer.- status
- processing
- customer charged
- $40
6The shop retries with the same key. The key exists, so the service returns the stored payment, still
processing, instead of charging again.- status
- processing
- customer charged
- $40
7The processor's webhook arrives:
pay_88succeeded. The handler checks the signature and stores the event id, so a redelivered copy is ignored.- status
- succeeded
- customer charged
- $40
- ledger sum
- 0
8One transaction moves
processingtosucceededonly if it is stillprocessing, and writes 3 ledger lines: +4000, −3880, −120. They sum to zero.- status
- succeeded
- customer charged
- $40
- matched
- yes
9The next day the processor's settlement file lists
pay_88at $40. The reconciler finds a matching payment and ledger lines, so nothing needs a human.
Deep dives
Idempotency keys, end to end
The idempotency lesson covers the basic pattern. In a payment system, three details decide whether it actually works.
- The key is written in the same transaction as the payment. If the key lives in a cache and the payment in the database, a crash between the two writes lets a retry slip through.
- The key is scoped and fingerprinted. Unique per shop, not globally, and stored with a hash of the request. The same key with a different amount is a client bug: return 422 rather than guess.
- The key travels downstream. Our retries to the processor reuse
pay_88as its idempotency key, so a retry after a lost reply returns the first charge instead of making a second. Every hop that can be retried needs its own key.
This is what "exactly once" means here, and it is worth saying precisely: requests can arrive any number of times, and the charge takes effect once. No network can deliver a message exactly once; the receiver makes repeats harmless.
The payment state machine
Every payment is in exactly one state, and only certain moves are allowed. Writing the rules down turns a pile of race conditions into a table you can check.
| From | To | Triggered by |
|---|---|---|
| created | processing | we are about to call the processor |
| processing | succeeded | processor reply, webhook, or a status query |
| processing | failed | a definite decline, or the processor confirms it has no such charge |
| succeeded | refunded | a refund for the full amount succeeds |
Each move is a conditional update: SET status = 'succeeded' WHERE payment_id = 'pay_88' AND status = 'processing'. If the reply and the webhook race, the first applies and the second changes zero rows, which is correct. A late "processing" event can never drag a succeeded payment backwards, because there is no such arrow.
Payments stuck in processing are expected. A sweeper finds ones older than a minute and asks the processor for the result by idempotency key. Only when the processor says it has no charge with that key may the payment move to failed, and even that answer can race with the original request still in flight. So either wait past the processor's own request timeout before trusting "no such charge", or resend the charge with the same key: the processor then returns the first result or makes the charge once, and the outcome becomes definite.
A double-entry ledger
A single balance column per shop is easy to update and impossible to audit: when it is wrong, nothing tells you why. A ledger records every movement as entries that sum to zero. For the $40 payment, with a 3% platform fee of $1.20:
| Account | Amount (cents) | Meaning |
|---|---|---|
| processor_receivable | +4000 | the processor owes us $40 |
| merchant:42 | −3880 | we owe the shop $38.80 |
| platform_fees | −120 | we earned $1.20 |
+4000 − 3880 − 120 = 0. (Real processors also charge their own fee; leaving it out keeps the example small.) Three rules make the ledger trustworthy:
- Every transaction sums to zero. Check it before commit. Money cannot appear or vanish, only move between accounts.
- Append only. A mistake is fixed with a new, reversing transaction, never by editing a row. The history is the audit trail.
- Balances are derived. A shop's balance is the sum of its entries. You may cache it in a balance row updated in the same transaction, but the entries are the truth, and you can rebuild the cache from them.
Webhooks, retries and reconciliation
The processor sends webhooks at least once and not always in order. The handler verifies the signature, then in one transaction inserts the event id into a table with a unique key and applies the event through the state machine. Only after that commits does it return 200. A repeat hits the unique key and is acknowledged without changing anything; a crash before the commit sends no 200, so the processor delivers the event again. Recording the id first and applying the change later would turn a crash in between into a lost event. Out-of-order events are harmless because illegal moves change nothing. If an event looks wrong, fetch the payment's current state from the processor's API instead of trusting the event.
Retries go out with exponential backoff and jitter, and always with the same key. One rule interviewers probe: never retry a payment on a second processor while the first one's outcome is unknown. The keys are not shared between processors, so both could charge.
Reconciliation is the safety net under all of it. Every day the processor publishes a settlement file listing what it actually charged and refunded. The reconciler matches each line to a payment and its ledger lines, by processor reference and amount.
| Option | Good at | Bad at | Pick it when |
|---|---|---|---|
| Fix automatically Usual default | Fast. A missed webhook for a known payment is fixed by applying the processor's state through the normal state machine. | Auto-fixing the wrong cause can hide a real bug. | The mismatch has one known cause and one safe fix. |
| Queue for a human | Someone looks at the evidence before money moves. | Slow, and costly in volume: if even 1 line in 10,000 disagreed, that is 500 a day. | Amounts differ, or the processor charged something we have no record of. |
| Trust our records | No work. | Errors pile up silently until a shop's payout is wrong. | Never. Matching is the point. |
Bottlenecks and failures
At 10x, 6,000 payments a second at peak and 18,000 ledger lines a second, one database leader starts to strain. Partition by merchant_id: a payment and its ledger lines for one shop stay on one partition, so each transaction is still local. Platform-wide accounts like platform_fees and processor_receivable become hot rows, and every payment touches them; give each partition its own copy of those accounts and sum them for reports. The processor's rate limits also become real, which is a conversation with the provider, not a design trick.
Correctness beats availability in every row below. When in doubt, the system stops taking payments rather than guessing; a shop losing sales for two minutes is bad, a customer charged twice or a ledger that does not balance is worse.
| When this dies | What happens | What you do |
|---|---|---|
| A payment server, mid-call | The payment is left in processing. | The sweeper queries the processor by key and finishes it. |
| The database leader | Writes stop until the synchronous replica is promoted, usually within a minute. | Return 503 meanwhile. Because replication was synchronous, no acknowledged payment is lost. |
| The processor | Charges time out or fail. | Return clear errors and stop retrying after a few tries. Do not switch processors for payments whose outcome is unknown. |
| The webhook handler | Events queue up at the processor, which keeps retrying them. | Payments stay in processing; the sweeper's status queries resolve them anyway. |
| The reconciler | Mismatches are found later. | Nothing breaks today; run it again over the missed days before payouts. |
What a strong answer sounds like
| Level | What the answer does |
|---|---|
| Junior | Calls the processor and saves the result, and adds a retry; with prompting, adds an idempotency key. |
| Mid | Uses idempotency keys correctly, defines payment states, handles webhooks and stores money as integers. |
| Senior | Treats timeouts as unknown, passes keys downstream, models money in a double-entry ledger, reconciles daily, and explains why this system chooses correctness over availability. |
Your call to the processor times out. What do you do?
Mark the payment failed and retry it a few times.
Your call to the processor times out. What do you do?
A timeout means I don't know, so the payment stays in processing. I retry with the same idempotency key, so the processor returns the first result instead of charging again. If that fails too, I tell the shop it's processing and let the webhook or a status query settle it.
And if the webhook never arrives?
A sweeper queries the processor for anything stuck in processing for more than a minute, and the daily reconciliation catches whatever is left. Three independent paths reach the same state machine.
Follow-up questions
How do refunds work?
A refund is its own record with its own key and states, linked to the payment. When it succeeds, it writes the reverse ledger lines; partial refunds are allowed while their total stays at or below the original amount.
Authorize now, capture later?
For goods that ship later, the processor can authorize (reserve the funds) and capture when the order ships. Add authorized and captured states; authorizations expire after some days, so a job captures or releases them in time.
How do you pay shops?
Once a day, sum each shop's ledger balance, create a payout record with its own idempotency key, and write ledger lines moving the amount from the shop's account to a payouts account. The bank transfer is another external call with the same unknown-outcome care.
How do you store card numbers?
You don't. The customer's browser sends the card to the processor, which returns a token. Keeping raw card numbers out of your systems shrinks the compliance burden enormously.
Could you use an eventually consistent database?
For the payment records and ledger, no: you need the key insert and the status change to be atomic, and a lost acknowledged write is lost money. For read-only views such as a shop's dashboard, a replica a few seconds behind is fine.
After your attempt, tick what you really did. Be strict: "I thought about it" is not "I said it out loud".