Design a payment system (Stripe-like)
The interviewer asks
“Design a payment system like Stripe's core: merchants call an API to charge customers, the system talks to card networks and banks through processors, records every movement of money, and pays merchants out. Retries, timeouts and partial failures are everywhere. How do you make sure money is never lost or charged twice?”
What a strong answer looks like
A strong answer is built around three ideas. First, idempotency everywhere: every API call carries an Idempotency-Key stored with its response, and every call to an external processor carries our own unique reference, so retries never double-charge. Second, an explicit payment state machine (created → requires_action → authorized → captured → refunded, plus failed and unknown) stored in a relational database, with transitions driven by processor responses and webhooks, and an 'unknown' state handled by status queries rather than guessing. Third, a double-entry ledger that is append-only and balanced, separate from the payment state, plus daily reconciliation against processor and bank settlement files. Around these sit async processing via an outbox and Kafka, merchant webhooks with retries and signatures, and payouts as a batch workflow. Say clearly that you cannot get exactly-once across networks; you get effectively-once with idempotency keys and reconciliation.
Concepts to mention
1 Requirements
Functional
- Merchant creates a payment (amount, currency, method) and confirms it with customer details.
- Support authorise-then-capture, immediate capture, partial capture and refunds.
- Handle 3-D Secure / OTP challenges (requires_action) and asynchronous methods.
- Notify merchants of state changes through signed webhooks.
- Record all money movements in a ledger: customer charge, fees, merchant balance, refunds, payouts.
- Pay out merchant balances to their bank accounts on a schedule.
- Reconcile with processor and bank reports daily and surface mismatches.
Non-functional
- No double charges and no lost payments: every payment ends in a known final state.
- API p99 under 300 ms excluding the processor; processor calls take 0.5-3 s.
- 99.99% availability for payment creation and confirmation.
- Ledger always balanced (sum of debits = sum of credits) and auditable for years.
- ~5k payments/s peak (e.g. festive sale); 10x burst tolerance at the edge.
- PCI DSS: raw card numbers only inside an isolated vault; everything else uses tokens.
Out of scope (say it out loud)
- Fraud ML model internals (mention the hook).
- Merchant onboarding / KYC.
- Currency conversion and FX treasury.
2 Estimates
3 API
| Endpoint | What it does |
|---|---|
POST/v1/payment_intents | Create intent {amount, currency, merchant_id, capture_method}; Idempotency-Key header required. |
POST/v1/payment_intents/{id}/confirm | Confirm with a payment method token; may return requires_action with a 3DS/OTP URL. |
POST/v1/payment_intents/{id}/capture | Capture all or part of an authorised amount. |
POST/v1/refunds | Refund {payment_id, amount}; Idempotency-Key required. |
GET/v1/payment_intents/{id} | Current state and history. |
POST/internal/processor/webhooks | Processor notifications (async results, chargebacks), verified by signature. |
POST/v1/payouts | Manual payout of available balance; scheduled payouts run automatically. |
4 Data model
idempotency_keysPostgres (or DynamoDB) keyed by merchant(merchant_id, key) PKrequest_hashstatus (in_progress|done)responsecreated_atInsert first; if it exists with same request hash, return the stored response; if hash differs, return 422. Expire after 24 h-7 days.
paymentsPostgres sharded by merchant_idpayment_idmerchant_idamount_minorcurrencystatusprocessorprocessor_ref (our unique ref sent to processor)auth_codeversioncreated_atState machine; transitions only through allowed edges with a version check. Amounts in minor units (paise/cents) as integers.
payment_eventsPostgres (same txn) → Kafka via outboxevent_idpayment_idfrom_statusto_statusreasontsHistory of every transition; drives webhooks and ledger posting.
ledger_entriesPostgres / ledger DB (e.g. TigerBeetle), append-onlyjournal_idline_idaccount_iddebit_minorcredit_minorcurrencypayment_idcreated_atEvery journal balances: sum(debits) = sum(credits). Never updated or deleted; corrections are new reversing journals.
accounts / balancesPostgresaccount_idowner (merchant, platform fees, processor receivable, bank)typecurrency; balance snapshot + last_line_idBalance is derived from lines; snapshots speed reads and are verifiable.
card_vaultIsolated HSM-backed store (PCI zone)tokenencrypted PANexpiryfingerprintOnly the vault sees card numbers; other services use tokens. Reduces PCI scope.
recon_recordsS3 + warehouseprocessor settlement file linesbank statement linesmatch statusDaily matching jobs and exception queues.
5 Architecture
Hover a box to highlight its connections.
6 Request flows
Charge a card (authorise + capture)
- 1Merchant calls POST /payment_intents with Idempotency-Key K; the service inserts (merchant, K) as in_progress, creates the payment in status created, and stores the response.
- 2Merchant confirms with a card token; payment service moves status to processing with a version check and generates processor_ref (unique per attempt).
- 3Processor adapter asks the vault to send the card data directly to the processor (the PAN never leaves the PCI zone) and calls authorise with processor_ref.
- 4If the issuer needs 3DS/OTP, status becomes requires_action and the customer completes the challenge; the result comes back by redirect or webhook.
- 5On approval, status becomes authorized (or captured if auto-capture); the transition and an outbox event are written in one transaction.
- 6Outbox relay publishes PaymentCaptured; ledger poster writes a balanced journal: debit processor receivable ₹1,000, credit merchant balance ₹980, credit platform fee revenue ₹20.
- 7Webhook dispatcher sends payment.succeeded to the merchant, signed with HMAC, retrying with backoff until a 2xx.
Processor timeout (unknown outcome)
- 1The authorise call times out after 10 s; we do not know if the card was charged.
- 2Payment moves to status unknown, not failed; the merchant sees processing.
- 3A recovery worker queries the processor by processor_ref with backoff (most processors support lookup by our reference).
- 4If found approved, continue as success; if not found after the processor's window, mark failed. If still unclear, the daily settlement file decides and any captured money is reversed or recorded.
Daily reconciliation and payout
- 1Recon job loads the processor settlement file from S3 and matches each line to payments by processor_ref and amount.
- 2Unmatched or mismatched lines go to an exception queue for operations; ledger adjustments are posted as new journals.
- 3Settled funds move from processor receivable to bank cash in the ledger.
- 4Payout job computes each merchant's available balance from the ledger, creates payouts with idempotent references, and calls bank APIs; balances are debited only when the payout is recorded.
7 Deep dives
Idempotency end to end
Networks fail between every pair of systems, so every hop can be retried, and every retry must be safe. At our API: the merchant sends an Idempotency-Key; we insert it with a hash of the request before doing work. If the insert conflicts, we either return the stored response (same request) or reject (different body with same key). If the first request is still in progress, return 409 so the client retries later, never run it twice. Store the final response, including errors that are deterministic. Towards the processor: generate our own unique reference per payment attempt and send it; many processors deduplicate on it, and it allows a status lookup after a timeout. Inside our system: Kafka gives at-least-once delivery, so consumers are idempotent: the ledger poster uses (payment_id, event_type) as a unique key for journals, and the webhook dispatcher includes an event_id so merchants can deduplicate. This is effectively-once processing: messages may be delivered more than once, but effects happen once. State it precisely; claiming exactly-once delivery over the network is a red flag.
Payment state machine and unknown outcomes
Model a payment as an explicit state machine with allowed transitions: created → processing → requires_action → authorized → captured → partially_refunded/refunded, and failed, canceled, and unknown. Each transition is a conditional update (WHERE status = expected AND version = v) inside a transaction that also writes the event to an outbox table, so state and events never diverge. The most important state is unknown: a timeout from the processor does not mean failure. Marking it failed and letting the merchant retry is how customers get charged twice. Instead, a recovery worker resolves unknown payments by querying the processor with our reference, with backoff, and finally the settlement file is the ground truth. Webhooks from processors can arrive out of order or duplicated; the state machine rejects transitions that go backwards and treats duplicates as no-ops. Keep a full transition history for support and audit. Timers matter too: authorisations expire (often after about 7 days for cards), so a scheduled job captures or voids them before expiry according to merchant settings.
Double-entry ledger
The payments table says what happened to a payment; the ledger says where the money is. Keep them separate. In a double-entry ledger every movement is a journal with two or more lines, and debits equal credits in each journal. Accounts include merchant balance, platform fees, processor receivable, bank cash, refunds payable and reserves. A ₹1,000 capture with a 2% fee posts: debit processor receivable 1,000; credit merchant balance 980; credit fee revenue 20. When the processor settles, debit bank cash 1,000 and credit processor receivable 1,000. A refund reverses the relevant parts. The ledger is append-only: never update an entry; post a correcting journal. Balances are sums of lines, with periodic snapshots for speed. Integrity checks run continuously: every journal balanced, every captured payment has exactly one capture journal, the receivable account drains to zero after settlement. Use integer minor units and store the currency on every line. Write throughput of thousands of lines per second fits a partitioned Postgres or a purpose-built ledger like TigerBeetle; hot accounts like fee revenue can be split into sub-accounts and summed.
Reconciliation: trust but verify
Even with perfect code, external parties make mistakes, files arrive late, and some failures leave no trace in our system. Reconciliation compares three views daily: our payments and ledger, the processor's settlement report, and the bank statement. Matching keys are our processor_ref, amount and date. Typical exceptions: captured at the processor but failed in our system (timeout we resolved wrongly), fee different from contract, settlement missing or short, duplicate settlement. Each exception creates a case and, after review, a correcting ledger journal. Track the match rate and age of open exceptions as key metrics. Reconciliation is also what makes the 'unknown' state safe in the end: anything not resolved online is resolved here.
8 Trade-offs
Relational DB vs NoSQL for payments
Payments need transactions across the payment row, the idempotency key and the outbox, plus unique constraints. Postgres (or a distributed SQL like Spanner/CockroachDB) gives that directly. NoSQL can scale writes more easily but you rebuild transactions yourself. Shard Postgres by merchant_id to scale.
Synchronous ledger posting vs async via events
Posting ledger entries in the same transaction as the payment is simplest when they share a database. At scale the ledger is its own service; posting asynchronously from the outbox keeps payments fast, and integrity checks ensure every payment event gets exactly one journal.
Authorise and capture separately vs auto-capture
Separate capture suits marketplaces and travel (capture when shipped or ticketed) and makes cancellations free (void instead of refund). Auto-capture is simpler for digital goods. Support both; track authorisation expiry.
Build a vault vs use a third-party vault
An in-house vault reduces dependence and fees but brings full PCI DSS Level 1 scope. A third-party vault speeds launch. Either way, keep raw card data out of the main services.
9 Bottlenecks
Hot merchant during a flash sale
One merchant drives most traffic, so one shard and one merchant balance account get hot. Split the merchant's ledger account into N sub-accounts and sum them for balance; payments can be spread by payment_id within the merchant's shard group.
Processor capacity and latency
A processor slows down and threads pile up. Use async I/O, per-processor bulkheads and circuit breakers, and route to a secondary processor when the primary degrades (where the merchant contract allows).
Webhook backlog
A merchant endpoint is slow or down, and retries pile up. Queue per merchant with exponential backoff, cap concurrency per endpoint, and let merchants fetch events via API.
10 Failure handling
Our service crashes after the processor approved
The payment stays in processing. The recovery worker finds stale processing payments, queries the processor by our reference, and completes the transition. The merchant's retry with the same Idempotency-Key returns the in-progress or final result.
Kafka is down
Payments still commit to Postgres with outbox rows. The relay catches up when Kafka returns; ledger and webhooks are delayed but not lost.
Ledger imbalance detected
A continuous check finds a journal or account that does not balance. Freeze payouts for affected accounts, page on-call, and fix with a correcting journal after root cause; never edit history.
11 What impresses
- Handles the timeout case with an 'unknown' state and status lookup instead of marking it failed.
- Separates payment state from the ledger and writes a correct balanced journal with a fee.
- Explains idempotency at every hop, including our own reference to the processor.
- Says exactly-once is impossible across the network and explains effectively-once.
- Brings reconciliation in as a core component, not an afterthought.
- Splits hot ledger accounts to avoid a single-row bottleneck.
12 Mistakes
- Treating a timeout as failure and letting the client retry, causing double charges.
- Storing money as floating point.
- Keeping a balance column updated in place without a ledger.
- Ignoring idempotency or implementing it with an in-memory cache that is lost on restart.
- Calling the processor inside a database transaction that holds locks for seconds.
- No reconciliation, assuming our records always match the bank.
13 Follow-ups
Lessons this design uses
Practice questions
- Design idempotency for POST /payments. A client times out and retries. How does the server make sure the customer is charged only once?
- How should you store money amounts and currencies in a payments database?
- A teammate says 'We enabled Kafka exactly-once, so our consumer that charges customers will never double-charge.' Is that right?
- A user on a slow 3G connection taps 'Pay ₹1,499' twice, and the app also retries once on timeout. How do you make sure they are charged only once?
- Your checkout service calls the payment provider to charge a card, and the call times out after 10 seconds. Did the payment happen? What does your system do next?
- You are designing the ledger for a payments company expecting 50 million transactions a day. Would you use Postgres, Cassandra, or something else? Justify the choice.
- A user taps 'Pay' twice on a slow 3G connection and gets charged twice. What is idempotency, and how would you change the payment API so this cannot happen?
- You have built idempotency keys for your 'create order' API. Two requests with the same key arrive 5 milliseconds apart on two different servers. Walk me through exactly what happens.
- For a payments platform, the business asks for "zero data loss and no downtime". How do you turn that into concrete RPO and RTO requirements, and what do they cost?
- Your payments service had a 2-minute outage. When it came back, it immediately fell over again, and this repeated three times. What is happening and how do you break the cycle?
- After a 10-minute network issue between your checkout service and the payment provider, 3,000 customers were charged twice. You lead the postmortem. What most likely happened, how do you fix the customers, and what design changes stop it from happening again?
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.