Hirestack
Hirestack
Worked Example 15.13 · 13 of 15

Design a payment system (Stripe-class)

📖 5 min read · 11 sections · May 2026
Asked at: Stripe PayPal Razorpay Square

The setup

"Build a payment processing system. APIs for charges, refunds, and transfers. 1000 transactions per second; never double-charge a customer; survive failures gracefully. Money is involved — correctness is non-negotiable."

This is the canonical case where correctness dominates everything else. Performance, throughput, even availability are secondary. A payment system that loses one $5 transaction is broken; one that handles 10× the spec but allows occasional double-charges is unshippable.

Clarifying questions

QuestionStory answer
What payment methods?Credit cards (Visa, Mastercard, Amex via card networks).
Refunds, partial refunds?Full + partial refunds.
Multi-currency?Yes — USD, EUR, INR.
Reconciliation with banks?Daily settlement files from banks; must reconcile.
Compliance — PCI-DSS, etc.?Yes. Assume infrastructure is PCI-compliant (separate concern).

The non-negotiables

  1. Idempotency: a retry must not double-charge. Required by every payments API.
  2. Auditability: every state change traceable. Required by financial regulators.
  3. Reconcilability: every charge must reconcile with the bank's view. Required for accounting.
  4. Atomicity at business level: a "purchase" creates a charge AND deducts inventory AND ships. These don't atomic-DB-commit together but must be logically atomic via sagas.

Design Decision 1: the ledger

At the core of every serious payment system is the ledger — an append-only record of every transaction. Stripe, banks, accounting systems all use double-entry accounting principles:

ledger_entries (append-only):
  id, transaction_id, account_id, amount, side (debit/credit),
  currency, created_at, idempotency_key, related_transaction_id

Constraint: for any transaction_id,
  SUM(amount where side=debit) = SUM(amount where side=credit)

Every transaction has both a debit and a credit. Money doesn't appear or
disappear — it moves between accounts. This is foundational.

A charge: debit customer's payment source account, credit merchant's account. A refund: opposite direction.

The ledger is append-only. You never update or delete a row. Corrections are new transactions that net against earlier ones.

Design Decision 2: idempotency

Client sends a charge with an Idempotency-Key header (UUID generated client-side). Server:

BEGIN TRANSACTION;

  -- Atomic claim-or-fetch
  INSERT INTO idempotency_keys (
    key, request_hash, status, response, created_at, expires_at
  )
  VALUES ($key, $hash, 'pending', null, now(), now() + interval '24h')
  ON CONFLICT (key) DO UPDATE SET key = idempotency_keys.key
  RETURNING *;

  -- If status='complete': return stored response (replay).
  -- If status='pending' and request_hash matches: another request is processing.
  --   Either wait/poll or return 409 conflict.
  -- If new row created: we own this request; process it.

COMMIT;

-- Process the charge (call card network, etc.)
result = call_card_network(amount, source, merchant);

BEGIN TRANSACTION;
  -- Write ledger entries atomically
  INSERT INTO ledger_entries (transaction_id, ...) VALUES (...);  -- debit
  INSERT INTO ledger_entries (transaction_id, ...) VALUES (...);  -- credit
  -- Update idempotency key
  UPDATE idempotency_keys SET status='complete', response=$result WHERE key=$key;
COMMIT;

RETURN result;

Retries with the same key return the same response without re-charging.

Design Decision 3: handling external state — the card network

Interviewer: You call Visa to charge. What if your server crashes mid-call?
The hardest part of payment systems. Three scenarios after crash:
  • You never called Visa: safe, just retry.
  • You called Visa but they never received: safe, retry.
  • You called Visa, they processed, but you never got the response: danger. Naive retry double-charges.
Solution: pass your idempotency key through to Visa. Visa recognises the same key as the same charge; returns the previous result. Same idempotency contract, propagated upstream. Most payment networks support this; if not, you maintain a "in-flight transactions" log and reconcile post-crash by querying Visa for the transaction status.

Design Decision 4: reconciliation

At end of each day, you receive settlement files from each bank — what they actually paid out, what your customers actually charged. The ledger must match.

DiscrepancyCauseAction
Charge in ledger but not bank filePending settlement; bank delayWait for next day's file
Charge in bank file but not ledgerBug: ledger update failed; ours is staleInvestigation; ledger correction (append correcting entries)
Amount mismatchCurrency conversion or fee discrepancyCompare exchange rate, fees; reconcile
Chargeback / refund not in ledgerExternal event we didn't know aboutAppend correction; investigate notification gap

Reconciliation runs nightly. Mismatches generate alerts; humans investigate. Mismatch rate < 0.001% is healthy; rising mismatch rate is a critical alarm.

The architecture

sequenceDiagram
    participant C as Client
    participant PS as Payment Service
    participant L as Ledger DB
    participant PG as Payment Gateway
    participant B as Bank / Card network

    C->>PS: POST /pay { idempotency_key=K, amount }
    PS->>L: lookup K
    alt already processed
        L-->>PS: cached result
        PS-->>C: same response (idempotent)
    else first time
        PS->>L: insert PENDING with K
        PS->>PG: authorize + capture
        PG->>B: charge
        B-->>PG: success / auth_id
        PG-->>PS: success
        PS->>L: UPDATE to SETTLED + auth_id
        PS-->>C: 200 OK
    end
Client ──API call──► [Charge Service]
                          │
                          ├──► [Idempotency check] (Postgres)
                          │
                          ├──► [Card network adapter] (Visa/Mastercard/etc.)
                          │
                          ├──► [Ledger writer] (Postgres / immutable store)
                          │
                          └──► Return result

Background:
  [Settlement reconciliation job] reads daily bank files,
  compares to ledger, alerts on discrepancies

  [Webhook dispatcher] sends payment events to merchant webhooks
  (signed, idempotent, retried)

Cross-examination round 1: failure modes

Interviewer: Server processes charge, writes ledger entries, but webhook to merchant fails. Walk through.
Webhook delivery is separate from charge atomicity. After ledger commit, an event is published to a webhook queue. Webhook worker delivers with exponential backoff retries. If merchant's endpoint is down: retry for up to 72 hours. Eventually: mark as failed; merchant fetches via API. The charge itself succeeded; only the notification is delayed.

Cross-examination round 2: distributed atomicity

Interviewer: A purchase is: (1) charge customer, (2) deduct inventory, (3) ship. These touch different services. Atomic?
Not strictly atomic — these are separate services with separate datastores. Saga pattern: orchestrator runs sequence, each step has a compensating action.
  • Step 1: charge → on later failure: refund
  • Step 2: deduct inventory → on later failure: restock
  • Step 3: create shipment → on later failure: cancel
Intermediate states are visible (you've been charged but inventory not yet deducted). Compensation runs if any step fails. Stripe, Shopify, every e-commerce platform uses sagas. The temptation to use 2PC (XA transactions across services) is real but operationally fragile.

Takeaway

Payment systems are the canonical case where correctness dominates everything else. Idempotency, double-entry accounting, reconciliation aren't optional — they're the system's reason for being. The architectural lesson: when money is at stake, make every state transition idempotent and every record append-only. Mutability is the enemy.

The deeper insight: external systems aren't transactional. The card network can't participate in your DB transaction. You must design for the case where you've sent the request but don't know if it succeeded. Idempotency keys propagated all the way through are the only safe pattern.