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 Published 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.

Back-of-envelope estimate for a large online store (assumed figures)
QuantityAssumptionResult
Payments10 million a dayabout 116 a second on average; about 1,160 at a 10x sale peak
PSP callsabout 2 per payment (authorize, capture), before retries20 million a day; about 2,300 a second at peak
Ledger entries4 per payment (charge and fee, two entries each)40 million a day, about 14.6 billion a year
Ledger storageabout 200 bytes an entry with indexes8 GB a day, about 2.9 TB a year
Webhooks receivedabout 2 per payment20 million a day, arriving in any order
Back-of-envelope estimate for a large online store (assumed figures). Scroll sideways on a small screen.

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)
Amounts are integers in the currency's minor unit (cents), never floating point.

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

Six tables, one relational database
TableKey fieldsWhy it is there
paymentspayment_id, order_id, amount, currency, status, psp_reference, versionOne row per payment; the state machine lives here; version enables conditional updates
idempotency_keyskey (unique), request_hash, status, stored_response, expires_atReplays the first response to a retry and detects a key reused for a different request
ledger_entriesentry_id, txn_id, account_id, direction (debit or credit), amount, currency, created_atAppend-only; the entries of each txn_id must balance
accountsaccount_id, type (asset, revenue, expense, liability), currencyPSP clearing, bank, sales revenue, processing fees, refunds
outboxevent_id, payment_id, type, payload, published_atWritten in the same transaction as a state change, published afterwards
psp_eventspsp_event_id (unique), type, received_at, processed_atDrops duplicate webhooks
Six tables, one relational database. Scroll sideways on a small screen.

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:

Double-entry ledger for one payment of 49.99 (assumed fee of 1.75)
EventDebitCreditAmount
Payment capturedPSP clearing (asset)Sales revenue49.99
PSP fee chargedProcessing fees (expense)PSP clearing1.75
PSP pays outBank account (asset)PSP clearing48.24
Double-entry ledger for one payment of 49.99 (assumed fee of 1.75). Scroll sideways on a small screen.

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.

  1. Client creates the keyOne key per payment attempt, stored with the cart, so a double click or reload resends the same key.
  2. Service claims the keyInsert the key as in_progress under a unique constraint, with the CREATED payment row, in one transaction. A completed key replays its response; an in-progress one gets 409.
  3. 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.
  4. 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.
  5. 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.
Payment state machine: transitions only move forward
StateMeaningAllowed next states
CREATEDRecorded; PSP not called yetPENDING, FAILED
PENDINGSent to the PSP; outcome not yet knownREQUIRES_ACTION, AUTHORIZED, FAILED
REQUIRES_ACTIONCustomer must complete a bank check such as 3-D SecureAUTHORIZED, FAILED
AUTHORIZEDFunds held on the card, not yet takenCAPTURED, CANCELED (void, or the hold expired)
CAPTUREDMoney taken; it will settle to youPARTIALLY_REFUNDED, REFUNDED
FAILED, CANCELED, REFUNDEDFinalnone
Payment state machine: transitions only move forward. Scroll sideways on a small screen.

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

  1. Client
    • Checkout page
    • PSP hosted card fields (card data goes to the PSP only)
  2. API
    • API gateway
    • Payment service (idempotency, state machine)
  3. Storage
    • Primary database: payments, ledger, outbox
    • Synchronous standby in a second zone
  4. External
    • PSP (for example Stripe or Adyen)
    • Card networks and banks, behind the PSP
  5. Async
    • Outbox relay and event queue
    • Webhook receiver
    • Reconciliation job
    • Order, notification and finance services
Checkout, PSP and the asynchronous paths around them. The checkout page collects card details in the PSP's hosted fields, which return a token. The browser sends the token and an idempotency key to the payment service through the API gateway. The payment service claims the key, creates the payment, calls the PSP and records the result, the ledger entries and an outbox event in one transaction on the primary database, which replicates synchronously to a standby in another zone. An outbox relay publishes events to a queue for the order, notification and finance services. The PSP sends webhooks to a receiver that verifies signatures, deduplicates and advances the state machine. A daily reconciliation job compares the ledger with the PSP's settlement reports and raises exceptions for finance.

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 modes worth naming
FailureEffectMitigation
PSP call times out after the card was chargedOutcome unknown; a naive retry charges twiceStay PENDING; retry with the same key or look up by reference; the webhook and reconciliation are the backstops
Webhook lostPayment stuck in PENDING; order never shipsPoll the PSP for stale pending payments; reconciliation creates the missing record
Crash after commit, before publishingOrder service never hears of the paymentOutbox relay publishes after restart
Paid, then out of stockCustomer charged for nothingSaga compensation: void if only authorized, refund if captured, and tell the customer
Dispute (chargeback) weeks laterMoney is pulled back by the bankDispute webhook appends reversing ledger entries; never edit the original ones
Rounding and currency mistakesLedger off by cents across millions of rowsInteger minor units, currency on every amount, per-currency decimals (zero for JPY)
Failure modes worth naming. Scroll sideways on a small screen.

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

The timeout question every payment interview reaches
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.

Keep reading

Put it on your own machine.

A live-conversation assistant that runs locally. Your conversations stay on your machine.

macOS 12+ · Windows 10/11

© 2026 Natively AI Private Limited

Source-available under the Natively Personal Use Source License v1.0 · IP geolocation by DB-IP