AI System Design
← All chapters
Chapter 26 6 min read

Payment System

A payment backend for an e-commerce site has low throughput but extremely high stakes: money must never be lost, charged twice or left unaccounted for. The design rests on a payment service that orchestrates external providers, a double-entry ledger, idempotency everywhere, careful retry and failure tracking, and daily reconciliation as the final safety net.

Architecture at a glance
  1. Client checkout
  2. Payment service
  3. Payment executor
  4. PSP (hosted payment page)
  5. Card schemes / banks
  6. Ledger + wallet

Problem and requirements

We are designing the payments backend for a marketplace in which buyers pay and sellers get paid. There are two flows: pay-in, where the buyer's money moves from their card to the marketplace's bank account, and pay-out, where the marketplace later sends sellers their share. The company does not process cards itself; it integrates with a payment service provider (PSP) such as Stripe or Adyen, which talks to the card schemes.

Scale is modest, about 1 million transactions per day, but the non-functional requirements are strict. The system must be reliable and fault tolerant, because failed payments must be handled carefully rather than dropped. It needs a reconciliation process between internal services and external providers to verify that payment information is consistent. The guiding principle is that correctness beats raw performance at every turn.

Back-of-the-envelope estimation

One million transactions per day divided by 1e5 seconds gives 10 transactions per second. Even a 10x holiday peak at 100 TPS is easy for one relational database. That tells us the design should not be driven by throughput at all. Instead it should be driven by correctness: idempotency, transactional state changes, audit trails and recovery from partial failures. Storage is similarly small, roughly 1 million rows per day for orders plus several ledger entries each, or a few hundred million rows a year.

The important consequence is that we can pick a mature ACID relational database with a long track record, strong tooling and good backups, rather than a NoSQL store chosen for scale we do not need.

Pay-in flow and components

When a buyer checks out, a payment event is created; one event can contain several payment orders, one per seller. The payment service receives the event, stores it, runs risk checks such as anti-money-laundering screening, and then hands each order to the payment executor, which calls the PSP. The PSP moves money through the card schemes (Visa, Mastercard) and banks. On success, the payment service updates the wallet, which tracks each seller's balance, and the ledger, which records every financial movement.

The ledger uses double-entry accounting: every transaction is recorded as a debit to one account and an equal credit to another, so the sum of all entries is always zero. If a buyer pays 1 dollar, the buyer account is debited 1 dollar and the seller account credited 1 dollar. Any imbalance points directly at a bug, which makes the ledger the primary tool for auditing and reconciliation.

  • APIs: POST /v1/payments with buyer info, checkout ID, credit card info and a list of payment orders, each carrying a payment_order_id used as an idempotency key; GET /v1/payments/{id} returns status.
  • Tables: payment_event (checkout ID, buyer, is_payment_done) and payment_order (order ID, amount, currency, status, ledger_updated, wallet_updated).
  • Amounts are stored as strings or integer minor units, never floats, to avoid rounding errors.
Figure 1Pay-in flow
Pay-in flowpaymenteventpaymentorderchargeauthorizeorder statuson successdebit + creditBuyer checkoutPayment servicestores event, risk checkPayment executorone call per orderPSPe.g. Stripe, AdyenLedgerdouble-entry, append-onlyWalletseller balancesPayment DBpayment_event / _orderCard schemes + banksVisa, Mastercard
The payment service owns the payment event and fans each order out to the executor, the only component that talks to the PSP. Wallet and ledger are updated only after the PSP reports success, and the ledger records every move as a balanced debit and credit.

PSP integration and the hosted payment page

Storing raw card numbers brings the full weight of PCI DSS compliance. Most companies avoid it with a hosted payment page. The payment service first registers the payment with the PSP, passing a UUID nonce for idempotency; the PSP returns a token. The client then renders the PSP's hosted page, via an SDK or iframe, which collects card details directly. Card data never touches our servers. After the PSP processes the payment, it redirects the browser to our success URL and also sends a webhook with the final status, which updates payment_order.status.

The pay-out flow mirrors pay-in, but instead of a PSP for card acceptance we use a pay-out provider such as Tipalti, which moves funds from the marketplace account to sellers' bank accounts, usually in scheduled batches with its own reporting.

Figure 2Hosted payment page
Hosted payment pageBuyer browserPayment servicePSP1. checkout: POST /v1/payments2. register payment, nonce = uuid-7c1e3. token tok_93ab4. render PSP hosted page with token5. card details entered in PSP iframe6. authorize via card scheme7. redirect to our success URL8. webhook: tok_93ab succeeded9. payment_order.status = SUCCESS10. 200 OK (webhook acknowledged)
Card numbers go straight from the browser to the PSP, so our servers stay out of most PCI DSS scope. The registration nonce makes retries safe, and the final status arrives by webhook even if the user closes the tab before the redirect.

Reconciliation and delays

Messages between services and providers can be lost or delayed, so the last line of defence is reconciliation. Every night the PSP or bank sends a settlement file listing balances and transactions. A reconciliation job compares it against our ledger and payment records. Mismatches fall into three classes: those an automated program can fix because the cause and fix are known; those whose cause is known but too costly to automate, which go to a queue for the finance team; and those with unknown cause, which are escalated for investigation.

Some payments take hours or days, for example when the PSP flags a payment as high risk or 3-D Secure requires extra authentication. The PSP returns a pending status, our service records it and the client shows it, and the final outcome arrives via a webhook or by our service polling the PSP. Designs must assume asynchronous completion as normal, not exceptional.

Figure 3Nightly reconciliation
Nightly reconciliationsettlementour recordsmismatchmismatchmismatchPSP / banknightly settlement fileReconciliation jobmatch line by lineAuto-fix programknown cause, known fixInvestigationunknown cause, escalateLedger + payment DBour view of the dayFinance work queueknown cause, manual fix
The PSP's settlement file is compared line by line with our ledger and payment records. Each mismatch is sorted by how well we understand it: auto-fixed, queued for finance, or escalated for investigation.

Internal communication and failed payments

Synchronous HTTP between services is simple but couples availability and latency: if the ledger is slow, checkout is slow. Asynchronous messaging through a queue like Kafka decouples them. A single-receiver queue suits work items processed once; a multi-receiver log lets the same payment event drive the ledger, wallet, analytics and notifications independently. For durability, every state transition of a payment is recorded in an append-only table, so a crashed service knows exactly where to resume.

Failures are routed deliberately. Retryable errors go to a retry queue and are retried with exponential backoff; non-retryable errors, such as invalid input, are stored for analysis. Messages that keep failing after the retry budget move to a dead letter queue, where they can be debugged and replayed without blocking healthy traffic.

Deep dive: exactly-once and double payment

No network can guarantee exactly-once delivery, but we can build exactly-once processing from two halves. At-least-once comes from retries: if a call times out, try again, with backoff and jitter so retries do not overwhelm a recovering provider. At-most-once comes from idempotency: the client sends an Idempotency-Key header, and the server inserts it under a unique constraint before doing work. A duplicate either sees the stored result or collides on the constraint and returns the earlier outcome.

Two double-charge scenarios matter. A user clicking Pay twice is caught by the idempotency key on our API. A PSP that processed the payment but whose response was lost is caught by reusing the same nonce with the PSP, so the PSP recognises the retry and returns the original result instead of charging again. Together, retries plus idempotency give the effect of exactly once.

Consistency, security and wrap-up

Consistency has several layers. Between internal services, idempotent processing plus a durable state log keeps state aligned. Between us and the PSP, idempotency plus reconciliation catches divergence. For database replicas, reading from a lagging replica could show stale payment status, so either serve all reads and writes from the primary or use a consensus-based database such as one built on Raft that guarantees linearisable reads.

Security spans the whole path: HTTPS against eavesdropping, encryption and integrity checks against tampering, nonces and idempotency against replay attacks, rate limiting against DDoS, tokenization so real card numbers are never stored, PCI DSS compliance for whatever card data remains, and fraud checks such as address verification, CVV validation and behavioural analysis. In short: low TPS, high stakes; be idempotent everywhere, record every state change, reconcile every day.

Key numbers

Transactions per day
~1 million
Average TPS
~10
Ledger invariant
Sum of debits = sum of credits
Reconciliation cadence
Daily settlement files
Retry strategy
Exponential backoff, then DLQ

Key terms

PSP
A payment service provider that moves money between buyer and merchant accounts on our behalf.
Double-entry ledger
An accounting record where every transaction is a matched debit and credit, so all entries sum to zero.
Hosted payment page
A PSP-provided page that collects card data so the merchant never handles raw card numbers.
Idempotency key
A unique request identifier that ensures a repeated request produces the same effect as the first.
Reconciliation
Periodically comparing internal records with external settlement files to detect and fix discrepancies.
Dead letter queue
A queue holding messages that repeatedly failed processing so they can be inspected without blocking others.
PCI DSS
The security standard governing organisations that store, process or transmit cardholder data.
Tokenization
Replacing a card number with a surrogate token that is useless if stolen.

Common mistakes

  • Storing money amounts as floating-point numbers.
  • Retrying PSP calls without reusing the same nonce or idempotency key, causing double charges.
  • Treating the synchronous PSP response as final and ignoring pending states and webhooks.
  • Skipping reconciliation because internal tests pass, letting silent mismatches accumulate.
  • Reading payment status from a lagging replica and showing the wrong result to the user.
  • Collecting raw card data on your own servers and inheriting full PCI scope.

Further study

  • Stripe engineering: Designing robust and predictable APIs with idempotency
  • PCI DSS specification
  • Uber's payments platform and Cadence workflow engine
  • Martin Fowler's patterns of accounting (Accounting Entry / double-entry)

Now practise it