Designing a Payment Gateway
Design a payment gateway that routes charges to acquirers, guarantees idempotency, tokenizes cards and reconciles with banks reliably.
A payment gateway sits between merchants and card networks or banks. It accepts a charge request, validates it, routes it to an acquirer, and reports the result. The defining constraint is that money must never be moved twice or lost, even when the network lies about whether a request succeeded.
Requirements
Functional
- Authorize, capture, void and refund card and bank payments.
- Tokenize card data so merchants never touch raw numbers.
- Route across multiple acquirers with failover.
- Webhooks, daily settlement files and reconciliation.
Non-functional
- 99.99% availability; p99 under 1.5 s end to end, most of it in the acquirer.
- Exactly-once effect per idempotency key.
- PCI DSS scope limited to a small isolated vault.
- Sustain 2,000 transactions per second at peak.
Capacity Estimation
At 50M payments per day the average is about 580 per second. Peak at 3x gives 1,750, so design for 2,000. A payment record with its event history is roughly 5 KB, giving 250 GB per day uncompressed and about 90 TB per year. Archive records older than 13 months to cold storage but keep them queryable for disputes. Webhook delivery adds roughly 2 outbound calls per payment, so 4,000 per second at peak.
High-Level Design
Merchant -> Edge/WAF -> API Layer (auth, idempotency, rate limit)
|
Payment Orchestrator
/------------|--------------\
Token Vault Risk Engine Router
(HSM, isolated) |
Acquirer Adapters -> Card networks
|
Ledger + Event Log -> Webhook Dispatcher
|
Reconciliation Batch
The vault is a separate network zone holding encrypted PANs with keys in an HSM. Everything outside it handles only tokens, which keeps most of the system out of PCI scope.
Data Model
payments(payment_id, merchant_id, amount, currency, state, token, acquirer_ref, created_at)payment_events(event_id, payment_id, type, payload, created_at)as an append-only logidempotency_keys(merchant_id, key, request_hash, response, status, expires_at)ledger_entries(entry_id, payment_id, account, debit, credit)settlements(batch_id, acquirer, file_ref, state)
State machine: created, authorized, captured, settled, with side branches voided, refunded, failed. Transitions are validated in code and enforced with a compare-and-set on the state column.
Deep Dives
Idempotency
Every mutating call needs a key from the merchant. Insert the key row first with status in-progress, keyed on merchant and key. A concurrent duplicate hits the unique constraint and either waits or receives a conflict response. Store a hash of the request body; a reused key with a different body is rejected. After completion the stored response is replayed for 24 hours. The same key, namespaced, goes to the acquirer where supported.
Consistency with the acquirer
The hard case is a timeout after sending an authorization. The outcome is unknown. Persist an intent record before the call, then on timeout set state to unknown and schedule inquiry calls by reference. If the acquirer supports it, send a reversal for unresolved authorizations. Never retry a capture blindly without the acquirer's idempotency reference.
Concurrency
Captures and refunds race against each other. Use optimistic locking on the payment row and enforce invariants: captured amount cannot exceed authorized, refunded amount cannot exceed captured. Partition by payment_id so all events for one payment are processed in order by a single consumer.
Routing
The router picks an acquirer by cost, success rate over a sliding window, and card type. A circuit breaker per acquirer trips at, say, 20% error rate over 30 seconds and shifts traffic. Retrying on a second acquirer is safe only for declines that never reached the network, so classify error codes carefully.
Failure Modes and Trade-offs
- Acquirer outage: failover via routing, accepting higher fees. Fail with a clear retryable error if none are healthy.
- Duplicate webhook delivery: at-least-once with signed payloads and event ids; merchants must deduplicate.
- Reconciliation mismatch: nightly comparison of the ledger against settlement files; unmatched items open investigation tickets rather than being auto-corrected.
- Vault compromise risk: isolate, minimize access, rotate keys, use format-preserving tokens that reveal only the last four digits.
- Sync versus async: synchronous responses are simpler for merchants but couple latency to acquirers; offering an async mode with webhooks absorbs slow banks.
- Strong consistency cost: a single-region primary for ledger writes limits latency and availability, but multi-region active-active would risk double-spend without careful per-payment ownership.
Key takeaways
- Idempotency keys with request hashing are the core safety mechanism for money movement.
- Persist intent before calling external systems and treat timeouts as an unknown state to be resolved by inquiry.
- Isolate card data in a tokenizing vault to shrink compliance scope.
- Model payments as a validated state machine with an append-only event log.
- Route with circuit breakers, but retry on another acquirer only when the first provably did not process.
- Reconciliation against settlement files is a required feature, not an afterthought.