HardProblem 28 of 36 in the system design series
Design a Payment System: Charge Exactly Once and Keep Books Balanced

A payment system lets merchants charge a customer's card, track whether the charge went through, issue refunds and receive notifications when anything changes. Behind a single API call sit card networks, issuing banks and a processor, any of which can be slow or unreachable at exactly the wrong moment.
Interviewers like this problem because it inverts the usual priorities. Most designs chase throughput or latency, but here the traffic is moderate and the real test is whether every rupee can be accounted for. Candidates must reason about retries, partial failures and audit trails rather than caches and fan-out.
The hard part is ambiguity. When a request to the bank times out, nobody knows whether the customer was charged. A good design never guesses: it records intent before acting, makes every step safe to repeat, and later confirms the truth by checking with the processor and comparing records line by line.
This is an interview practice design for a Payment System-like service, built from first principles. It is not a description of how Payment System is actually built.
Functional Requirements
Four capabilities cover the merchant's needs. Creating a payment charges a tokenised card for a given amount. A status lookup reports whether the payment is still processing, has succeeded or has failed. Refunds return all or part of the original amount. Webhooks push a notification to the merchant's server whenever a payment moves to a new state, so they do not have to poll.
Fraud scoring, payouts to merchant bank accounts and foreign exchange are deliberately excluded. Each is a large system with its own rules, and they all sit downstream of the core question this design must answer: how to move money once, correctly, and prove it afterward.
Create a payment
A merchant charges a customer's card for an amount.
charge a card
Check status
See if a payment is processing, succeeded or failed.
payment status
Refunds
Return all or part of a payment to the customer.
full · partial
Notify merchants
Tell the merchant's server when a payment changes.
webhooks
- Out of scope: fraud models, payouts to merchant banks, currency exchange
Non-Functional Requirements
Correctness outranks everything. A customer must never be charged twice and a successful payment must never vanish. A few extra seconds of latency are acceptable; a wrong charge is not. Close behind is auditability: every movement of funds needs a permanent record, and the totals must always reconcile.
Security is non-negotiable because card data is among the most regulated information there is, which is why the design follows PCI DSS and keeps raw card numbers out of most services entirely. Availability matters too, targeting around 99.99 percent, because every minute of downtime translates directly into lost sales for every merchant on the platform.
Exactly-once charging
Never charge twice; never lose a payment.
correctness first
Auditable money
Every movement is recorded, and the books balance.
ledger
Security
Card numbers are protected; we follow PCI DSS.
tokenised cards
High availability
Downtime means merchants lose sales.
99.99%
Scale Estimation
Assume 50 million payments per day. That averages to roughly 600 per second, with peaks near 5,000 during big sale events. Each payment produces about four ledger entries covering the charge and fees, so the ledger grows by around 200 million rows daily.
At roughly 1 KB per payment, storage reaches about 50 GB a day and around 18 TB a year, and financial regulations require keeping it for many years. None of these figures stretch a well-sharded relational database. That is the point of the estimate: throughput is manageable, so engineering effort belongs in correctness, durability and auditability rather than exotic scaling tricks.
50million
Payments per day
600avg
Payments per second
≈ 5K at peak (sale days)
200million
Ledger entries / day
≈ 4 entries per payment
18TB
Storage per year
1 KB per payment
Volume is modest. Correctness, not throughput, is the hard part.
Core Entities & API
The model has four entities: Merchant, Payment, Ledger entry and Refund. A ledger entry records an amount moving into or out of a named account, which is what later lets us prove the books balance.
POST /payments takes an amount, a currency and a card_token, never a raw card number, plus an Idempotency-Key header that makes retries safe. GET /payments/{payment_id} returns current status. POST /payments/{payment_id}/refunds accepts the amount to return. In the opposite direction, the platform calls the merchant's registered webhook URL with events such as payment.succeeded, and keeps retrying until it receives a 2xx response.
Core entities
MerchantPaymentLedger entryRefund
API
- POST /payments { amount, currency, card_token } + Idem-Key→ { payment_id, status }
- GET /payments/{payment_id}→ { status, amount, created_at }
- POST /payments/{payment_id}/refunds { amount }→ { refund_id, status }
- POST {merchant_url}/webhooks { event: payment.succeeded, id }→ 2xx, or we retry
High-Level Design
Requests from a merchant's backend first pass through the payments API, where credentials and input are verified. Next, the payment service looks up the idempotency key and inserts the payment into Postgres with status CREATED before any money moves. Only after that row is durable does it call the PSP gateway, the component that reaches card networks and issuing banks while managing timeouts and retries.
Every state transition is written in the same database transaction as a row in an outbox table. A relay process reads the outbox and publishes those events to Kafka, which guarantees an event is emitted if and only if the state change committed. Two consumers react: the ledger service records double-entry postings, and the webhook service delivers signed notifications to merchants.
The underlying rule is to write down what you are about to do, then do it. If any process dies midway, the database shows exactly where the payment stopped, and the remaining steps can be resumed or replayed without guessing.
Record first, then act → every step can be recovered and replayed
Deep Dive 1 — Idempotency
Networks drop responses, so merchants will retry. The defence begins with the merchant attaching a unique idempotency key, such as a UUID tied to the order, to each create request. The service inserts that key into a table with a unique constraint on merchant and key; a successful insert means this is a genuinely new payment.
If the insert collides, the request is a repeat, and the service returns the response it stored for the original call instead of charging again. A key reused with a different body signals a client bug and is rejected. If the original request is still in flight, a concurrent retry receives a 409 Conflict and is asked to try again shortly, which prevents two workers from racing on the same charge.
The same idea applies one hop further. When the payment service calls the processor, it passes its own payment ID as the processor's idempotency key. That way even the platform's internal retries after a timeout cannot produce a second charge at the bank.
- 1Merchant sends a keyA unique Idempotency-Key on every create call
- 2Insert or fetchUnique constraint on (merchant_id, key)
- 3Same key, same answerA retry gets the stored response, not a new charge
- 4Still in progress?A parallel retry gets 409 and tries again later
- 5Key to the PSP tooOur payment ID is the idempotency key at the PSP
- Same key with a different body? Reject it — it's a client bug
Deep Dive 2 — Double-Entry Ledger
Double-entry bookkeeping records every movement as a pair: a debit to one account and a matching credit to another. The platform keeps accounts for the processor receivable, each merchant's balance, its own fee revenue and refunds. Nothing is ever just added to a balance column; balances are derived by summing entries.
Take a 1,000 rupee purchase with a 2 percent platform fee. The ledger debits the processor receivable by 1,000, credits the merchant's account by 980 and credits fee revenue by 20. Debits and credits match, so that transaction nets to zero. A later refund would add new entries that reverse the flows rather than editing these.
The ledger is append-only. Corrections are made with compensating entries, which keeps a complete history for auditors. A continuous check verifies that the sum across all accounts is still zero; if it ever drifts, an alert fires immediately, because an imbalance means money is unaccounted for somewhere.
Every movement is two entries
Debit one account, credit another, for the same amount.
An account for everyone
PSP receivable, merchant balance, our fee revenue, refunds.
Example: ₹1,000 payment, 2% fee
Debit PSP receivable 1,000 · credit merchant 980 · credit fees 20
Append-only, never update
Mistakes are fixed with new reversing entries; history stays complete.
All entries sum to zero
Checked continuously; any imbalance raises an alarm.
Deep Dive 3 — State & Reconciliation
The scariest moment is a timeout from the bank. Retrying immediately risks a second charge if the processor lacks idempotency protection. Marking the payment failed is worse: the customer may have been charged, leaving money missing from the books and an angry cardholder.
The chosen approach adds an explicit UNKNOWN state. The payment moves through CREATED and PROCESSING to SUCCEEDED or FAILED, but when the outcome is unclear it sits in UNKNOWN while a background job queries the processor by payment ID until it gets a definitive answer. The cost is that some customers wait minutes for confirmation, which is far better than a wrong one.
A daily reconciliation job then compares the internal ledger against the processor's settlement file line by line, and any mismatch lands in a queue for human review. Merchant notifications carry a signature, may arrive more than once, and keep retrying with exponential backoff over several days, so receivers deduplicate on the event ID.
Retry right away
- + Simple
- − May charge twice
Mark it failed
- + Quick answer
- − Customer may be charged
- − Money goes missing
Mark UNKNOWN, verifyChosen
- + Never guess
- + Ask PSP by our ID
- − Final answer takes longer
- CREATED → PROCESSING → SUCCEEDED / FAILED · UNKNOWN until verified
- Daily: match our ledger with the PSP settlement file, line by line
- Webhooks: signed, at-least-once, retried with backoff for days
Trade-offs
Feeding the ledger asynchronously through the outbox keeps the payment path fast and the ledger replayable, but ledger balances trail live payments by a few seconds; reconciliation catches any gaps. The UNKNOWN state means some outcomes are delayed, a deliberate choice of correctness over speed.
A relational database gives transactions, unique constraints and foreign keys, all of which payments rely on, and write volume is handled by sharding on merchant ID. At-least-once webhooks guarantee merchants hear about every event, with the expectation that they deduplicate on their side. The thread running through all of it is to make each operation repeatable, persist before acting and verify against an external source daily.
Ledger fed by the outbox
Decoupled and replayable — but it lags by seconds; reconciliation covers gaps.
The UNKNOWN state
We never guess — but some customers wait minutes for a final answer.
SQL for payments
Transactions and constraints — scale writes by sharding on merchant ID.
At-least-once webhooks
Never lost — but merchants must handle duplicates.
Idempotent everywhere. Record before acting. Reconcile every day.
Key takeaways
- Volume is modest at about 600 payments per second; correctness and auditability are the real challenge.
- Require idempotency keys from merchants and pass your own payment ID as the key to the processor.
- Record every state change with an outbox event in one transaction, then publish to Kafka.
- Keep an append-only double-entry ledger where all entries always sum to zero.
- On timeouts use an UNKNOWN state and verify; reconcile with the settlement file every day.
Interview questions
How does a payment system avoid charging a customer twice?
It uses idempotency at two levels. The merchant sends a unique key with each create request, and a unique database constraint ensures the same key cannot start a second payment; repeats get the original response. The platform then passes its own payment ID as the idempotency key when calling the processor, so internal retries after a timeout also cannot trigger a duplicate charge at the bank.
Why use a double-entry ledger instead of a balance column?
A balance column can be overwritten or drift without leaving any trace of why. A double-entry ledger records each movement as a matching debit and credit in an append-only table, so every balance can be recomputed and explained. Because all entries must sum to zero, any bug or missing record shows up as an imbalance that can be detected automatically.
What should a payment system do when the bank call times out?
It should not guess. Mark the payment UNKNOWN rather than failed or succeeded, then have a background job ask the processor for the outcome using the payment ID that was sent as the idempotency key. Once the processor confirms, move the payment to its final state and emit the matching ledger entries and webhook. Daily reconciliation catches anything still unresolved.
Where should a payment system store card numbers?
Inside an isolated vault built to PCI DSS rules, tightly locked down and audited. The vault stores the card and returns a token; every other service, including the payment service and databases, only handles that token. This keeps most of the platform outside the strictest compliance scope and limits the damage if any other component is ever breached.
What is the transactional outbox pattern in payments?
When a payment changes state, the service writes both the new state and an event row into an outbox table in one database transaction. A separate relay publishes outbox rows to a message broker such as Kafka. This guarantees that an event exists exactly when the state change committed, avoiding the case where the database updates but the ledger or webhook never hears about it.
Keep practising
- Learn the building blocks in the free system design course.
- Rehearse answers with system design interview questions.
- See which open jobs fit your skills: upload your resume.