Skip to content
C Codeloom
System Design

System Design: Design a Payment System (Stripe-like)

Design a payment system like Stripe. Covers ledgers, idempotency, double-entry accounting, PCI scope, retries, and surviving downstream processor failures without double-charging.

·6 min read · By Yash Kesharwani
Intermediate 12 min read

What you'll learn

  • Model money with double-entry bookkeeping
  • Make every write idempotent
  • Talk to card networks without double-charging
  • Minimize PCI scope with tokenization
  • Reconcile with processors safely

Prerequisites

  • Familiarity with relational databases and transactions.
  • Comfort with REST APIs. See [REST API Design Best Practices](/blog/rest-api-design-best-practices).

A payment system has the lowest tolerance for bugs of any system you will design. A double charge is a refund, a legal complaint, and a churned merchant. The architecture is shaped by two ideas: every operation is idempotent, and money lives in a double-entry ledger that is the source of truth.

Functional Requirements

  • Charge a card for an amount in a given currency.
  • Refund a charge fully or partially.
  • Store cards securely for repeat use.
  • Pay out balances to merchants.
  • Provide a ledger view per merchant.
  • Webhooks for charge, refund, dispute events.

Non-Functional Requirements

  • Throughput: 10k charges per second peak.
  • Latency: p99 under 2 seconds (bounded by card network).
  • Durability: zero acked transactions lost.
  • Correctness: zero double-charges, zero phantom credits.
  • Availability: 99.99 percent for charge API.
  • Compliance: PCI DSS, SCA (3DS) where required.

High-Level Architecture

  • API gateway: terminates TLS, authenticates merchants by API key, enforces idempotency keys.
  • Payment service: orchestrates charge flow, talks to processors, writes to ledger.
  • Vault: stores card data, returns tokens. Only this component is in PCI scope.
  • Ledger service: append-only double-entry bookkeeping.
  • Processor adapters: per-network integrations (Visa, Mastercard, ACH, regional networks).
  • Webhook service: delivers events to merchants with retries.
  • Reconciliation: nightly job that matches our ledger to processor reports.

Data Model

CREATE TABLE charges (
  charge_id        UUID PRIMARY KEY,
  merchant_id      BIGINT NOT NULL,
  amount_minor     BIGINT NOT NULL,
  currency         CHAR(3) NOT NULL,
  status           VARCHAR(16) NOT NULL,
  idempotency_key  TEXT NOT NULL,
  processor_id     TEXT,
  created_at       TIMESTAMP NOT NULL,
  UNIQUE (merchant_id, idempotency_key)
);

CREATE TABLE ledger_entries (
  entry_id      BIGSERIAL PRIMARY KEY,
  txn_id        UUID NOT NULL,
  account_id    BIGINT NOT NULL,
  amount_minor  BIGINT NOT NULL,
  direction     CHAR(1) NOT NULL,
  currency      CHAR(3) NOT NULL,
  created_at    TIMESTAMP NOT NULL
);

Every transaction writes at least two ledger entries that sum to zero. Money never appears from nothing; it moves between accounts.

Card data is never stored in these tables. The vault returns a token like tok_xyz that maps internally to the encrypted PAN.

Key APIs

POST /v1/charges
  headers: Authorization, Idempotency-Key
  body: amount, currency, source (token), description
  returns: charge_id, status

POST /v1/refunds
  headers: Authorization, Idempotency-Key
  body: charge_id, amount (optional)
  returns: refund_id, status

GET  /v1/charges/:id
GET  /v1/balance
POST /v1/payouts

Idempotency keys are mandatory on every write. If a key has been seen before, return the stored response. This is the single most important rule. See REST API Design Best Practices for the broader pattern.

Idempotency

The flow for a charge:

  1. Client sends POST /v1/charges with an Idempotency-Key header.
  2. Payment service inserts a row into an idempotency table with the key and a status of in_progress. If insert fails on conflict, the request is a retry — return the prior result or 409 if still processing.
  3. Execute the charge.
  4. Update the idempotency row with the final response and status.
  5. Return.

The unique constraint on (merchant_id, idempotency_key) plus the in_progress state guard makes concurrent retries safe.

Talking to Card Networks

The processor call is the dangerous part. Possible outcomes:

  • 200 OK approved.
  • 200 OK declined.
  • Timeout — we do not know if the network charged the card.
  • 5xx — same uncertainty.

For unknowns, do not retry blindly. Either:

  • Submit with a network-level idempotency key (some processors support this).
  • Poll the processor for the status of the original request before retrying.

Mark the charge requires_action and let a reconciliation worker resolve it from the processor’s authoritative report.

Double-Entry Ledger

Every movement is two entries: debit one account, credit another. A successful charge writes:

credit merchant_pending  +1000
debit  customer_card     +1000

Refunds reverse. Payouts move from merchant_pending to merchant_payout and then to the bank.

Sharded by merchant_id. Entries are append-only. Balances are derived as the sum of entries for an account — for performance, snapshot the balance every N entries and sum forward from the snapshot.

See SQL Indexes and Performance for the indexing patterns that make these aggregations fast.

Scaling and Tradeoffs

Strong consistency on the write path. Use a transactional database. The ledger cannot be eventually consistent — you cannot have a moment where money exists in two accounts at once.

Webhooks. Push events to a queue, deliver with exponential backoff, retain for days. Merchants must treat webhooks as at-least-once and dedupe by event id.

PCI scope. Only the vault touches raw card data, and it lives in an isolated network segment with separate access controls. Everything else handles tokens. This drastically reduces the audit surface.

Reconciliation. Each night, ingest the processor’s settlement report and match every line to a ledger entry. Mismatches are alerts. This catches double-charges, lost charges, and processor bugs.

3D Secure. For SCA jurisdictions, route the user through a 3DS challenge. The charge moves through states: requires_action to processing to succeeded. The API surfaces redirect URLs to the merchant.

Multi-region. Run the charge API in multiple regions, but pin a merchant’s writes to a home region to keep the ledger consistent. Cross-region failover is rare and manual.

Disputes and chargebacks. Long-tail workflow with its own state machine. Reserve part of the merchant’s balance to cover potential clawbacks.

What to Say in an Interview

  • Lead with idempotency. Every write takes an idempotency key. This is the single most important sentence.
  • Use double-entry bookkeeping for the ledger. Mention that balances are derived, not stored as a mutable cell.
  • Discuss processor timeouts honestly — what happens when you do not know if the card was charged. Reconciliation is the answer.
  • Scope PCI to one component. Show you understand the compliance cost.
  • Mention webhook retries and merchant-side dedup. It is a small detail that demonstrates real product thinking.

Wrap up

A payment system is an idempotent API on top of a double-entry ledger with a paranoid reconciliation loop. Strong consistency on writes, append-only ledger entries, tokenized cards, and a clear story for processor timeouts. Get those right and the rest of the system — webhooks, payouts, disputes — is a pile of state machines you can implement at your own pace.