•CASE STUDY

Payment Processing System (Stripe-style)

7 min read·1,253 words·Advanced

Asked at

18 candidate reports between Dec 2025 and Aug 2026

How to use this case study

SDE-2 / Mid

  • Explain the payment states (authorized, captured, refunded)
  • The idempotency key
  • 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
  • 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)

Problem RestatementProblem

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.

RequirementsRequirements

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/paymentswith 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.

High-Level ArchitectureArchitecture

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
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
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"]

Data ModelData 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);

Key FlowsFlows

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.

Deep Dive A — The call to the bank timed outDeep dive

We asked the bank to authorize a card and the call timed out. The charge may have gone through, or it may not. This one unknown is where most payment bugs live.

Weak

Retry the charge

The call failed, so send it again. It is what every HTTP client does by default, and it is the answer that ends the interview badly.

Sequence diagram
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
sequenceDiagram
  participant P as Payment service
  participant B as Bank
  P->>B: authorize $200
  B-->>B: approves, holds $200
  B--xP: response lost - timeout
  P->>B: authorize $200 again
  B-->>B: approves, holds $200 again
  Note over B: customer is down $400

The bank saw two unrelated requests and honoured both. The customer is charged twice, and we find out from a support ticket.

Good

Treat the timeout as a failure

Mark the payment failed and let the customer try again. No double charge, which feels safer.

But if the bank did approve, the money is now held against a payment we have written off. The hold sits on the customer's card for days, our ledger disagrees with the bank's settlement file, and reconciliation has to chase every one of these by hand. We have swapped a visible bug for a silent one.

Best

Same idempotency key, then ask until you know

Never guess the outcome. Record the attempt before the call, and give the provider a key that identifies this attempt:

Sequence diagram
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
sequenceDiagram
  participant P as Payment service
  participant B as Bank
  P->>P: write payment - status authorizing, key pay_7f2
  P->>B: authorize $200 - Idempotency-Key pay_7f2
  B--xP: timeout
  P->>B: authorize $200 - Idempotency-Key pay_7f2
  B-->>P: same result as before - approved, auth_id 91
  P->>P: status authorized

The provider recognises the key and replays the original result instead of charging again. If it stays unreachable, the payment holds at authorizing and a background job polls "what happened to pay_7f2?" until there is an answer. The merchant sees "processing" the whole time — an honest unknown is better than a wrong answer in either direction.

This is why the state is written before the external call, and why every outbound call carries a key.

Deep Dive B — Ledger, outbox and reconciliationDeep dive

  • 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.

Trade-offs & AlternativesTrade-offs

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

Common Follow-up QuestionsFollow-ups

  • "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.

Wrap-UpWrap-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

Frequently Asked Questions

What is the Payment Processing System (Stripe-style) system design question?

Payment Processing System (Stripe-style) is a system design interview question asked at FAANG companies. It covers payments, fintech, distributed systems, event driven and tests your ability to design scalable, production-ready systems. InterviewSkool's breakdown walks you through requirements, API design, architecture, and trade-offs.

Which companies ask the Payment Processing System (Stripe-style) question?

JPMorgan, OpenAI, Rippling, Salesforce, Visa have reportedly asked variations of this question in system design interviews. The exact wording may differ, but the core design challenges remain the same.

How should I prepare for the Payment Processing System (Stripe-style) interview question?

Start with the problem statement and scale estimates, then design the high-level architecture. Focus on the core components, data model, and API design. InterviewSkool's breakdown covers the full solution with mermaid diagrams and trade-off analysis to help you prep efficiently.

What level is the Payment Processing System (Stripe-style) question?

This question is suitable for SDE-2, SDE-3, and Staff engineer interviews. The level guidance on this page provides specific tips for each level — SDE-2 candidates should focus on core architecture, while Staff engineers should discuss trade-offs, monitoring, and incremental rollouts.

Practice with a Mock Interview

Apply what you learned in a live system design mock interview with InterviewSkool's AI interviewer.

Start System Design Interview →