CASE STUDY

Payment Processing System (Stripe-style)

6 min read·1,100 words·Advanced

How to use this case study

SDE-2 / Mid

Explain the payment states (authorized, captured, refunded), the idempotency key, and how the service talks to an external payment provider.

SDE-3 / Senior

Go deeper on unknown outcomes and timeouts, the double-entry ledger, the outbox pattern, retries without double charging, and reconciliation with provider reports.

Staff / Principal

Discuss 10K TPS scale, multiple providers and routing, settlement batches, multi-region consistency for money, auditing and compliance (PCI).


0) Problem Restatement

Design a system that lets merchants charge customers through external payment providers (card networks, banks, PayPal). A payment goes through several steps:

  • Authorize: ask the card's bank to hold the money (e.g., a hotel holds $200).
  • Capture: actually take the held money (maybe days later, when the order ships).
  • Refund: give money back.
  • Settle: the provider moves the real money to the merchant's bank, usually in daily batches.

The top requirement is correctness: never charge a customer twice, and never lose track of money, even when networks time out and servers crash. Target scale is about 10,000 transactions per second.

Asked at: JPMorgan, OpenAI, Rippling, Salesforce, Visa — 18 candidate reports between Dec 2025 and Aug 2026.

1) Requirements

1.1 Functional

  • Create a payment, authorize, capture (full or partial), cancel and refund.
  • Get payment status.
  • Notify merchants of status changes (webhooks).
  • Reconcile our records with provider reports every day.

1.2 Non-Functional

  • Exactly-once effect: retries must not create double charges.
  • Durability and auditability: every change recorded, nothing deleted.
  • High availability for authorization, which sits in the checkout path.
  • Security: card numbers never stored in plain text (PCI rules).

1.3 Scale Estimates

  • 10K transactions/sec at peak, about 200M per day.
  • Each payment makes ~5 ledger entries plus state changes → ~50K DB writes/sec. That calls for sharding by merchant or payment ID.
  • Provider calls take 200 ms–2 s, and some time out, so we must handle "unknown" results.

1.4 API Design

  • POST /v1/payments with header Idempotency-Key: 8f1c... and body { amount: 5000, currency: "USD", payment_method_token, capture: "manual" }{ payment_id, status: "authorized" }
  • POST /v1/payments/{id}/capture { amount }
  • POST /v1/payments/{id}/refunds { amount }
  • GET /v1/payments/{id}
Amounts are in the smallest unit (cents) as integers. Never use floating point for money.


2) High-Level Architecture

2.1 Overview

  • Payment API: checks the idempotency key and validates the request.
  • Payment Service: runs the payment state machine (a fixed set of states and allowed moves between them).
  • Tokenization vault: stores card numbers securely and returns tokens. The rest of the system only sees tokens.
  • Provider adapters: one per provider (Visa/Mastercard acquirer, PayPal), with retries and timeouts.
  • Ledger: a double-entry record of all money movement.
  • Outbox + Kafka: reliably publishes events (for webhooks, analytics, settlement).
  • Reconciliation jobs: compare our records with provider settlement files.

2.2 Architecture Diagram

Architecture Diagram

flowchart LR
    M["Merchant"] --> API["Payment API - idempotency check"]
    API --> PS["Payment Service - state machine"]
    PS --> DB[("Payments DB + Outbox")]
    PS --> V["Token Vault"]
    PS --> PA["Provider Adapters"]
    PA --> EXT["Card networks / banks"]
    PS --> L[("Ledger - double entry")]
    DB -->|"outbox relay"| K[("Kafka")]
    K --> WH["Webhook Sender"]
    K --> ST["Settlement jobs"]
    ST --> REC["Reconciliation vs provider files"]

3) Data Model

CREATE TABLE payments (
  payment_id      UUID PRIMARY KEY,
  merchant_id     UUID,
  amount          BIGINT,          -- in cents
  currency        CHAR(3),
  status          TEXT,            -- created, authorizing, authorized, capturing, captured, failed, refunded
  provider        TEXT,
  provider_ref    TEXT,            -- the provider's ID for this payment
  version         INT
);
CREATE TABLE idempotency_keys (
  merchant_id UUID, key TEXT, request_hash TEXT, response JSONB, created_at TIMESTAMP,
  PRIMARY KEY (merchant_id, key)
);
CREATE TABLE ledger_entries (       -- never updated, only inserted
  entry_id UUID, payment_id UUID, account TEXT, direction TEXT,  -- debit / credit
  amount BIGINT, created_at TIMESTAMP
);
CREATE TABLE outbox (event_id UUID, payload JSONB, published BOOLEAN);

4) Key Flows

4.1 Authorize with idempotency

  1. The merchant sends POST /payments with an Idempotency-Key.
  2. We insert the key into idempotency_keys. If it already exists, we return the saved response. The client retried, so we must not charge again.
  3. We create the payment in state authorizing before calling the provider. That way, if we crash, we know a call may have been made.
  4. We call the provider, passing our payment_id as their idempotency key too.
  5. On success, set authorized, write ledger entries, and in the same DB transaction write an outbox event. Save the response under the idempotency key.

4.2 Capture and settle

Capture moves the payment authorized → captured. Every night, the provider sends a settlement file. A job matches each line to our payments and writes ledger entries for fees and payouts.


5) Deep Dive A — The timeout problem ("unknown outcome")

We call the bank, and the call times out. Did the charge happen? We don't know. We must never just retry blindly with a new request, and never assume it failed.

  • Keep the payment in authorizing (unknown).
  • Retry with the same provider idempotency key. The provider returns the original result if it already processed it.
  • If the provider stays unreachable, a background job keeps asking "what's the status of payment_id X?" until it gets an answer.
  • The merchant sees "processing" until we are sure.

This is why every external call carries an idempotency key, and why we record state before the call.


6) Deep Dive B — Ledger, outbox and reconciliation

  • Double-entry ledger: every movement is written as two entries that add to zero. For example, debit "customer_receivable" $50 and credit "merchant_payable" $50. If the books don't balance, there's a bug. Entries are append-only, so we never edit history, and corrections are new entries.
  • Outbox pattern: writing to the DB and publishing to Kafka are two separate systems, so one can succeed while the other fails. Instead, write the event into an outbox table in the same transaction as the state change. A relay process reads the outbox and publishes to Kafka. Events are never lost, and duplicates are handled by consumers using event IDs.
  • Reconciliation: every day, compare (1) our ledger, (2) provider settlement files, and (3) bank deposits. Any mismatch (a missing capture, a wrong fee) goes to a review queue.
  • Sharding: shard payments and ledger by merchant_id so a payment's records live together and transactions stay on one shard.


7) Trade-offs & Alternatives

DecisionChoiceWhyAlternative
ConsistencyStrong (SQL, per-shard transactions)Money must be exactEventual NoSQL: faster, risky for balances
Duplicate protectionIdempotency keys at API and providerSafe retries everywhereDedup by amount/time: unreliable
EventsTransactional outboxNo lost or phantom eventsDual writes: can diverge
Multi-step flowsState machine + saga stepsClear recovery after crashesDistributed 2-phase commit: providers don't support it

8) Common Follow-up Questions

  • "How do you add a second provider?" Add an adapter and a router that picks a provider by cost, success rate or card type. Fail over to another provider only when you're sure the first attempt did not succeed.
  • "Offline merchants that send payments later?" Accept batches with client-side IDs as idempotency keys, process them asynchronously, and reconcile the results.
  • "How do you keep card data safe?" Keep card numbers only in a separate, locked-down vault (small PCI scope), and pass tokens everywhere else.


9) Wrap-Up

Use a state machine that records "in progress" before calling providers, idempotency keys at every step, a double-entry append-only ledger, and a transactional outbox for events. Treat timeouts as "unknown" and resolve them by querying the provider, and reconcile daily with provider files so every cent is accounted for.

More Case Studies

Practice with a Mock Interview

Apply what you learned in a live system design mock interview with our AI interviewer.

Start System Design Interview →