InterviewLegend

Design a Payments System

Commonly asked
Alex Chen24 min readUpdated 33 real interview reports
In this lesson
  • 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

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.

The American Express angle
On a payments rail, money being right is the requirement, and Amex hires engineers who feel that. At a consumer app a double-counted like is invisible and a lost write is something the user just redoes. Move value between two accounts and neither is survivable: charging a card twice or dropping a transfer is a real harm with a real paper trail behind it. So the words that carry weight in this round are exactly-once processing, idempotency, and auditability. Exactly-once means a retried request settles the money a single time. Idempotency keys are how you reach it when the network forces a resend. Auditability means every state change of a transaction is recorded immutably, because a payment has to be reconstructable and reconcilable long after the customer forgot about it. Say those out loud and design for them on purpose, and you are speaking the language this loop rewards.

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.

Loading diagram...

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.

typescript
// 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.

The American Express angle
The closed loop does not remove the hard part; it concentrates it, and idempotency on retries is where Amex will push hardest. Because Amex sees both sides of every transaction, the cardholder and the merchant, it can score richer fraud signals and owns the whole ledger end to end, which is a genuine advantage to name. But owning both sides also means one system is now responsible for correctness that an open network would spread across two banks. A terminal still times out and resends, and if a resent authorization is not tied to the original by a stable key, it becomes a second hold and the cardholder sees a duplicate charge. So a card auth carries an identifier that lets every step treat a retry as the same logical transaction, and settlement matches on it too, so a replayed capture never settles twice. That is the same idempotency-key mechanism from the general design, now doing its most important work at the network boundary Amex owns end to end. Explain why authorize and settle are separate. Show how one identifier keeps a retried message from moving money twice across both. That answers the closed-loop question the way this loop wants.

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.

1

Idempotent double-entry ledger: post, decline, replay

Medium
Problem
A payments system records money as double-entry postings against an append-only ledger. Implement the ledger:

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

How this uses the pattern

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

Loading...
Click "Run" to execute your code

Test Cases

Input: [["ClosedLoopLedger",[]],["post",["k1","SETTLEMENT","alice",1000]],["balance",["alice"]],["post",["k1","SETTLEMENT","alice",1000]],["balance",["alice"]],["balance",["SETTLEMENT"]]]Expected: [null,"APPLIED",1000,"APPLIED",1000,-1000]
Input: [["ClosedLoopLedger",[]],["post",["f","SETTLEMENT","alice",1000]],["post",["t","alice","bob",300]],["balance",["alice"]],["balance",["bob"]]]Expected: [null,"APPLIED","APPLIED",700,300]
Input: [["ClosedLoopLedger",[]],["post",["t","alice","bob",500]],["balance",["bob"]]]Expected: [null,"DECLINED",0]
Input: [["ClosedLoopLedger",[]],["post",["t","alice","bob",500]],["post",["t","alice","bob",500]],["balance",["alice"]]]Expected: [null,"DECLINED","DECLINED",0]
Input: [["ClosedLoopLedger",[]],["post",["z","SETTLEMENT","alice",0]],["balance",["alice"]]]Expected: [null,"INVALID",0]
Input: [["ClosedLoopLedger",[]],["post",["f","SETTLEMENT","alice",700]],["post",["t","alice","bob",700]],["balance",["alice"]],["balance",["bob"]]]Expected: [null,"APPLIED","APPLIED",0,700]
The American Express angle
This is the correctness core of the payments-system design reduced to what runs. Amex cares that the ledger is double-entry, that a real account never goes negative, and that a retried key replays instead of re-applying. Those three rules stop a card from being charged twice and money from being conjured. The interviewer is watching the overdraw boundary and the idempotent replay survive the test cases. State the invariants before you code them, and drive the boundary cases yourself. Retry a key after both an APPLIED and a DECLINED. Try a debit that exactly equals the balance, and the SETTLEMENT account going negative.
Take it further
A natural extension puts the rest of the rail back. Split authorize from capture: an authorization posts a hold that a later capture settles or a timeout reverses, so a key can move through states rather than resolving once. Then add reconciliation: given an external list of what settled, report the breaks, an authorization that never captured or a settlement the ledger never recorded. Be ready to say what each costs: holds mean the ledger stores pending as well as settled postings, and reconciliation means the ledger must stay append-only so the history is replayable.

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.
Had your American Express interview?Tell us what they asked. Reviewed before it goes in, never with your name.
American Express interview guide

Know what American Express asks before you walk in.

9 coding · 3 system design · 15 behavioral questions

Get Premium$49 a year · 30-day money-back guarantee
Or start freeNo card.

Get their questions first.

InterviewLegend is independent and not affiliated with or endorsed by any company it covers.