Case Study 9: Payment Gateway like Razorpay
A platform for merchants that accepts money by UPI, cards and netbanking, records it safely in a ledger and settles it to the merchant.
C.1 Requirements
Functional requirements:
- Merchant onboarding, API keys and webhook URL configuration.
- Create orders and show payment methods at checkout (UPI, cards, netbanking, wallets).
- Authorize/capture payments through the bank, card network or NPCI.
- Idempotency on every API: no double charge on a retry.
- Send webhooks for payment events to the merchant (payment.captured, refund.processed).
- Account for every rupee in a double-entry ledger.
- Merchant settlement (T+1/T+2) and reconciliation with bank files.
- Full and partial refunds.
Non-functional requirements:
- 99.99% availability of the payment API: downtime = the merchant's revenue loss.
- Payment create API p99 < 300 ms (bank latency is separate).
- Strong consistency in the ledger: debit = credit always, money must never vanish.
- PCI-DSS compliance: card data tokenized and encrypted, the vault isolated.
- Peak 10,000 TPS (sale days such as Big Billion Days).
- Data durability: retain financial records for 8-10 years (regulatory).
C.2 Estimation (back-of-the-envelope)
| Item | Number | How it is derived |
|---|---|---|
| Transactions per day | ~2 crore | Assumption for a large Indian gateway. |
| Average TPS | ~230 | 2 crore / 86,400. |
| Peak TPS | ~10,000 | Festival sales and the 8-11 PM peak are ~40x the average. |
| Ledger entries per day | ~8 crore | Each payment makes ~4 entries (customer, merchant, fees, GST). |
| Storage per day | ~60 GB | 2 crore payments x ~1 KB + 8 crore ledger rows x ~500 bytes. |
| Storage per year | ~22 TB | 60 GB x 365, replication extra. |
| Webhook deliveries per day | ~5 crore | 2-3 events per payment (authorized, captured, etc.). |
C.3 API design
| Endpoint | Purpose |
|---|---|
| POST /v1/orders | Create an order with amount, currency, receipt (Idempotency-Key) |
| POST /v1/payments | Initiate a payment from checkout: with method and order_id |
| POST /v1/payments/{id}/capture | Capture an authorized payment |
| GET /v1/payments/{id} | Fetch the payment status |
| POST /v1/payments/{id}/refunds | Full or partial refund (Idempotency-Key) |
| POST /internal/callbacks/{bank} | Async callback from the bank / NPCI / card network |
| GET /v1/settlements | The merchant's settlements and their breakup |
C.4 Data model
| Table / Store | Fields | Why |
|---|---|---|
| payments (PostgreSQL/MySQL, sharded by merchant_id) | payment_id PK, order_id, merchant_id, amount, method, status, gateway_ref, created_at | ACID state transitions; sharded by merchant_id for merchant-wise queries. |
| idempotency_keys (Redis + DB) | merchant_id + key PK, request_hash, response, status, expires_at | Return the same response for the same key; error on a request_hash mismatch. |
| ledger_entries (RDBMS, append-only) | entry_id, txn_id, account_id, debit, credit, currency, created_at | Double-entry: sum(debit) = sum(credit) for every txn, never updated/deleted. |
| refunds (RDBMS) | refund_id, payment_id, amount, status, bank_ref, created_at | Track multiple partial refunds of one payment. |
| webhook_deliveries (Cassandra) | event_id, merchant_id, payload, attempt_no, status, next_retry_at | High write volume, retry tracking. |
| card_vault (isolated, HSM-backed) | token, encrypted_pan, expiry, fingerprint | The PCI scope stays limited to this small vault. |
C.5 High-level architecture
- 1.Clients: Customer Checkout (UPI / card / netbanking, checkout JS from CDN) and Merchant Server (API key + secret)
- 2.WAF + DDoS -> Load Balancer -> API Gateway (auth, per-merchant rate limit)
- 3.Services: Order Service (POST /orders, Redis idempotency keys), Payment Service (state machine + idempotency), Refund Service (refund <= captured), Card Vault (token + HSM, PCI scope only here)
- 4.Payment Router (success rate + cost) -> Bank / NPCI / Card adapters -> NPCI / Bank (customer PIN, debit); the async callback comes back
- 5.PostgreSQL sharded by merchant_id (payments, refunds, outbox) -> outbox -> Kafka (payment.captured, refund.processed)
- 6.Consumers: Ledger Writer (double-entry rows) -> Ledger DB (append-only, debit = credit, 8 crore rows / day); Webhook Dispatcher (HMAC sign, backoff) -> merchant webhook, Cassandra webhook_deliveries; Settlement Engine (T+1 / T+2 payout) -> merchant bank a/c
- 7.Reconciliation Job: matches the bank / NPCI settlement files every night
Scale: 2 crore txn/day, peak 10k TPS, p99 < 300 ms.
- Customer checkout comes via the CDN and the merchant server comes with an API key; both pass through WAF + DDoS -> Load Balancer -> API Gateway (auth, per-merchant rate limit).
- Order / Payment / Refund services; the card number lives only in the isolated Card Vault (token + HSM), so the PCI scope is small.
- Payment Service runs the state machine + Idempotency Redis; the Payment Router picks a route by success rate and cost, and adapters talk to NPCI / the bank.
- Status goes into the PostgreSQL merchant_id shard + outbox -> Kafka; Ledger Writer, Webhook Dispatcher, Settlement and Reconciliation run async.
| Component | What it does |
|---|---|
| API Gateway | Auth with the merchant API key/secret, per-merchant rate limit, request logging. |
| Payment Service | Runs the payment state machine: created -> authorized -> captured / failed. Idempotency is enforced here. |
| Payment Router | Chooses the best acquirer/PSP by method, bank success rate and cost; a fallback route on failure. |
| Card Vault | Card numbers are tokenized and HSM-encrypted; the other services see only the token. |
| Ledger Writer | Consumes Kafka events and writes double-entry rows; append-only, immutable. |
| Webhook Dispatcher | Sends HMAC-signed webhooks to the merchant, retrying with exponential backoff for a few days. |
| Reconciliation Job | Matches bank/NPCI settlement files against internal records; an alert or auto-correction on a mismatch. |
C.6 Main flows
UPI payment flow:
- The merchant server does POST /orders with an Idempotency-Key and gets an order_id.
- The customer chooses UPI at checkout; POST /payments, status=CREATED.
- The Payment Router sends a collect request or intent to NPCI via the PSP bank.
- The customer enters the PIN in their UPI app; the bank debits.
- An async callback arrives from NPCI/PSP; verify the signature and dedup on gateway_ref.
- Payment status becomes CAPTURED (in a DB transaction) and a payment.captured event goes on Kafka.
- The Ledger Writer writes the entries: customer clearing debit, merchant payable credit, fee credit.
- The Webhook Dispatcher sends a signed webhook to the merchant; the merchant confirms the order.
Idempotent retry flow:
- The merchant's network times out and they retry the request with the same Idempotency-Key.
- The Payment Service looks up the key in Redis/DB.
- If the key is IN_PROGRESS, 409 or wait; if COMPLETED, return the stored response.
- If request_hash differs, a 422 error: the same key with a different body is not allowed.
- This way the customer's money is deducted only once.
Refund and reconciliation flow:
- The merchant does POST /payments/{id}/refunds with an amount.
- The Refund Service checks: total refunded <= captured amount.
- Reverse entries in the ledger; the amount is held from the merchant balance.
- A refund request to the bank/NPCI; an async status callback.
- At night the Reconciliation Job matches every txn against the bank file.
- On a mismatch (for example success at the bank, failed with us), an auto-update or an ops ticket.
C.7 Deep dives
- 1.1. Merchant -> POST /orders + Idem-Key -> order_id
- 2.2. Customer / UPI app -> POST /payments UPI -> CREATED
- 3.3. Payment Service: Router -> PSP: collect / intent
- 4.4. PSP / NPCI / Bank: UPI PIN -> bank debit
- 5.5. Callback: sign verify + dedup
- 6.6. CAPTURED + outbox in one DB txn
- 7.7. payment.captured -> Ledger
- 8.8. HMAC signed webhook -> merchant confirms the order
- 1.Merchant timeout -> retry with the same Idempotency-Key
- 2.Lookup in Redis / DB: merchant_id + key + request_hash
- 3.New key: save IN_PROGRESS -> process -> COMPLETED
- 4.IN_PROGRESS: 409 or wait; COMPLETED: return the stored response
- 5.request_hash differs: 422 error
A DB unique constraint is the final guard: money is deducted only once.
Double-entry ledger (a Rs 1000 payment: the merchant's share, fee and GST):
| Account | Debit | Credit |
|---|---|---|
| customer clearing | 1000.00 | - |
| merchant payable | - | 976.40 |
| gateway fee | - | 20.00 |
| GST on fee | - | 3.60 |
| sum | 1000.00 | 1000.00 |
The ledger is append-only: a mistake is fixed with a reversal entry, never an update / delete. Refund = reverse entries + a merchant balance hold. At night the Recon Job matches the bank / NPCI settlement file against payments + ledger: match OK, or on a mismatch an auto-fix / ticket.
Problem: double charge on retry / network timeout. A merchant-scoped Idempotency-Key on every write API, stored with the request hash. A DB unique constraint is the final guard. Bank callbacks are also deduped on gateway_ref.
Problem: the payment state is unknown (bank timeout). Keep the status PENDING, poll the bank status-check API, and treat the reconciliation file as the final truth. Show the customer pending, not a wrong failure.
Problem: ledger correctness. A double-entry, append-only ledger; sum(debit) = sum(credit) is checked inside the DB transaction for every transaction. A mistake is fixed with a reversal entry, never an update.
Problem: webhook delivery reliability. The outbox pattern: the payment update and the event insert in one DB transaction, then Kafka. Retries with backoff, an HMAC signature, and merchant-side dedup by event_id.
Problem: bank/acquirer downtime. The Payment Router tracks every bank's real-time success rate; a circuit breaker shuts the bad route and moves traffic to another acquirer.
Problem: PCI-DSS and security. Card data only in an isolated vault, with tokenization + HSM encryption. Network segmentation, TLS everywhere, secrets rotation, audit logs.
C.8 Trade-offs
| Decision | What was chosen and why |
|---|---|
| SQL vs NoSQL for payments/ledger | SQL (PostgreSQL/MySQL): ACID and strong consistency are non-negotiable for money. Cassandra for logs/webhooks. |
| Sync ledger write vs async via Kafka | Payment status is written sync in the DB, the ledger async via outbox + Kafka: low latency, though the ledger may lag a little, and reconciliation covers that. |
| Single acquirer vs smart routing | Smart routing: more complexity, but both success rate and cost are better. |
| Auto-capture vs manual capture | Auto-capture by default; a manual option for merchants who want to capture after delivery. |
C.9 Interview tips
- Draw the payment as a state machine, with every transition idempotent.
- Explain the full Idempotency-Key lifecycle: store, in-progress, completed, hash mismatch.
- Explain why the ledger is double-entry and append-only: this is a senior-level signal.
- Call reconciliation the final safety net: mismatches are bound to happen in distributed systems.
- Be sure to mention the outbox pattern + retry + signature for webhooks.