AI SWE Prep
0/10in System Design
System Design

Designing a Distributed Payments System

A full consistency-critical design: idempotent APIs, payment state machines, double-entry ledgers, processor failures, webhooks, and reconciliation.

~28 minLesson 51 of 61
Your progressNot started

A payment system is a compact test of senior engineering judgment. It has ordinary distributed-system failures—timeouts, duplicates, reordering, partial outages—but an incorrect retry can move real money twice. The design goal is not mythical exactly-once delivery. It is effectively-once business behavior built from idempotent commands, immutable accounting, explicit state transitions, and reconciliation.

This lesson designs card authorization and capture for a marketplace. The platform accepts a payment request, calls an external payment service provider (PSP), records what money movement means internally, and eventually settles merchants.

1. Start with invariants, not boxes

Before choosing a database or queue, state what must remain true:

  1. A client retry must not create a second logical payment.
  2. A processor retry must not create a second external charge.
  3. Every committed money movement must be represented by balanced ledger entries.
  4. Payment status must move only through legal transitions; late messages cannot move it backward.
  5. The system must retain enough evidence to explain any balance to support, finance, and auditors.
  6. Internal records and processor settlement must eventually agree—or produce an actionable discrepancy.

Those invariants produce the architecture. They are also the standard against which every failure scenario is judged.

2. Requirements and estimates

Functional scope

  • Create a payment intent for an order.
  • Authorize a card, then capture immediately or later.
  • Refund a captured payment.
  • Receive signed processor webhooks.
  • Expose current status and an audit history.
  • Reconcile internal transactions against processor settlement files.

Non-functional requirements

Requirement Initial target Why it matters
API availability 99.99% Checkout should not fail because an analytics path is down.
Create-payment p95 < 300 ms before processor time Local validation and durable intent creation must be fast.
End-to-end authorization p95 < 3 s Processor latency dominates the user experience.
Ledger correctness No unbalanced committed transaction Availability never justifies inventing money.
Audit retention 7+ years, jurisdiction-dependent Financial investigations outlive normal application logs.
Reconciliation freshness Daily, with near-real-time alerts Processor truth can diverge after ambiguous failures.

Assume 10 million payments/day with a 10× peak factor:

average creates/sec = 10,000,000 / 86,400 ≈ 116
peak creates/sec    ≈ 1,160
write events        ≈ 8 per payment → ~9,300 writes/sec peak

ledger entries:
10M payments × 4 entries across auth/capture/refund lifecycle
≈ 40M entries/day

The raw QPS is manageable for a partitioned relational system. Correct transactions, operational isolation, and auditability matter more than choosing a fashionable high-scale datastore.

3. The architecture and its failure paths

Use the scenarios below as a design review. Do not only inspect the happy path: switch to each failure and explain which invariant prevents money from moving twice.

Distributed payment system · failure explorerSelect a scenario
Payments APIauth · validation · stable payment ID
Idempotency storekey + request hash + saved result
Payment orchestratorlegal state machine · saga policy
Double-entry ledgerimmutable balanced transactions
Transactional outboxcommit state and event atomically
Processor connectortimeouts · retries · provider normalization
External processorauthorization · capture · settlement
Webhook inboxsignature · dedupe · ordering guard
Reconciliationinternal ledger ↔ processor report
command →reserve ↓post →publish →authorize →← signed eventcompare ↑
Invariant under test

One command, one processor operation, balanced ledger entries

The API returns the durable payment state only after the local transaction commits.

  1. 1
    ClientPOST /payments with key pay_7F
    Request accepted
  2. 2
    Payments APIReserve idempotency key
    First request owns the key
  3. 3
    OrchestratorCreate payment + pending ledger intent
    Local transaction commits
  4. 4
    ConnectorAuthorize with processor key pay_7F
    Processor returns authorized
  5. 5
    LedgerPost balanced authorization entries
    Payment becomes authorized
active pathambiguous or duplicate observationdurable source of money truth

The components have deliberately separate jobs:

Component Owns Must not own
Payments API Authentication, request validation, stable payment identity Processor-specific behavior
Idempotency store Command key, request fingerprint, execution/result state Accounting truth
Orchestrator Legal payment state transitions and saga decisions Mutable balance totals
Double-entry ledger Immutable financial transactions and balances Workflow retries
Transactional outbox Atomic state-change event publication Business decision logic
Processor connector Provider API normalization, deadlines, retry keys Final internal truth
Webhook inbox Signature verification, event dedupe, ordered application Blind trust in arrival order
Reconciler Compare independent records and open discrepancies Rewriting history silently

4. API design: identify the command

POST /v1/payments
Idempotency-Key: checkout_84d9_attempt_1
Content-Type: application/json

{
  "order_id": "ord_84d9",
  "amount": 12900,
  "currency": "USD",
  "payment_method_token": "pm_tok_...",
  "capture": false
}

The server stores:

(merchant_id, idempotency_key)
request_hash
payment_id
status: processing | completed | failed
response_status + response_body
expires_at

The key is scoped to the authenticated merchant or tenant. The request hash matters: if the same key arrives with a different amount, returning the original response would hide a client bug. Reject the mismatch.

The API can return:

  • 201 Created when the operation completed synchronously.
  • 202 Accepted with a payment ID when the processor result is still pending.
  • The original saved response for a completed duplicate.
  • 409 Conflict when a key is reused with a different canonical request.

5. Payment state is a monotonic state machine

created
  ├─> authorizing ─> authorized ─> capturing ─> captured ─> refunding ─> refunded
  │         │               │             │
  │         └─> failed      └─> voided    └─> failed_or_unknown
  └─> canceled

Persist a state version and transition with compare-and-swap semantics:

UPDATE payments
SET status = 'captured', version = version + 1
WHERE id = :payment_id
  AND status IN ('authorized', 'capturing')
  AND version = :expected_version;

A late authorized webhook cannot overwrite captured, because that transition is illegal. A duplicate captured event becomes a no-op because its processor event ID and ledger transaction reference have already been recorded.

Do not compress every unknown into failed. A timeout means the caller does not know. The processor may have committed before the response disappeared. Preserve pending or unknown so retry and reconciliation can resolve it.

6. The ledger is not the payment table

payments.status = "captured" is workflow state. It is not an auditable model of who owns money.

A double-entry ledger records immutable transactions whose debits and credits balance:

Capture $129.00 Debit Credit
Processor receivable $129.00
Merchant payable $125.13
Platform fee revenue $3.87
Total $129.00 $129.00

Useful tables:

ledger_transactions(
  id, reference_type, reference_id, event_type,
  effective_at, created_at
)

ledger_entries(
  transaction_id, account_id, direction,
  amount_minor, currency
)

Enforce in one database transaction:

  • Entries use integer minor units or fixed-precision decimals, never binary floating point.
  • All entries share one currency unless an explicit FX transaction bridges accounts.
  • Sum(debits) equals sum(credits).
  • (reference_type, reference_id, event_type) is unique for idempotent posting.
  • Posted entries are immutable; corrections use reversing and replacement transactions.

7. Atomic state and event publication

After committing payment = authorized, downstream capture, notification, and analytics consumers need an event. Writing the database and publishing to a queue as two independent operations creates a dual-write bug:

database commit succeeds
process crashes before queue publish
payment is authorized, but capture never starts

Write an outbox row in the same local transaction as the payment transition. A relay publishes unpublished rows and marks them sent. Publication can repeat, so consumers dedupe by event ID or process idempotently.

BEGIN;
  UPDATE payments ...;
  INSERT INTO outbox(event_id, aggregate_id, event_type, payload)
  VALUES (..., 'payment.authorized', ...);
COMMIT;

The outbox closes the local atomicity gap. It does not make consumers exactly once.

8. Webhooks are untrusted, duplicated, and reordered

A robust webhook path:

  1. Verify the signature over the raw body and enforce a timestamp tolerance.
  2. Persist the provider event ID in an inbox table with a unique constraint.
  3. Acknowledge quickly after durable acceptance.
  4. Process asynchronously.
  5. Fetch processor state when the event is incomplete or suspicious.
  6. Apply only a legal versioned transition.
  7. Post ledger entries with a unique business reference.

Do not rely on webhook arrival order. A capture event can race the synchronous authorization response; a refund can be delivered twice; an old authorization update can arrive after settlement.

9. Reconciliation: the repair loop

Idempotency prevents many duplicates, but no request path proves that two independent financial systems agree forever. Reconciliation compares:

internal payment operations
internal ledger transactions
processor transaction export
processor settlement and fee report
bank deposit

Classify mismatches:

  • Present at processor, absent internally.
  • Present internally, absent at processor.
  • Amount or currency mismatch.
  • Duplicate processor operation.
  • Status/timing mismatch.
  • Fee or settlement mismatch.

The reconciler should create a discrepancy record, attach evidence, and route by policy. Safe automated repairs post explicit compensating transactions. They never edit old ledger rows to make the mismatch disappear.

10. What to memorize vs. what to derive

Memorize these invariants

  • Same logical command → same idempotency key.
  • Same external operation → same processor key on retry.
  • Ledger transaction → balanced immutable entries.
  • Unknown external result → pending plus retry/reconcile, not guessed failure.
  • State transitions → monotonic, versioned, and idempotent.
  • State change plus event → transactional outbox.

Derive these from requirements

  • Synchronous vs. asynchronous authorization response.
  • Database partition key and regional ownership.
  • How long idempotency records live.
  • Whether capture is immediate, delayed, or partial.
  • Which discrepancies can be repaired automatically.
  • Availability policy when the processor, ledger, or queue is unavailable.

Worked design questions

Failure drillHard
  • Idempotency
  • Unknown outcome
  • Reconciliation

The processor timed out after capture

Your connector sent capture(pay_123) and timed out after three seconds. The processor dashboard later shows a successful capture, but the internal payment remains capturing. Design the immediate response and repair path.

Before revealing the answer, distinguish what is known, what is unknown, and which operation may safely repeat.

Show the failure walkthrough

1. Preserve uncertainty. The timeout is not a failed capture. Keep the payment in capturing or capture_unknown; return a pending status rather than telling the client to start a new payment.

2. Retry the same operation identity. Retry the processor call with the same connector idempotency key. A compliant processor returns the original capture instead of moving money again.

3. Accept independent observations. A signed webhook or a connector status lookup can confirm capture. Both enter through the same idempotent transition handler.

4. Commit internal truth once. In one local transaction:

transition capturing -> captured if legal/version matches
insert ledger transaction with unique (payment_id, "capture")
insert payment.captured outbox event

If a webhook already applied it, the unique references and state guard make the late response a no-op.

5. Reconcile. The daily processor export finds any operation still unknown. If the processor captured but internal posting is absent, create an explicit repair transaction after validating amount, currency, merchant, and processor reference.

The key reasoning is that retry safety comes from stable identity, while final confidence comes from reconciliation between independent records.

Schema drillHard
  • Ledger
  • State machine
  • Amounts

Design partial capture and partial refund

An order authorizes $200. The merchant ships one item and captures $120, later captures $50, then refunds $20. How should the state and ledger model avoid ambiguous mutable totals?

Show the accounting model

Treat authorization, each capture, and each refund as separate immutable operations under one payment:

payment pay_1
authorization auth_1: 200
capture cap_1: 120
capture cap_2: 50
refund ref_1 against cap_1: 20

Derived amounts:

authorized       = 200
captured gross   = 170
refunded         = 20
captured net     = 150
remaining auth   = 30

Each operation has its own idempotency key and legal limits:

  • Total captures cannot exceed the live authorization.
  • A refund cannot exceed the refundable amount of its capture/payment.
  • Currency must match.
  • Concurrent captures use a transactional guard on remaining authorization.

The payment status can be a projection such as partially_captured or partially_refunded; it is not the source of amount truth. Ledger transactions record each capture and refund independently, and balances are derived from entries.

Architecture reviewHard
  • Multi-region
  • Consistency
  • Availability

Choose a multi-region consistency policy

Checkout traffic is global. Product wants active-active writes in every region, but a payment must never be captured twice. Propose a practical ownership and failover policy.

Show the trade-off analysis

Route each payment to one home region chosen from a stable key such as merchant plus payment ID. That region owns payment-state transitions and ledger posting. Other regions can accept traffic at the edge, authenticate, and forward the command to the owner.

Why not unconstrained multi-writer payment state?

  • Concurrent retries can race in different regions.
  • Conflict resolution such as last-write-wins is invalid for money.
  • A globally synchronous consensus write on every step increases latency and reduces availability.

Failover policy:

  1. Replicate payment and idempotency records to a paired region.
  2. Fence the old owner before promoting the replica using a lease/epoch stored in a strongly consistent control plane.
  3. Include the ownership epoch in writes so a stale owner cannot commit after promotion.
  4. Keep processor operation keys stable across regions.
  5. Reconcile all operations spanning the failover window.

The trade-off is explicit: during ambiguous ownership, reject or delay consistency-critical writes rather than risk double capture. Read-only status and unrelated checkouts can remain available.

Your progressNot started