Design a Payments System
- Build a general payments system end to end before adding any card-network flavor
- State the invariants money forces, like balanced postings and integer minor units, before you draw a box
- Layer the Amex closed loop on top, where the authorization decision never leaves the system
Understanding the prompt
Design a payments system that can charge a card and move the money correctly. Take a transaction from authorization through a durable ledger to settlement. Keep it exactly-once under retries, and add a fraud check and a reconciliation pass. Then adapt it to the Amex closed-loop network.
When an Amex panel opens with "design a payments system," the tempting wrong turn is to sketch the Amex network and start naming the pieces of a card transaction. Do that and you have skipped the part the round grades: whether you can build a general system that moves value without ever getting the total wrong. Add the closed-loop card shape at the end. Some candidates can recite the Amex diagram but can't design a ledger that balances or make a retry idempotent. They can't explain why a payment splits into authorization and settlement. They come apart the moment the interviewer swaps the wording. So build the reusable machine first, then dress it in the network.
The reusable machine is any system that takes money out of one account and puts it into another and must never lose or invent a cent. Strip the branding off and the same core sits behind a wallet transfer, a merchant payout, a subscription charge, and a card swipe. A request arrives, and you check it is allowed. You record the movement in a ledger that always balances. The whole thing has to survive a retry when the network drops your response. Get that core honest and the Amex prompt becomes a short final step, because the issuer role, the acquirer role, and the authorize-then-settle rhythm all land on pieces the general design already has.
Bounding the requirements first
Hold off on naming services for a minute and bound the problem out loud, because the prompt is deliberately open and the interviewer wants to watch you scope it. State the functional surface plainly. A client asks to charge a cardholder and pay a merchant some amount. The system decides whether to allow it, records the movement durably, and returns a definite outcome. It later finalizes the movement so the funds settle, and at any point it can answer what an account's balance is and how a given transaction reached its current state. That is the whole core, and it is enough to model something real without drowning in features.
Then draw the line between what you handle now and what you name and defer. Try to design fraud scoring, currency conversion, chargebacks, and rewards accrual in the first pass, and you run out of time before the ledger is clean. The right move is to model the skeleton so each of those has an obvious home, then say so. A design where "add real fraud scoring later" means dropping a model behind the existing fraud seam is a good design. A design where it means rewriting the charge path is not. Name the non-goals for this pass, put a hook where each deferred piece attaches, and you have shown the judgment the round is set to find.
- What the round grades: correctness, then scale. Clean ledger invariants, exactly-once processing, and an auditable trail come first. Throughput and sharding matter, but a design that scales beautifully and can double-charge a card fails the part that counts. Lead with the money being right.
- Money is not a float: integer minor units. Represent amounts as integer minor units, cents rather than dollars, so rounding never invents or destroys a fraction of a currency. Say this early; reaching for a floating-point balance is a fast way to look like you have never shipped a ledger.
- The ledger is the source of truth: double-entry, always balanced. Every movement of money is two entries that sum to zero: a debit and an equal credit. The invariant that the whole ledger nets to zero is what proves no money was created or lost, and it is the spine of the design.
- Retries are the normal case: idempotent by key. Networks drop responses, so clients resend. Every mutating request carries an idempotency key, and a repeat of a key returns the first outcome without applying the money again. This is how one charge stays one charge.
The money invariants before the boxes
Spend the first real minutes on the invariants, because the components fall out of them once the rules are straight. Three ideas carry the whole design. The first is double-entry accounting. You never store a balance as a number you edit in place. You store an append-only sequence of postings. Every posting moves an amount out of one account and into another as a matched debit and credit. A balance is then a derived quantity, the sum of an account's postings, and the global invariant is that all postings across the ledger sum to zero. That structure is what makes the system auditable: you can replay the log and prove exactly how every balance came to be, which is precisely what reconciliation and a disputed charge both need.
The second idea is the non-negative invariant on real accounts. A cardholder or merchant account must never drop below zero, because that would mean the system let out money that was not there. So a debit against a real account is conditional: it posts only if the balance covers it, and otherwise the transaction is declined rather than forced through. The exception is a designated settlement account that stands for value entering and leaving from the outside world, which is allowed to run negative because it is the counterweight that keeps every posting balanced. Naming that one privileged account explicitly is what lets funding and payouts stay double-entry without breaking the rule everywhere else.
The third idea is exactly-once through idempotency, which is the one most candidates state weakly. On an unreliable network a client cannot tell a lost request from a lost response, so it retries, and a naive system applies the money twice. The fix: every mutating call carries a client-chosen idempotency key. The system records the outcome of a key the first time it processes it. Any later request with the same key returns that stored outcome without touching the ledger again. The subtle part is that you cache every decision, declines included, so a retried request that was declined stays declined. State that out loud and the exactly-once story stops being a slogan and becomes a mechanism.
Finding the components
With the invariants straight, surface the components by underlining the responsibilities and keeping the ones that carry state or a single clear job. Eight components survive that test. A payment API accepts and validates requests, and an idempotency store dedupes retries. A fraud check can veto, and an authorization service decides the outcome. A ledger service owns the double-entry postings, backed by a durable transaction record. A capture and settlement path finalizes funds. A reconciliation pass proves the books agree with the outside world. Give each one a single responsibility and check that no two are quietly doing the same job.
The seam that matters most is keeping the ledger service the only thing that writes postings. Everything else decides whether money should move; only the ledger records that it did, and it does so as balanced double-entry against the idempotency guarantee. That one rule keeps the design honest. The fraud check can grow from a stub into a real model. The settlement worker can change how it batches, and the API can change how it authenticates. None of them reaches into balances directly. The transaction record sits beside the ledger as the immutable trail of how each payment moved through its states, so a disputed charge or a reconciliation break can be reconstructed after the fact.
How the pieces fit together
With the components named, the design is really the wiring between them and the order of one charge, so draw it. Read the diagram by role. Blue is an external party the system does not own: the merchant that starts the charge, and the merchant's bank that finally receives the payout. Violet is a service the system owns and runs. Here that includes the cardholder authorizer, because Amex issues the card itself. The approve-or-decline decision stays inside the system and never goes to an outside bank. Amber is the queue that hands the finalize step to a worker. Green is the durable state the design exists to protect: the idempotency store, the transaction record, and the double-entry ledger. Follow the edges to read one payment. Dedupe it, screen it, and authorize it against the internal cardholder account. Post the hold in the ledger, then capture and settle it. Reconciliation checks the books against what was paid out.
The lifecycle: authorize, capture, settle
The whole design converges on the order of one charge, and the order is the design. First the API dedupes the request against its idempotency key, so a retry short-circuits to the stored outcome before anything else runs. Then it screens the request with the fraud check, because a blocked charge should never reach the ledger. Then authorization asks the cardholder authorizer for a real-time approve or decline. On approval the system does two durable things together: it records the authorized state in the transaction record and posts an authorization hold in the ledger as balanced double-entry. Only later, on capture, does the settlement worker finalize the funds and write the settling posting. Splitting authorize from capture is what lets the decision be instant while the money moves in its own time.
// One charge is one lifecycle. The ORDER is the design: dedupe, screen,// authorize, then post the hold durably. Capture and settle run later.async function charge(req: ChargeRequest): Promise<Outcome> { // 1. IDEMPOTENCY: a retried key returns the first outcome, no double-charge. const prior = await idem.lookup(req.idempotencyKey) if (prior) return prior.outcome // exactly-once: replay, do not re-apply // 2. FRAUD HOOK: a blocked charge never reaches the ledger. if (!(await fraud.allow(req))) { return idem.record(req.idempotencyKey, { status: 'BLOCKED' }) } // 3. AUTHORIZE: real-time approve/decline from the internal cardholder account. const auth = await cardholder.authorize(req.card, req.amount) if (!auth.approved) { return idem.record(req.idempotencyKey, { status: 'DECLINED' }) } // 4. POST THE HOLD durably: transaction state + balanced double-entry, together. await txns.append(req.id, 'AUTHORIZED') await ledger.post({ // debit and credit sum to zero key: req.idempotencyKey, debit: 'SETTLEMENT', credit: req.merchant, amount: req.amount, }) // Capture is enqueued, not done inline: settlement finalizes funds later. await captureQueue.enqueue(req.id) return idem.record(req.idempotencyKey, { status: 'AUTHORIZED' })}Say two things about this order to the interviewer directly. First, the idempotency lookup comes before every side effect on purpose. Screen or authorize before you dedupe and a retry re-runs the fraud call and re-checks the credit line, which at best wastes work and at worst double-authorizes. Deduping first makes the entire charge exactly-once, ledger write included. Second, the transaction-state append and the ledger post have to be durable together, so a crash cannot leave the books saying a payment authorized while the ledger holds no posting, or the reverse. You might reach for a transaction across one store, an outbox the settlement worker drains, or a saga with compensations. Either way, the round wants to hear that you noticed the two writes must not diverge.
Idempotency, exactly-once, and reconciliation
Exactly-once has to be engineered
The network delivers at least once, clients resend, and workers crash mid-batch, so the money layer has to make applying the same logical operation twice land the same as applying it once. Idempotency keys give you that on the write path, and the key step is caching the decision so a retried decline stays declined.
Authorization is a hold, not a movement
Authorization asks the cardholder account whether the credit is there and reserves it in real time. No settled money has moved yet, which is why it can be reversed cheaply if the capture never comes and why it must return fast without waiting on settlement.
The append-only ledger is the history
Because every posting is written once and never edited, the ledger is a replayable record of how every balance came to be. That is the concrete payoff of designing for auditability from the start: a dispute weeks later is answerable against the trail rather than a guess.
Reconciliation is the safety net
Even with idempotent writes the outside world and your books can disagree. The reconciliation pass matches the ledger against what settled and flags every break. That could be an authorization that never captured, or a settlement the ledger never recorded. You catch the drift before a customer does.
Reconciliation deserves its own beat because it is the check the happy path cannot provide. Even with idempotent writes, the outside world and your books can disagree: a settlement your ledger never recorded, an authorization that ages out without a capture, or an amount that comes back different. The reconciliation pass reads the ledger postings and the transaction record and matches them against what was paid out and billed, then flags every break for repair. It is only possible because the ledger is append-only and every transaction state was recorded immutably, which is the concrete return on designing for auditability from the start rather than bolting it on.
Authorization (real time)
- A hold: the cardholder account reserves credit but no settled money has moved yet
- Must feel instant, so it returns a decision without waiting on settlement
- Recorded as an authorized state plus a hold posting in the ledger
- Can be reversed cheaply if the capture never comes
- Answers the question "is this allowed and is the credit there?"
Capture and settlement (later)
- The real movement: settled funds reach the merchant
- Runs asynchronously, often batched, off the capture queue
- Recorded as a settled state plus the final balanced posting
- Is where reconciliation matches your books to what was paid out
- Answers the question "did the money move, exactly once?"
The Amex closed-loop flavor
Only now, with the general system standing, add the Amex shape, because it maps onto pieces the design already has. Most card transactions run on an open loop. There is the cardholder and the merchant, a separate acquiring bank that banks the merchant, and a separate issuing bank that issued the card. A network in the middle routes messages between the two independent banks. American Express runs a closed loop instead, meaning it plays the issuer, the acquirer, and the network itself. That is why the diagram shows the cardholder authorizer as a violet service the system owns. The approve-or-decline decision never leaves Amex. The payout to the merchant is a direct settlement leg, with no hop between independent processors.
Keep the auditability thread running through the card framing too. A card payment is exactly the kind of transaction that gets disputed weeks later through a chargeback. The only way to answer a dispute is an immutable record of how the payment moved from authorization to settlement. Amex is both the issuer billing the cardholder and the acquirer paying the merchant. So that record has to reconcile both legs against one ledger. That raises the stakes on the audit trail. Say that the closed loop is a correctness responsibility as much as a routing convenience, and you have connected the general system to the Amex reality without losing the reusable core.
The runnable core: an idempotent double-entry ledger
The interviewer often compresses the whole design into one implementable class. For a payments system, that is the ledger itself. It is an idempotent, double-entry poster that moves money between accounts and refuses to overdraw a real account. A retried posting lands exactly once. It is the same skill as the full design in miniature, because the entire correctness posture lives in that one class. The double-entry balance, the non-negative invariant, and the idempotency cache are exactly what keep the system honest, and they are exactly where a candidate either states a clean invariant or hand-waves.
The problem below strips the network away and keeps the money math, because that is what runs. Amounts are integer minor units, so nothing rounds. One designated settlement account may go negative, because it stands for value entering and leaving from outside. Every posting carries an idempotency key, so a resend replays the first outcome. Handling the overdraw decline and the retried-key replay cleanly is the exercise. The duplicate case plays the same role in a min-stack.
Idempotent double-entry ledger: post, decline, replay
Problem
-
ClosedLoopLedger() creates an empty ledger. Every account starts at balance 0 and is created on first reference.-
post(key, debit, credit, amount) moves amount (an integer number of minor units, such as cents) out of the debit account and into the credit account as a balanced double-entry, governed by an idempotency key:- If
key has been seen before, return the same status string recorded for it and change no balances. This is exactly-once: a retry replays the first outcome.- Otherwise, if
amount <= 0, record and return "INVALID".- Otherwise, apply the non-negative invariant: if
debit is any account other than the special "SETTLEMENT" account and its balance is less than amount, record and return "DECLINED" and change no balances. The "SETTLEMENT" account is the external settlement account and is allowed to go negative.- Otherwise, subtract
amount from debit, add amount to credit, record and return "APPLIED".-
balance(account) returns the current integer balance of account (0 if never referenced).The overdraw decline and the retried-key replay are the whole exercise: a real account must never go negative, and a repeated key must return its first outcome without moving money a second time.
Input encoding. A test case is a JSON array of operations. The first entry is
["ClosedLoopLedger", []]; each later entry is [method, [args]]. The expected array holds each operation's return, with null for the constructor.Input: ClosedLoopLedger(); post("k1","SETTLEMENT","alice",1000); balance("alice"); post("k1","SETTLEMENT","alice",1000); balance("alice")
Output: the second post returns "APPLIED"; alice stays 1000
Funding alice from the settlement account applies once. The second post reuses key "k1", so it replays the stored "APPLIED" and does not credit alice again; her balance is still 1000, not 2000.
Input: ClosedLoopLedger(); post("t","alice","bob",500); balance("bob")
Output: post returns "DECLINED"; bob is 0
alice has a balance of 0 and is not the SETTLEMENT account, so debiting 500 would overdraw her. The posting is declined, no balance changes, and bob stays at 0.
Hold two maps: account to integer balance, and idempotency key to recorded status. post is a key lookup first, then a validate-and-apply with three outcomes. balance is a map lookup defaulting to 0.
Idempotent double-entry ledger: post, decline, replay
Click "Run" to execute your code
Test Cases
[["ClosedLoopLedger",[]],["post",["k1","SETTLEMENT","alice",1000]],["balance",["alice"]],["post",["k1","SETTLEMENT","alice",1000]],["balance",["alice"]],["balance",["SETTLEMENT"]]]Expected: [null,"APPLIED",1000,"APPLIED",1000,-1000][["ClosedLoopLedger",[]],["post",["f","SETTLEMENT","alice",1000]],["post",["t","alice","bob",300]],["balance",["alice"]],["balance",["bob"]]]Expected: [null,"APPLIED","APPLIED",700,300][["ClosedLoopLedger",[]],["post",["t","alice","bob",500]],["balance",["bob"]]]Expected: [null,"DECLINED",0][["ClosedLoopLedger",[]],["post",["t","alice","bob",500]],["post",["t","alice","bob",500]],["balance",["alice"]]]Expected: [null,"DECLINED","DECLINED",0][["ClosedLoopLedger",[]],["post",["z","SETTLEMENT","alice",0]],["balance",["alice"]]]Expected: [null,"INVALID",0][["ClosedLoopLedger",[]],["post",["f","SETTLEMENT","alice",700]],["post",["t","alice","bob",700]],["balance",["alice"]],["balance",["bob"]]]Expected: [null,"APPLIED","APPLIED",0,700]What the round rewards
The bar on this design is correctness of money first, then a clean path to scale, and never the reverse. Whichever way the prompt is phrased, a payments system, transaction handling, or the Amex network itself, the moves are the same. Build the general machine before the branding: a double-entry ledger as the source of truth, integer minor units so nothing rounds, and one component that owns every balance write. Make processing exactly-once with idempotency keys that cache the decision, and say plainly that this is how a retried request stays a single charge. Split authorize from capture so the decision is fast and settlement moves in its own time, and keep an immutable transaction record and an append-only ledger so the system is auditable and reconcilable. Put a hook where fraud, currency, and disputes will attach rather than solving them all now. Then, and only then, layer the closed loop on top, mapping the issuer and acquirer onto services Amex owns and pointing at idempotency as the piece that keeps a retried auth from charging twice. Show that order of priorities and you read as someone who could own a piece of a real payments platform, not someone who memorized the network diagram.
Key Takeaways
- ✓Design the general payments system before the card network. The reusable core is a double-entry ledger as the source of truth, integer minor units, one component that owns every balance write, and a clean authorize-then-capture split. The Amex closed loop is a flavor you add on top.
- ✓Money forces the invariants. Every posting balances to zero, real accounts never go negative while one designated settlement account may, and amounts are integer minor units rather than floats. State these before you draw a box, because they are what the round is really grading.
- ✓Exactly-once has to be engineered. Every mutating request carries an idempotency key, the system caches the decision, declines included, and a repeat replays the first outcome without moving money again. Dedupe before every side effect so the whole charge is exactly-once, ledger write included.
- ✓Split authorization from settlement and keep the trail immutable. Authorization is a real-time hold; capture and settlement move the money later off a queue; reconciliation matches the append-only ledger against what was paid out. The transaction record and the ledger are what make a chargeback answerable.
- ✓The closed loop concentrates correctness rather than removing it. Amex issues the card, banks the merchant, and runs the network, so one system owns the whole ledger. That helps fraud and audit. It also gives idempotency on retries the most weight.
American Express interview guideKnow what American Express asks before you walk in.
9 coding · 3 system design · 15 behavioral questions
Get their questions first.
InterviewLegend is independent and not affiliated with or endorsed by any company it covers.