System design interview: design a payment system
A payment system for an online business does not move money itself: it asks a payment service provider (PSP) such as Stripe or Adyen to charge a card, tracks each payment through a strict state machine, and records every movement of money in its own double-entry ledger. The interview is about correctness rather than traffic: idempotency keys so a retry never charges twice, a strongly consistent ledger, events published through an outbox instead of distributed transactions, and daily reconciliation against the PSP's reports.
By Evin JohnPublished 8 min read
1. Clarify the requirements
Establish early that you will integrate a PSP rather than connect to card networks. Then agree on which flows matter.
- Functional: customers pay for an order by card or wallet; the business can capture later (when the order ships) and refund fully or partly; every payment, fee, refund and payout is recorded; payment status is visible to the order system and to finance.
- Non-functional: never charge twice and never lose a record of a charge; ledger writes are strongly consistent and auditable; an unknown outcome is shown as "processing" rather than guessed; a few seconds of latency is acceptable because the PSP call dominates.
- Out of scope unless asked: becoming an acquirer or card processor, building fraud models (assume the PSP's), currency conversion, and seller onboarding for marketplaces.
2. Estimate the scale
The assumed numbers mostly show that throughput is not the problem.
| Quantity | Assumption | Result |
|---|---|---|
| Payments | 10 million a day | about 116 a second on average; about 1,160 at a 10x sale peak |
| PSP calls | about 2 per payment (authorize, capture), before retries | 20 million a day; about 2,300 a second at peak |
| Ledger entries | 4 per payment (charge and fee, two entries each) | 40 million a day, about 14.6 billion a year |
| Ledger storage | about 200 bytes an entry with indexes | 8 GB a day, about 2.9 TB a year |
| Webhooks received | about 2 per payment | 20 million a day, arriving in any order |
A well-indexed relational database handles 1,160 payments a second. Storage grows steadily, so the ledger is partitioned by time from the start and archived after the audit retention period.
3. Define the API
POST /v1/payments
headers: Idempotency-Key: 3f9a61c2-order-5512-attempt-1
body: { "orderId": "ord_5512", "amount": 4999, "currency": "USD",
"paymentMethod": "pm_tok_8Hq2", "capture": "later" }
returns: 201 { "paymentId": "pay_77", "status": "AUTHORIZED" }
same response again if the key was already used with the same body
409 if a request with this key is still in progress
422 if the key was reused with a different body
POST /v1/payments/pay_77/capture (Idempotency-Key required)
POST /v1/payments/pay_77/refunds (Idempotency-Key required)
body: { "amount": 1500 }
POST /webhooks/psp (from the PSP; signature verified)pm_tok_8Hq2 is a token. The card number was typed into the PSP's hosted fields or SDK on the checkout page and sent directly to the PSP; your servers never see it. That tokenization keeps most of your backend out of PCI DSS scope.
4. Sketch the data model
| Table | Key fields | Why it is there |
|---|---|---|
payments | payment_id, order_id, amount, currency, status, psp_reference, version | One row per payment; the state machine lives here; version enables conditional updates |
idempotency_keys | key (unique), request_hash, status, stored_response, expires_at | Replays the first response to a retry and detects a key reused for a different request |
ledger_entries | entry_id, txn_id, account_id, direction (debit or credit), amount, currency, created_at | Append-only; the entries of each txn_id must balance |
accounts | account_id, type (asset, revenue, expense, liability), currency | PSP clearing, bank, sales revenue, processing fees, refunds |
outbox | event_id, payment_id, type, payload, published_at | Written in the same transaction as a state change, published afterwards |
psp_events | psp_event_id (unique), type, received_at, processed_at | Drops duplicate webhooks |
Balances are derived from entries, never typed in. A worked example with an assumed processing fee of 1.75 on a 49.99 charge (any currency) shows why the PSP clearing account is so useful:
| Event | Debit | Credit | Amount |
|---|---|---|---|
| Payment captured | PSP clearing (asset) | Sales revenue | 49.99 |
| PSP fee charged | Processing fees (expense) | PSP clearing | 1.75 |
| PSP pays out | Bank account (asset) | PSP clearing | 48.24 |
After the payout, PSP clearing nets to zero: 49.99 minus 1.75 minus 48.24. Any balance left in it after a settlement is money you expected and did not receive, or received and cannot explain, which is exactly what reconciliation looks for.
5. Charge exactly once on a network that delivers at least once
Exactly-once delivery over a network is not possible: a request can succeed and its response can be lost. What you can build is at-least-once attempts plus idempotent handling, so repeating a request has the same effect as sending it once.
- Client creates the keyOne key per payment attempt, stored with the cart, so a double click or reload resends the same key.
- Service claims the keyInsert the key as
in_progressunder a unique constraint, with theCREATEDpayment row, in one transaction. A completed key replays its response; an in-progress one gets 409. - PSP gets the same guaranteeCall the PSP with an idempotency key derived from
payment_id. Stripe and Adyen both document idempotency keys on their APIs, so a retried call returns the original result instead of charging again. - Record the result atomicallyIn one database transaction: move the payment to its new state, append the ledger entries, write an outbox event, and store the response on the idempotency key.
- Handle the unknownIf the PSP call times out, the payment stays
PENDING. Retry with the same key, or look the payment up at the PSP; never create a new charge to find out.
| State | Meaning | Allowed next states |
|---|---|---|
CREATED | Recorded; PSP not called yet | PENDING, FAILED |
PENDING | Sent to the PSP; outcome not yet known | REQUIRES_ACTION, AUTHORIZED, FAILED |
REQUIRES_ACTION | Customer must complete a bank check such as 3-D Secure | AUTHORIZED, FAILED |
AUTHORIZED | Funds held on the card, not yet taken | CAPTURED, CANCELED (void, or the hold expired) |
CAPTURED | Money taken; it will settle to you | PARTIALLY_REFUNDED, REFUNDED |
FAILED, CANCELED, REFUNDED | Final | none |
Every transition is a conditional update, UPDATE payments SET status = 'CAPTURED', version = version + 1 WHERE payment_id = ? AND status = 'AUTHORIZED' AND version = ?. If zero rows change, another worker or webhook got there first, and nothing is written twice.
6. The architecture
- Client
- Checkout page
- PSP hosted card fields (card data goes to the PSP only)
- API
- API gateway
- Payment service (idempotency, state machine)
- Storage
- Primary database: payments, ledger, outbox
- Synchronous standby in a second zone
- External
- PSP (for example Stripe or Adyen)
- Card networks and banks, behind the PSP
- Async
- Outbox relay and event queue
- Webhook receiver
- Reconciliation job
- Order, notification and finance services
7. Keep it consistent as it grows
- Strong consistency for the ledger. Ledger writes go to one primary inside transactions, replicated synchronously so a failover loses no committed entry: a deliberate choice of consistency over availability (see consistency and CAP). Reports can read lagging replicas. Multi-row transactions and constraints do real work here, which makes this a SQL rather than NoSQL workload.
- No distributed transactions. The PSP cannot join your database transaction, and two-phase commit across services is fragile. Use a saga: local transactions, each with a compensating action. Reserve stock, authorize, capture on shipment; if stock fails after authorization, void it; if the order is canceled after capture, refund.
- Outbox for events. A database write and a queue publish can succeed separately. Write the event to the outbox in the same transaction as the state change; a relay publishes it at least once, and consumers deduplicate by
event_id. A receipt reaches the notification system this way. - Webhooks. Verify the signature, store the event ID under a unique constraint, return 200 quickly, process asynchronously. Duplicates and out-of-order events are harmless with forward-only transitions. Poll the PSP for payments stuck in
PENDING. - Retries. Retry PSP calls only on network errors, 5xx and 429, with backoff and the same key. A decline is an answer, not an error.
- Hot accounts. Every payment credits the same revenue account, so a stored running balance there becomes a lock everyone waits for. Split it into sub-accounts summed on read, or compute it from entries in batches.
- Reconciliation. Daily, match every line of the PSP's settlement report to a payment by
psp_reference(amount, fee, currency, status). Mismatches (missing on either side, amounts differ) go to an exceptions queue for finance, and the clearing account must net to zero for each payout.
8. What breaks, and what you do about it
| Failure | Effect | Mitigation |
|---|---|---|
| PSP call times out after the card was charged | Outcome unknown; a naive retry charges twice | Stay PENDING; retry with the same key or look up by reference; the webhook and reconciliation are the backstops |
| Webhook lost | Payment stuck in PENDING; order never ships | Poll the PSP for stale pending payments; reconciliation creates the missing record |
| Crash after commit, before publishing | Order service never hears of the payment | Outbox relay publishes after restart |
| Paid, then out of stock | Customer charged for nothing | Saga compensation: void if only authorized, refund if captured, and tell the customer |
| Dispute (chargeback) weeks later | Money is pulled back by the bank | Dispute webhook appends reversing ledger entries; never edit the original ones |
| Rounding and currency mistakes | Ledger off by cents across millions of rows | Integer minor units, currency on every amount, per-currency decimals (zero for JPY) |
9. Trade-offs to discuss
- Authorize then capture, or charge at once. Separate capture lets you take money only when goods ship and makes cancellation a free void, but authorizations expire after a period set by card network rules, so long delays need re-authorization.
- Synchronous or asynchronous confirmation. Waiting gives an immediate answer; confirming later tolerates a slow PSP. Many checkouts wait a few seconds, then show "processing".
- One PSP or several. A second PSP adds failover and routing options, but card tokens are usually tied to the PSP that created them, and reconciliation doubles.
- Your ledger or the PSP's dashboard as truth. The PSP knows only its own payments; your ledger also holds payouts, fees and manual adjustments.
10. How to talk through it in the interview
Fictional example
- Interviewer
- Your call to the PSP times out. Did the customer get charged? Do you retry?
- Candidate
- I cannot know yet, so the payment stays in PENDING and the customer sees "processing". I retry with the same idempotency key, which the PSP treats as the same request: if the first one went through, I get its result back instead of a second charge.
- Interviewer
- And if the retries also fail?
- Candidate
- The PSP's webhook will tell me when it settles either way. A job polls PSP status for anything pending more than a few minutes, and the daily reconciliation catches anything both of those miss. At no point do I create a new charge to find out.
- Draw the state machine; it answers half of the follow-up questions.
- Use the words idempotency key, double-entry and reconciliation, and explain each in one sentence.
- Say why you avoided two-phase commit and what replaces it.
Practicing this design with Natively
Rehearse this as a mock interview: a friend asks the timeout and webhook questions on a call, or you read the prompt aloud and answer it alone. Natively transcribes the conversation live and can suggest talking points for each question as it comes in; comparing them with what you said is a quick way to spot gaps such as reconciliation or the outbox. You can capture a screenshot of your state machine sketch and get an explanation of it. The transcript and notes are kept for review afterwards, so you can check whether your ledger example actually balanced.
Questions people ask
How do idempotency keys prevent double charges?
The client sends the same key with every retry of one payment attempt, and the server stores it under a unique constraint with the first response. A repeat returns that stored response instead of charging again. The payment service passes its own key to the PSP so retries there are safe too.
Why does a payment system need a double-entry ledger?
Every movement of money is recorded as equal debits and credits, so the books always balance and any error shows up as an account that does not net out. Entries are only appended, never edited, which gives an audit trail. It also covers fees, refunds and payouts that the PSP's own records do not fully explain.
What is payment reconciliation?
It is the regular comparison of your ledger with the PSP's settlement reports, line by line. Mismatches, such as a charge you never recorded because a webhook was lost, go to an exceptions queue for finance. It is the final safety net behind idempotency and webhooks.
Where to go next
Practice with the same assistant you would use on a call: it listens, transcribes and suggests answers from your own resume and the job description.
Facts on this page were last checked on . Product details come from the Natively app itself; if something here is out of date, tell us at hello@heynatively.com.
