Skip to content
SeeItFlow
System Design · 14 chapters · ~26 min read

Designing a Payment System

A payment system's hardest requirement isn't speed — it's correctness under failure. A payment that silently double-charges or silently never completes is worse than one that's slow, which is why this design treats idempotency and failure handling as the primary architecture, not an add-on. Every component here earns its place by solving a specific failure mode.

Mangesh MandlikLast updated:
Chapter 01

Problem Definition

We are designing a payment system where a customer initiates a payment, and for one logical payment we must prevent accidental duplicate charges and converge to the correct financial outcome despite retries, timeouts and duplicate messages -- even when the network is unreliable and providers time out.

The deceptive simplicity of 'charge a customer' conceals one of the hardest problems in distributed systems engineering. A payment involves at least three parties: the customer, your system, and an external payment provider. Any of the links between them can fail at any point. Your system must make progress without double-charging under any failure combination.

Interactive

Visual Design Journey

Watch a payment service evolve from a naive happy-path flow into an architecture built around one unbreakable rule: a customer must never be accidentally charged twice.

Chapter 02

Requirements

Functional and non-functional requirements -- with emphasis on what makes payment systems different from typical web services, and an explicit note on what's in scope.

Scope

Baseline scope: one-time payment initiation, provider interaction, asynchronous confirmation, payment status, safe retry, webhook handling, reconciliation, refund, a financial ledger/audit trail, and downstream payment events. Optional/advanced extensions covered later: authorization + capture, multiple providers, multi-region, partial refunds, and payment-method tokenization. Out of scope beyond a brief mention: card-network internals, full acquiring/issuing bank architecture, a PCI compliance tutorial, a fraud/ML system, subscription billing, invoicing, a tax engine, an FX engine, and a complete double-entry accounting course.

Functional

Create/initiate a payment. Retrieve payment status. Retry safely without creating a duplicate charge. Receive asynchronous provider updates. Reconcile ambiguous payments. Refund a successful payment. View payment history. Optionally cancel/void before final capture, if the chosen model supports authorization/capture.

Non-functional

No accidental duplicate charge for one logical request

The most important invariant: one logical payment must never produce more than one charge because of a retry, a network failure, or a duplicate message -- this is achieved through the mechanisms in Provider State vs Internal State and Idempotency Key, not assumed by default.

Durable payment identity and auditability

Every payment and every state transition must be permanently identifiable and logged. Payment history must be immutable and complete for customer support, compliance, and incident investigation.

Correct monetary representation and secure data handling

Amounts are never represented with binary floating point, and currency is always explicit. Payment instrument data is handled through a tokenization/security boundary appropriate to the system's compliance posture -- see Architecture Deep Dive -> Security Boundary.

Availability with fail-closed behavior where correctness can't be preserved

The payment API should remain available under load, and failures in downstream systems (notifications, order fulfillment) must not make the payment API unavailable. Where correctness genuinely cannot be preserved -- for example, the database is unreachable before a logical payment can be durably created -- the system fails closed rather than guessing.

Bounded reconciliation lag and downstream eventual consistency

Reconciliation should resolve ambiguity within a bounded, monitored window. Downstream views (notifications, reporting) can be eventually consistent.

Capacity estimate (illustrative)

These are illustrative planning numbers, not measurements of any real system. Assumptions: a mid-sized marketplace processes 10 million payments/day; peak-to-average factor 5; one provider call per payment; two provider webhooks per payment (for example processing, then final status); about 8 database row writes per payment (payment, attempt, idempotency record, two ledger entries, two outbox rows, one transition-history row); about 1 KB of durable data per payment across those rows; idempotency keys retained 7 days at about 200 bytes each; a provider account limit of 1,500 requests/s; and a primary database sustaining about 20,000 row writes/s. Day = 86,400 s.

QuantityFormulaResult
Average payment rate10,000,000 ÷ 86,400≈ 116 payments/s
Peak payment rate116 × 5≈ 580 payments/s
Peak provider webhooks580 × 2≈ 1,160 webhooks/s
Peak database row writes580 × 8≈ 4,640 rows/s (about 23% of the assumed 20,000 rows/s ceiling)
Durable data growth10,000,000 × 1 KB≈ 10 GB/day, ≈ 3.65 TB/year before archival
Idempotency records10,000,000 × 7 days; × 200 B70 million rows, ≈ 14 GB
Headroom to provider limit1,500 ÷ 580≈ 2.6× normal peak
Ceiling from the database20,000 ÷ 8≈ 2,500 payments/s

Assumed targets (illustrative SLOs): create-payment p99 latency of 3 s including one provider call with a 2 s timeout; ambiguous (timed-out) payments resolved by reconciliation within 15 minutes; RPO of zero for committed payment and ledger state; RTO of 5 minutes for the payment API on regional failover. What the numbers imply for this design: a single relational primary is a reasonable baseline at this volume, so sharding is a deliberate later choice rather than a default; the provider limit (about 1,500/s), not the database (about 2,500/s), is the first throughput ceiling in a spike; and webhook ingestion at about 1,160/s needs the persist-then-process-asynchronously split described in Scaling. The Failure & Scale Scenarios page traces a spike against these same numbers.

Chapter 03

Simple Payment Flow

Start with the simplest possible architecture: Customer → API → Payment Service → Provider → Database.

Swipe to pan
Simple Payment FlowCustomer → API → Payment Service → Provider → DatabaseCustomerAPIPayment SvcProviderStripe-likePayment DBstate
The simplest payment flow — before we need idempotency, state machines, or reconciliation

The precise order matters: (1) validate the request; (2) durably create or reuse the logical payment for this idempotency key; (3) create a payment attempt; (4) call the provider using a stable provider idempotency/reference identity; (5) persist whatever result is actually known; (6) return a confirmed, pending, or unresolved response to the caller -- not a blind guess; (7) if the outcome was ambiguous, resolve it asynchronously via retry/status-query/webhook/reconciliation.

Payment state must be persisted to the database before calling the provider and after receiving a response. If the service crashes between these steps, the persisted state lets us reason about what happened.

Chapter 04

Payment State Machine

A payment is not a boolean. It has explicit states that represent what actually happened -- including intermediate and unresolved states.

Swipe to pan
Payment State MachineINITIATED → PROCESSING → SUCCESS or FAILED (or UNKNOWN)INITIATEDPROCESSINGSUCCESSFAILEDUNKNOWNneeds resolution(response lost)webhook/reconcile resolves
A simplified teaching state machine -- INITIATED -> PROCESSING -> SUCCESS or FAILED, with UNKNOWN for unresolved responses

INITIATED

Payment record created in the database. No external call made yet.

PROCESSING

Sent to the provider. Awaiting response. This state must survive restarts.

SUCCESS

Provider confirmed the charge. Terminal for the charge itself, though it can still transition to PARTIALLY_REFUNDED or REFUNDED later if refunds are in scope.

FAILED

Provider rejected or returned a definitive error. Terminal.

UNKNOWN

A response was not received, or the outcome is otherwise unresolved. The operation may have succeeded or failed. Requires reconciliation.

Logical payment vs payment attempt

One logical payment can have more than one payment attempt. Example: payment_123 has attempt_1 to Provider A, which fails definitively, and attempt_2 to Provider B, which succeeds. This is different from retrying the same ambiguous attempt -- a genuinely new attempt gets its own identity and its own provider reference, and the data model never overwrites earlier attempt history. This distinction becomes essential once multiple providers or reconciliation are involved -- see Architecture Deep Dive -> Payment Intent and Attempt Model.

Chapter 05

The Duplicate Request Problem

A customer clicks Pay twice. Or a client automatically retries a timed-out request. Without protection, both requests produce separate charges.

Swipe to pan
Duplicate Charge — Without IdempotencyTwo requests → provider processes both → customer charged twiceCustomerclicks twicePayment SvcProvidercharge $100charge $100 AGAINCustomer billed $200 for a $100 payment
Without idempotency: two requests → two charges → double charge

This is not a theoretical edge case. In production systems, duplicate payments are one of the most common causes of customer support escalations and refund costs. Network timeouts, user double-clicks, client retries, and message delivery-at-least-once semantics all produce duplicate requests.

Chapter 06

Idempotency Key

An idempotency key is a client-generated unique identifier attached to every payment request. The same key submitted multiple times must resolve to the same logical payment -- not necessarily by blocking on a lock, but by never creating a second, independent charge.

Swipe to pan
Idempotency Key — Safe RetrySame key → store hit → return stored result, no second chargeCustomerPayment SvcIdempotencykey storeProviderkey:abc123HIT → return storedskipped(no charge)
Idempotency key resolves to the same logical payment on retry -- backed by a durable record, not a cache lookup

A commonly seen but insufficient pattern checks a fast cache for the key, calls the provider if absent, and stores the result -- guarded by a distributed lock. The failure window: the key is absent, the provider successfully charges, the process crashes before the cache write, the lock expires, the client retries, and the service -- believing the request is new -- may call the provider again. A cache can accelerate lookups; it should not be the authoritative correctness mechanism.

Durable idempotency record

A uniqueness constraint in durable storage, conceptually UNIQUE(merchant/account scope, idempotency_key). The first request atomically creates the logical payment/idempotency record; a concurrent or later request with the same key finds the existing record instead of creating a new one.

Request fingerprint and conflict rejection

Store a fingerprint of the request (amount, currency, order/business reference, operation type) alongside the key. If the same key is reused with a different fingerprint, reject it as an idempotency-key conflict -- do not silently return the old result for what may be a semantically different request.

In-progress requests

If a duplicate arrives while the original is still processing, it does not need to block until the original finishes -- returning current state, 202/PENDING, or a bounded wait are all valid, as long as the duplicate resolves to the same logical payment and never creates a second charge.

Provider-side idempotency

A second, separate boundary: derive a stable provider idempotency key per payment attempt and reuse it across safe retries of that attempt. Local API idempotency prevents duplicate logical payments inside your own API; provider idempotency prevents your own retries from producing duplicate provider-side charges. See Architecture Deep Dive -> Provider Idempotency Boundary.

Retention

Retention is a product and provider contract -- sized to the API contract, the maximum client retry window, dispute/recovery requirements, and the provider's own idempotency window -- not an arbitrary cache TTL after which a retry is automatically treated as a new payment.

Chapter 07

The Timeout Problem

The provider processes the payment. The response is lost on the network. The service times out. Did the payment succeed?

Swipe to pan
Timeout AmbiguityProvider succeeds — response lost — internal state: UNKNOWNPayment SvcUNKNOWNProviderSUCCESS ✓✗ response loststate = UNKNOWNstate = SUCCESSA timeout ≠ payment failedResolution: webhook or reconciliation
Timeout ambiguity -- provider state may be SUCCESS while internal state is unresolved

A timeout means 'no response was received' -- not 'the operation failed.' The ambiguity can arise at several distinct points: the request never reached the provider; the provider received it but hadn't processed it yet; the provider processed it and it failed; the provider processed it and it succeeded; the success response was lost in transit; or the provider accepted it for asynchronous processing. The local system frequently cannot tell which of these actually happened.

The correct immediate response to a timeout: record the attempt as unresolved, keep the same provider idempotency identity for any safe retry, and let the resolution mechanisms above determine the actual outcome. See Architecture Deep Dive -> Ambiguous Outcome Resolution for the full decision tree.

Chapter 08

Provider State vs Internal State

After a timeout or dropped response, the internal system and the provider can temporarily disagree about the payment's state. The architecture must converge them.

Two mechanisms converge divergent state: webhook callbacks (provider pushes the result when it becomes available) and active status polling (Payment Service periodically asks the provider for the current status of pending payments).

Webhook callbacks

The payment provider POSTs the final payment result to a registered endpoint. The Webhook Handler updates internal state. This is the primary convergence mechanism.

Status polling

For payments in UNKNOWN state beyond a threshold time, the Reconciliation Service queries the provider directly. This is the fallback when webhooks are delayed or missed.

Chapter 09

Asynchronous Downstream Processing

Payment success triggers downstream operations -- order fulfillment, notifications, ledger entries. These must not block payment confirmation, and the hand-off between the payment database and the event queue has its own failure window worth naming explicitly.

Swipe to pan
Asynchronous Downstream ProcessingPayment confirmed synchronously — downstream events async via queuePayment SvcSUCCESSQueueeventsOrder SvcNotificationLedgerauditdecoupledasync retry
Queue-based downstream decoupling -- payment confirmation is durable locally, downstream effects are event-driven

When a payment succeeds, the service publishes a payment.success event to a queue, and workers consume it to trigger downstream operations independently. If the email service is down, the payment still confirms -- the email is retried later.

The DB-to-queue dual-write problem

Writing SUCCESS to the database and then publishing to the queue are two separate operations. If the database commits and the service crashes before publishing, downstream consumers never see the event. If the publish call is retried after an ambiguous broker response, consumers may see duplicates.

Transactional outbox

The production fix: in the same database transaction that writes the payment state, also write a durable outbox row representing the event. A separate relay publishes outbox rows to the broker. Because the relay itself can retry, delivery is at-least-once -- so every downstream consumer still needs to be idempotent. See Architecture Deep Dive -> Transactional Outbox.

Chapter 10

Provider Failure & Circuit Breaker

The payment provider becomes unavailable, or starts returning errors. The system must respond with conservatism, and must first tell the difference between a definitive failure and a transient one.

Error classification

A definitive failure (invalid payment method, decline, invalid request) should not be retried. A transient technical failure (5xx, rate limit, temporary connectivity issue) may be retried if the operation is idempotent and the provider's contract allows it. An ambiguous outcome (timeout after the request was sent, crash after the provider call) is resolved through the ambiguous-outcome decision tree, not treated as an ordinary retry. See Architecture Deep Dive -> Error Classification and Retry Engine.

Exponential backoff with jitter

Retry intervals grow exponentially with jitter -- this prevents retry storms and gives the provider time to recover, bounded by a retry budget and a maximum elapsed time.

Circuit breaker

After a threshold of consecutive failures, the circuit opens and new requests fail fast rather than waiting for timeouts, scoped per provider/operation. This is a useful resilience pattern for protecting the service and provider from repeated technical failures -- it does not resolve ambiguous payments that already happened before the circuit opened, and it is not a universal correctness requirement for every payment system.

Half-open probing

When the circuit is open, limited traffic tests recovery. If probes succeed, the circuit closes, avoiding indefinitely blocking a recovered provider.

Chapter 11

Reconciliation

A reconciliation process compares internal payment records against provider records, finds discrepancies, and repairs them -- covering considerably more ground than payments stuck in an unresolved state.

Swipe to pan
ReconciliationPeriodic sweep: compare internal DB vs provider, repair diverged stateReconcile SvcperiodicPayment DBUNKNOWNProviderSUCCESSLedgeraudit logGET unknownsGET statusUPDATE + audit
Reconciliation sweeps unresolved payments and converges state with the provider -- and also catches divergence webhooks never surfaced

Reconciliation handles: unresolved payments that never received a webhook, SUCCESS/FAILED mismatches, delayed callbacks, successful provider transactions missing internally, internal SUCCESS states missing provider evidence, refund mismatches, amount/currency mismatches, duplicate provider transactions, and webhook gaps.

Operational vs financial reconciliation

Operational reconciliation is a near-real-time or frequent periodic check against the provider's live API to resolve pending/unresolved payments. Financial reconciliation compares the internal ledger/payment records against a provider settlement file or export, and may run on a different cadence, catching discrepancies the API and webhooks never surfaced at all.

Idempotent reconciliation

Running the same reconciliation batch twice must not double-create ledger entries, emit duplicate business effects, or issue duplicate refunds -- every repair needs a stable reconciliation/action ID.

Every discrepancy found by reconciliation is logged -- not just the repair, but the fact a repair was needed, when it was made, and what the original and corrected states were.

Chapter 12

Auditability

Every state transition is permanently recorded in an immutable audit log -- and that audit log is a distinct concept from the financial ledger, even though both are typically append-only.

The audit log records: payment ID, amount, currency, provider reference ID, every state transition with timestamps, who or what triggered the transition, and the final outcome. It is append-only -- no modification, no deletion.

Payment state vs financial ledger

The mutable payments.current_state row is useful operational state -- it answers 'where is this workflow right now?' It is not the entire financial record. A financial ledger is a separate, append-only account of monetary movements -- charge/capture, refund, fee, reversal/adjustment -- that answers 'what money movement has actually been recorded?' An audit log tracking who changed what state is not automatically a financial ledger tracking money movements; the two serve different purposes even where both are append-only. See Architecture Deep Dive -> Financial Ledger for the full model, including the idea of balanced double-entry postings.

Customer support

When a customer reports a double charge, the audit log shows every event in the payment's lifecycle -- no guessing.

Financial reconciliation

Financial reconciliation requires a complete ledger record of every monetary movement, compared against provider settlement data -- not just an audit trail of state changes.

Refunds are a new financial operation

A refund is not simply flipping a payment's state to FAILED -- it is its own operation with its own idempotency key, provider reference, and state, constrained so that sum(successful refunds) never exceeds the captured amount. It produces its own ledger entries rather than modifying the original charge entry. See Architecture Deep Dive -> Refund Architecture.

Incident investigation

When an incident occurs, the audit log shows which states were visited, what provider responses were received, and what reconciliation actions were taken -- while the ledger shows what money actually moved.

Chapter 13

Scaling

Scale after correctness is established -- never sacrifice correctness guarantees for throughput.

Stateless payment API

The API tier is stateless -- any instance can handle any request. Scale horizontally behind a load balancer. Correctness-relevant state never lives only in process memory.

Database scaling

Payment databases favor strong consistency (relational, ACID) as a strong baseline for local invariants. Sharding is not a casual 'shard by customer ID or payment ID' choice -- the key affects idempotency uniqueness, merchant queries, reconciliation, order lookup, ledger aggregation and refund routing. See Trade-offs & Decisions -> Single Database vs Sharded Payment Store. Read replicas are appropriate for stale-tolerant reads and reporting, not for authoritative decisions that require fresh state.

Idempotency scaling

Idempotency correctness is anchored in the database's durable uniqueness constraint, not in Redis as authoritative state -- Redis can optionally cache completed responses to reduce load. See Architecture Deep Dive -> Durable Idempotency.

Webhook and reconciliation scaling

Webhook ingestion scales via lightweight verification/persistence followed by async, idempotent, partitioned processing with backpressure. Reconciliation scales via indexed unresolved-state queries, time windows, provider/account partitioning, checkpoints/watermarks, and bounded batches -- never one giant table scan.

Queue workers

Scale downstream workers independently -- they are decoupled from the payment confirmation path.

Chapter 14

Final Architecture

The complete system — every component earns its place by solving a specific demonstrated failure mode.

Swipe to pan
Final ArchitectureEvery component traces to a specific failure modeCustomerAPI GWPayment SvcPayment DBIdempotencystoreProviderWebhook HdlrReconcileQueueLedgeraudit
Complete payment architecture -- every component traces to a specific failure mode or correctness requirement it addresses
Key takeaways
  1. 01API Gateway: authentication, rate limiting, routing
  2. 02Payment Service: orchestration, durable idempotency check, guarded state writes, provider call via the provider adapter
  3. 03Durable Idempotency Record: database uniqueness constraint preventing duplicate logical payments -- not a cache
  4. 04Payment Database: durable source of truth for payment and attempt state, with constraints and versioned transitions
  5. 05Payment Provider(s): external parties that actually move money, accessed through a normalizing adapter
  6. 06Webhook Handler: verifies, persists, deduplicates and applies provider callbacks through guarded transitions
  7. 07Transactional Outbox: closes the DB-to-event-queue dual-write gap for downstream events
  8. 08Financial Ledger: append-only record of money movement, distinct from mutable payment state
  9. 09Reconciliation Service: periodic, idempotent sweep across operational and financial divergence, not only unresolved payments
  10. 10Refund Engine: a separate financial operation with its own idempotency and ledger entries
  11. 11Event Queue + Downstream Workers: order, notification, ledger consumers -- all async and idempotent
  12. 12Audit Log: immutable record of every state transition, distinct from the financial ledger