Industry-standard payment processing using idempotency keys (Redis SETNX), a sharded PostgreSQL ledger, and Kafka for async card network routing. Saga compensation reverses charges on failure. Sub-200ms charge latency at 100K TPS.
The idempotent ledger with saga orchestration is the industry-standard architecture for payment processing systems handling tens of thousands of transactions per second. It solves the two critical problems that make the naive synchronous approach unworkable at scale: the thread-blocking card network bottleneck and the absence of idempotency guarantees that cause double charges.
The key architectural shift is making the card network call asynchronous. Instead of blocking a request thread for 200-500ms waiting for Visa or Mastercard to respond, the charge API writes the payment record to the ledger with status 'pending' and publishes an event to Kafka. A dedicated CardNetworkWorker consumes the event and makes the external call asynchronously. The charge API returns in under 200ms with a payment_id and status 'pending' — the merchant either polls for the result or receives a webhook when the card network responds.
This async processing model transforms the throughput ceiling. The naive approach caps at ~450 RPS because each thread is blocked for 300ms. The saga approach frees the thread immediately after the ledger write (~50ms) and Kafka publish (~5ms), allowing each thread to handle ~15 charges per second instead of ~3. With 15 pods running 100 threads each (1,500 threads), the system sustains 100K+ RPS — a 200x improvement over the naive approach using the same thread model.
The idempotency guarantee is equally critical. Every charge request carries a client-supplied Idempotency-Key header. Before any processing begins, the ChargeService stores this key in Redis using SETNX (set-if-not-exists) — an atomic operation that succeeds only if the key does not already exist. If a merchant retries a timed-out charge, the SETNX fails because the key already exists, and the service returns the previous result. This guarantees at-most-once charging regardless of how many times the merchant retries. Critically, the idempotency key is stored BEFORE the ledger write — if the key were checked after processing, a retry during processing could create a second charge.
The saga pattern provides consistency guarantees across the distributed workflow. When a charge is created, the saga coordinates: idempotency check (Redis) then ledger write (PostgreSQL) then Kafka publish. If the card network declines the charge, the saga compensates: ledger status updates to 'failed', any merchant balance hold is released, and a payment_failed webhook is sent. Each step is independently retryable and idempotent. The saga guarantees that partial failures never leave the system in an inconsistent state — a charge is either fully processed or fully compensated.
This architecture is how Stripe, Adyen, and PayPal actually process payments. The idempotency-first design, async card network routing, and saga compensation are not theoretical — they are battle-tested patterns processing billions of dollars in daily transaction volume. Interviewers at payment companies expect candidates to arrive at this architecture as the natural evolution from the naive approach, and to articulate exactly why each component exists.
The saga-based payment system uses 10 components organized into a synchronous charge path (MerchantClient, ApiGateway, MainLB, ChargeService, IdempotencyCache, LedgerDB) and an asynchronous processing pipeline (PaymentStream/Kafka, CardNetworkWorker, SettlementWorker, WebhookWorker). The RefundService handles refund processing as a separate service.
The charge critical path begins at the MerchantClient, which sends a POST request with an Idempotency-Key header. The ApiGateway authenticates the merchant API key and enforces rate limits (120K RPS cap). MainLB distributes across ChargeService pods. ChargeService executes the synchronous portion: (1) check IdempotencyCache — Redis SETNX with 24-hour TTL, returning the cached result if the key exists; (2) write the charge record to LedgerDB with status 'pending'; (3) publish a charge_created event to PaymentStream (Kafka). The HTTP response returns immediately with payment_id and status 'pending'. This entire flow completes in under 200ms.
The asynchronous pipeline begins when CardNetworkWorker consumes the charge_created event from Kafka. It routes to the appropriate card network (Visa, Mastercard, Amex) based on the card BIN prefix. The external call takes 200-500ms. On success, CardNetworkWorker updates LedgerDB status to 'succeeded' and publishes a payment_result event. On failure, it updates to 'failed' and triggers saga compensation. WebhookWorker consumes payment_result events and delivers them to merchant webhook URLs with HMAC-SHA256 signatures and exponential backoff retry (up to 72 hours). SettlementWorker runs daily batch reconciliation matching charges against card network settlement files.
LedgerDB is the single source of truth — a PostgreSQL database sharded by merchant_id across 64 partitions. The sharding strategy ensures that most queries (which are scoped to a single merchant) hit a single shard. At 100K TPS across 64 shards, each shard handles approximately 1,500 TPS — well within PostgreSQL capacity. The ledger stores payments, refunds, and an append-only audit log retained for 7 years per financial regulations.
IdempotencyCache (Redis) is on the critical path of every charge. Redis SETNX completes in under 1ms, compared to a database check at 10-15ms. At 100K RPS, the Redis approach saves over 1 billion ms of aggregate latency per day. The 24-hour TTL on keys balances storage cost (8.6B keys/day at 100 bytes each is approximately 860GB at peak) against retry safety (most retries happen within seconds, but 24 hours covers edge cases).
The PaymentStream (Kafka) provides durable, ordered, at-least-once delivery. 64 partitions partitioned by merchant_id support 1M messages per second. Consumer groups allow CardNetworkWorker, SettlementWorker, and WebhookWorker to scale independently without interfering with each other. Kafka replay capability means a worker crash does not lose any payment events.
Choice
Redis SETNX before ledger write or Kafka publish
Rationale
Network timeouts cause merchant retries. If the idempotency key is checked after charge processing begins, a retry during processing could create a duplicate charge. By storing the key in Redis atomically (SETNX) before any processing, a retry finds the key and returns the existing result — even if the first request is still in-flight. This is Stripe's actual pattern for at-most-once charging.
Choice
Publish to Kafka, consume with dedicated workers
Rationale
Card network calls take 200-500ms and can timeout. Synchronous calls would push charge API latency to 500ms+ and create tight coupling with card network availability. Async via Kafka means the charge API returns in ~50ms with status 'pending'. The merchant polls or receives a webhook when the charge resolves. This is how Stripe and Adyen actually work.
Choice
PostgreSQL sharded across 64 partitions by merchant_id
Rationale
Payment queries are almost always scoped to a single merchant (list charges, check balance, generate statement). Sharding by merchant_id means most queries hit a single shard. At 100K TPS across 64 shards, each shard handles ~1,500 TPS — well within PostgreSQL capacity.
Choice
Each saga step has an idempotent compensation action
Rationale
If the card network declines after the ledger records the charge as 'pending', the saga compensates: status updates to 'failed', balance holds are released, and a payment_failed webhook is sent. Each compensation is independently retryable and idempotent, ensuring that partial failures never leave the system inconsistent.
Choice
ChargeService and RefundService as independent microservices
Rationale
Charges and refunds have different scaling profiles (100K vs 5K RPS), different failure modes (charge failure = do not charge; refund failure = must retry), and different compliance requirements. Separating them allows independent scaling, deployment, and PCI scope boundaries.
Choice
Redis SETNX for the idempotency check on every charge
Rationale
The idempotency check is on the critical path of every charge request. Redis SETNX completes in under 1ms while a database check would add 10-15ms. At 100K RPS, Redis saves over 1 billion ms of aggregate latency per day. The 24-hour TTL prevents unbounded key growth.
Target RPS
100K+ charges/sec
Latency (p99)
<200ms charge API (p99)
Storage
~50 TB/year (sharded ledger + Kafka)
Availability
99.9% (replicated components, saga compensation)
| Operation | Time | Space | Notes |
|---|---|---|---|
| Charge (POST /api/v1/payments/charge) | O(1) Redis SETNX + O(1) DB INSERT + O(1) Kafka publish | O(1) per payment + O(1) idempotency key | Total wall time ~70ms: Redis (1ms) + DB write (50ms) + Kafka (5ms) + processing (14ms). Card network call is fully async. |
| Card network authorization (async) | O(1) external API call (200-500ms) | O(1) status update in DB | Runs asynchronously in CardNetworkWorker. Does not affect charge API latency. 30 workers process in parallel. |
| Webhook delivery (async) | O(1) per delivery attempt, O(log R) with exponential backoff retries | O(R) retry state per failed delivery | At-least-once delivery. R = retry count (up to 72 hours). 15 workers handle parallel delivery. |
Authoritative financial ledger for all charges. Sharded across 64 partitions by merchant_id for single-shard query efficiency. Status lifecycle: pending -> succeeded | failed | refunded. Write-heavy on the charge path (50K inserts/sec at peak). Strong consistency with synchronous replication to 2 replicas.
Indexes: idx_payments_merchant ON (merchant_id, created_at DESC), idx_payments_status ON (status) WHERE status = 'pending'
64 shards x 2 replicas. Each shard handles ~1,500 TPS at peak. Partial index on 'pending' status for reconciliation job efficiency.
Negative ledger entries linked to original payments. Supports partial refunds. Idempotent when keyed by payment_id + refund amount.
Indexes: idx_refunds_payment ON (payment_id)
Write volume ~5K/sec at peak (10% of charge volume). Same shard as the parent payment.
Idempotency keys stored via SETNX with 24-hour TTL. Every charge checks this cache FIRST — if the key exists, the previous result is returned as an idempotent replay. 99%+ hit rate for retry deduplication.
At 100K TPS with 24hr TTL: ~8.6B keys/day. At ~100 bytes/key: ~860GB at full retention. Most keys can expire after 1 hour in practice.
| Variant | Tier | Latency | Throughput | Cost | Complexity | Reliability |
|---|---|---|---|---|---|---|
| Naive (Single Service + SQL) | T1 | 350ms-3s charges | ~450 RPS | $300/month (single DB + 3 pods) | Low — no cache, no workers, no Kafka | 99% (single DB, no failover) |
| Idempotent Ledger + Saga (Kafka) | T2 | <200ms charges (async card network) | 100K+ RPS | $5,000/month (Kafka + Redis + sharded DB) | Medium — Kafka, Redis, 3 worker types | 99.9% (replicated, saga compensation) |
| Multi-Region Distributed (Active-Active) | T3 | <300ms charges (fraud + tokenization) | 200K+ RPS | $20,000/month (multi-region, 12 components) | High — card vault, fraud ML, double-entry | 99.99% (multi-region, active-active) |
This template is for educational and illustration purposes only. It may not represent the optimal production design for this problem. Real-world systems involve additional considerations (compliance, specific cloud provider constraints, organizational requirements) not captured here. Use this as a starting point for discussion, not as a production blueprint.
Consider the failure scenario: the merchant sends a charge, the system starts processing, the card is charged successfully, but the HTTP response is lost. The merchant retries. If the idempotency key is only stored after successful processing, the retry arrives while the first request is still in-flight — both proceed, creating a double charge. By storing the key via Redis SETNX before any processing, the retry immediately finds the existing key and waits for or returns the original result. The order is: SETNX (atomic, <1ms) then process. This eliminates the race condition window entirely.
The saga has three stages: (1) idempotency check + key storage (Redis), (2) ledger write with status 'pending' (PostgreSQL), (3) Kafka publish for async card network routing. If the card network declines, CardNetworkWorker triggers compensation: LedgerDB status updates to 'failed', the idempotency cache is updated with the failed result, and a payment_failed webhook is sent to the merchant. Each compensation step is idempotent — if the worker crashes mid-compensation, it can safely retry without creating inconsistencies.
At 100K TPS peak, 64 shards means ~1,500 TPS per shard. PostgreSQL comfortably handles 2,000-3,000 TPS per instance, giving 30-50% headroom for burst traffic. 64 is also a power of 2, making consistent hash distribution uniform. Fewer shards (16) would put 6,250 TPS per shard — risky for PostgreSQL. More shards (256) would increase operational complexity without meaningful throughput gain. 64 balances capacity headroom against operational cost.
If the Kafka publish fails after the ledger write succeeds, the charge record exists in LedgerDB with status 'pending' but no event reaches CardNetworkWorker. A reconciliation job runs every 5 minutes, scanning for 'pending' charges older than 2 minutes without a corresponding Kafka event. It re-publishes these charges to Kafka. The idempotency layer ensures that even if a charge is processed twice by CardNetworkWorker, the card network call is deduplicated by the payment_id.
CardNetworkWorker (time-sensitive, 200-500ms per call), SettlementWorker (daily batch, minutes per run), and WebhookWorker (retry-heavy, 72-hour window) have fundamentally different processing characteristics. Combining them into one worker would cause webhook retries (which generate high volume during merchant outages) to starve card network processing (which is latency-critical). Separate Kafka consumer groups on the same topics provide workload isolation.
This template closely mirrors Stripe's publicly documented architecture: client-supplied idempotency keys stored before processing, async card network routing via a message queue, sharded ledger database, and webhook delivery with retry. The main simplifications are: single-region deployment (Stripe is multi-region), no fraud detection (Stripe runs Radar ML on every charge), and no PCI-compliant card vault (Stripe isolates card data in a dedicated service). The Distributed variant adds these production features.
Sign in to join the discussion.
Ready to design your own Payment System?
Open the simulator, place components on the canvas, wire them up, and run a traffic simulation to see how your architecture performs under real load.
Open Simulator