Courses › System Design › Question Breakdowns

Design Cash App (P2P Payments)

Atomic ledger transfers without double spending, synchronous fraud checks, external rails and settlement holds.

Design a peer-to-peer payments app like Cash App or Venmo: users hold a balance, send money to friends instantly by username or phone number, add money from a bank card, and cash out to their bank. Unlike a Payment System for a marketplace, we hold the money: every transfer moves funds between our own accounts. It's a question about an internal ledger, atomic transfers without double spending, idempotency, fraud checks in the hot path, and moving money in and out through slow external rails.

Requirements

Functional

  • Users have a balance; they can send money to another user, which arrives instantly
  • Users can add money from a linked debit card or bank account, and cash out to their bank (standard or instant)
  • Users see a transaction history and notifications for incoming money
  • Users can request money from others

Out of scope: cards and merchant payments, investing, crypto, credit.

Non-functional

  • Correctness above all: no money created or destroyed; a balance can never go negative; no transfer applied twice
  • Fast: a P2P send completes in < 1 second
  • Scale: ~50M monthly users, ~10M transfers/day (~500/s peak, more on Fridays and holidays)
  • Fraud-resistant: account takeover and scams must be caught before money leaves
  • Auditable for regulators

[!TIP] An internal transfer is a database transaction When both users' money lives in our ledger, "send $20" is just two ledger entries moving value between accounts in one ACID transaction. External money movement (card, ACH, bank payouts) is slow and uncertain; keep it at the edges, as its own state machine. (See Keeping Money Correct.)

Capacity estimates

Core Entities

  • User — identity, KYC status, linked funding sources
  • Account — user wallet balance (plus system accounts: card funding clearing, bank payouts in transit, fees)
  • Transfer — sender, recipient, amount, status, idempotency key, risk decision
  • Ledger entry — transfer, account, amount (+/−), created at — immutable
  • External transaction — card charge / ACH / payout, status, processor reference

API

POST /v1/transfers   Idempotency-Key: 2b7e...
{ "to": "$alex", "amount": 2000, "currency": "USD", "note": "pizza 🍕" }
→ 201 { "transferId": "tr_7", "status": "completed" }

POST /v1/deposits    { "fundingSourceId": "card_1", "amount": 5000 }
POST /v1/cashouts    { "bankAccountId": "ba_2", "amount": 10000, "speed": "instant" }
GET  /v1/activity?cursor=...

High-Level Design

app = client "Mobile App" @0,1
api = service "Transfer API" @1,1
risk = service "Risk Engine" @1,0
ledger = store "Ledger DB\n(accounts + entries)" @2,1
events = queue "Ledger Events\n(outbox)" @3,1
notify = service "Notifications" @4,0
feed = store "Activity Feed" @4,1
rails = service "Funding & Payout\n(card, ACH, instant)" @2,2
bank = client "Banks / Card Networks" @3,2
app -> api : send $20
api -> risk : score
api -> ledger : atomic transfer
ledger --> events
events -> notify
events -> feed
app -> rails : add money / cash out
rails -> ledger
rails -> bank
  • Send: the transfer API checks the idempotency key, asks the risk engine to score the transfer, then executes the debit and credit in one ledger transaction.
  • Events: the same transaction writes an outbox event; consumers update the activity feed and send push notifications ("Alex sent you $20").
  • Edges: the funding/payout service moves money in (card charge, ACH pull) and out (ACH or instant payout via card networks), recording each step in the ledger.

Deep Dives

1. The transfer transaction

BEGIN;
UPDATE accounts SET balance = balance - 2000
  WHERE id = :sender AND balance >= 2000;          -- 0 rows → insufficient funds, ROLLBACK
UPDATE accounts SET balance = balance + 2000 WHERE id = :recipient;
INSERT INTO ledger_entries (transfer_id, account_id, amount) VALUES (:t, :sender, -2000), (:t, :recipient, 2000);
INSERT INTO transfers (id, idempotency_key, status, ...) VALUES (:t, :key, 'completed', ...);
INSERT INTO outbox (...) VALUES (...);
COMMIT;
  • The conditional debit makes double spending impossible: two concurrent sends of the last $20 — only one finds balance >= 2000.
  • The unique idempotency key in the same transaction makes retries safe: a retried request finds the existing transfer and returns it.
  • Entries are immutable; balances are a cached sum checked by a periodic job (sum of entries must equal the balance).

2. Scaling the ledger

One Postgres primary handles thousands of transfers/second, which covers this scale. Beyond that, shard accounts by user ID. A transfer between users on different shards is no longer one local transaction:

  • Debit the sender on shard A and record a pending outgoing entry; credit the recipient on shard B; both keyed by transfer ID (idempotent). A coordinator (saga) drives the steps and retries; a recovery job completes or reverses stuck transfers.
  • Users see the money instantly once the debit and credit are both applied, typically milliseconds apart.

[!WARNING] Hot accounts System accounts (fees, card funding clearing) are touched by every transaction and become a lock hotspot. Split them into many sub-accounts, or post to them asynchronously in batches.

3. Fraud in the hot path

Money sent P2P is gone instantly, so the risk check is synchronous, within a ~100ms budget:

  • Features: device and location change, new recipient, amount vs. history, account age, velocity (transfers in the last hour), recipient's risk.
  • Decisions: allow, step up (PIN, biometric, confirm recipient), hold for review, or block.
  • Rules for fast changes (block a known scam pattern now) + an ML model for scoring; decisions are logged for retraining.

4. Adding money and cashing out

External rails are slow, uncertain and reversible:

Each external transaction is its own state machine with ledger entries at each step (pending → settled or returned). ACH deposits are credited but not withdrawable until they settle (or are limited by risk), so a returned ACH can't leave the wallet negative. Calls to processors carry idempotency keys; reconcile daily against processor files.

5. Activity feed and notifications

The feed is a read model built from ledger events (per-user list, newest first), so history reads never hit the ledger. Notifications are async and may lag a second; the balance shown comes from the account, not the feed.

6. Compliance

KYC before higher limits, transaction monitoring for money laundering, and an immutable audit trail — the ledger's append-only design is what makes this straightforward.

What's expected at each level

  • Mid-level: accounts with balances, an atomic transfer, transaction history, idempotent requests.
  • Senior: a double-entry ledger with conditional debits, outbox events for feed and notifications, synchronous risk scoring, external rails as state machines with settlement holds.
  • Staff: cross-shard transfers as idempotent sagas, hot system accounts, ACH return risk and withdrawal holds, reconciliation, and fraud decisions with step-up authentication.