Designing a Distributed Payments System
A full consistency-critical design: idempotent APIs, payment state machines, double-entry ledgers, processor failures, webhooks, and reconciliation.
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:
- A client retry must not create a second logical payment.
- A processor retry must not create a second external charge.
- Every committed money movement must be represented by balanced ledger entries.
- Payment status must move only through legal transitions; late messages cannot move it backward.
- The system must retain enough evidence to explain any balance to support, finance, and auditors.
- 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.
One command, one processor operation, balanced ledger entries
The API returns the durable payment state only after the local transaction commits.
- 1ClientPOST /payments with key pay_7FRequest accepted
- 2Payments APIReserve idempotency keyFirst request owns the key
- 3OrchestratorCreate payment + pending ledger intentLocal transaction commits
- 4ConnectorAuthorize with processor key pay_7FProcessor returns authorized
- 5LedgerPost balanced authorization entriesPayment becomes authorized
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 Createdwhen the operation completed synchronously.202 Acceptedwith a payment ID when the processor result is still pending.- The original saved response for a completed duplicate.
409 Conflictwhen 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:
- Verify the signature over the raw body and enforce a timestamp tolerance.
- Persist the provider event ID in an inbox table with a unique constraint.
- Acknowledge quickly after durable acceptance.
- Process asynchronously.
- Fetch processor state when the event is incomplete or suspicious.
- Apply only a legal versioned transition.
- 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
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 eventIf 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.
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: 20Derived amounts:
authorized = 200
captured gross = 170
refunded = 20
captured net = 150
remaining auth = 30Each 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.
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:
- Replicate payment and idempotency records to a paired region.
- Fence the old owner before promoting the replica using a lease/epoch stored in a strongly consistent control plane.
- Include the ownership epoch in writes so a stale owner cannot commit after promotion.
- Keep processor operation keys stable across regions.
- 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.