Design Venmo
80 M users paying friends instantly: 15 M transfers a day on a sharded double-entry ledger, funded and cashed out over slow bank rails.
Last updated 2026-10-26. Difficulty: hard. Patterns: ledger, sharding, hot-keys, fraud, fintech. Reported at PayPal, Stripe, Coinbase, Robinhood, Amazon, Google.
The interviewer asks, the candidate answers and draws, and you press Next. Skip to any stage or key moment, and ask the AI Mentor anything along the way.
Or sit it in the style of PayPal · Stripe · Coinbase · Robinhood, run as that company’s round is reported to go.
The short answer
How to design Venmo in a system design interview, in five steps:
- Scope it. Send money to a friend instantly, request money, balance: available and current, with pending items, link funding sources and add money, cash out to a bank account or debit card, social feed with per-payment privacy, risk, limits and compliance in the money path, never create or lose money.
- Set the targets. Money is conserved: Σ postings = 0 · cash ≥ balances owed; No overdraft by a user: available ≥ 0 after every user debit; Send latency: p99 < 500 ms from balance; Scale: 80 M users · 15 M transfers/day · 1 k/s peak.
- Do the numbers. Transfers per second: ~175/s avg · ~1 k/s peak; Ledger postings per day: ~90 M; Ledger storage: ~5 TB/yr raw · ~35 TB over 7 years.
- Draw the design. API gateway, Identity & limits, Risk service, Risk features, Transfer service, Money movement, Card networks, Feed service, Feed store.
- Go deep where it is hard.
- Balances: a ledger plus a cached row → Append-only double-entry journal plus a cached balance row, written in one transaction.
- Cross-shard transfers and hot accounts → Route by sender: leg 1 on the sender’s shard (debit, transit, outbox), leg 2 on the receiver’s (transit to receiver), idempotent on (transfer_id, leg).
- Bank funding that settles in days → Instant credit within a per-user limit; hold until settled above it.
- Instant vs standard cash-out → Push-to-card for instant, ACH for standard, both behind a hold-then-post ledger entry.
- Fraud checks in the transfer path → Synchronous score within 50 ms with live velocity counters, plus async review, holds and a strict cash-out gate.
- Reconciliation against the pooled accounts → A daily close as of a cutoff: internal checks plus line matching of every outward-facing account.
Functional requirements
- Send money to a friend instantly. From the in-app balance, or funded by a linked bank account or card when the balance is short. The recipient sees it in their balance within a second. A card-funded send charges the card through a processor (3 % for credit cards); a bank-funded one may be lent to the sender instantly, within a limit, before the bank debit settles.
- Request money. A request moves no money: it sits on the payer’s shard until they pay it, decline it or it expires after 30 days. Paying uses the request id as the idempotency key, so a request can be paid at most once however many times the button is tapped.
- Balance: available and current, with pending items. Current is settled money; available subtracts holds (a cash-out in flight, a risk hold on money received by a new account). Pending deposits and holds are listed with a reason and a date. Transaction history is exact because it is read from the ledger.
- Link funding sources and add money. Link a bank by bank login through an aggregator such as Plaid, or by micro-deposits, and link debit or credit cards. Adding money from a bank is an ACH debit: pending until it settles, unless instant credit covers it.
- Cash out to a bank account or debit card. Standard is an ACH credit: free, one to three business days. Instant is a push-to-card credit that lands in seconds to minutes, for 1.75 % with a $0.25 minimum and a $25 cap.
- Social feed with per-payment privacy. Friends’ payments with their notes, never the amounts. Each payment is public, friends-only or private; the stricter of the two people’s settings applies, and changing it later hides the item everywhere within a second.
- Risk, limits and compliance in the money path. KYC tiers with weekly send and cash-out limits, sanctions screening, fraud checks on every send and cash-out, holds for new accounts, and an audit trail for every movement and every manual adjustment.
- Never create or lose money. Every movement is a balanced ledger entry. Every night the ledger is reconciled against the pooled bank accounts and the card networks, and the cash held must cover what users are owed.
- Out of scope. The card-acceptance machinery behind card funding (Design a Payment System covers it), merchant checkout and purchase protection, a debit card for spending the balance (its authorisations would be holds like the ones here), crypto, tax forms, and currencies other than USD.
Non-functional requirements
- Money is conserved (Σ postings = 0 · cash ≥ balances owed). No double debit, no lost credit, and no balance its postings cannot explain. The pooled bank accounts must cover what users are owed at every daily close. This ranks above latency and availability.
- No overdraft by a user (available ≥ 0 after every user debit). Two concurrent sends cannot both spend the same $40; only a system reversal after a bank return can take a balance below zero. This drives the hardest tradeoff: the funds check is a serialised decision per account, which is exactly what makes hot accounts and cross-shard transfers hard.
- Send latency (p99 < 500 ms from balance). Our own path is a 50 ms risk check and one local transaction, about 150 ms typical; the recipient sees the credit within a second. Card-funded sends add the processor round trip of 300 to 800 ms.
- Scale (80 M users · 15 M transfers/day · 1 k/s peak). Friday nights and holidays run about 6× the 175/s average. Throughput is modest; the difficulty is contention on hot accounts and money that settles over days.
- Availability of sends (99.99 %). A shard failover may delay credits by seconds to a minute but must never lose one. The feed, notifications and analytics can degrade without touching money.
- Risk decision budget (p99 < 50 ms). Synchronous checks use precomputed features and live counters only. Anything slower runs asynchronously and acts through holds before money leaves the wallet.
- Auditability and retention (append-only · kept ≥ 5 years). Every movement has an entry, a cause and an external reference; manual adjustments need two approvers. Five years is the Bank Secrecy Act’s record-keeping minimum; we keep seven.
- Privacy (amounts never in the feed). Visibility is checked at read time against the current setting, so a payment made private disappears within a second, including from precomputed timelines.
Back-of-envelope estimates
- Transfers per second: ~175/s avg · ~1 k/s peak. 15 M/day ÷ 86 400 ≈ 174/s. Friday evenings and holidays run about 6× the average: ~1 k/s. Each transfer is two local transactions (a debit leg and a credit leg), so ~2 k ledger transactions/s at peak across the fleet. One large Postgres primary could take that; throughput is not what shapes the design.
- Ledger postings per day: ~90 M. A cross-shard transfer is two entries of two postings each (4). Funding, fees, cash-outs, settlements and returns add about 2 more per transfer on average, so ~6 postings per transfer: 15 M × 6 = 90 M a day, ~1 k/s average and ~6 k/s at peak.
- Ledger storage: ~5 TB/yr raw · ~35 TB over 7 years. 90 M × 150 B ≈ 13.5 GB/day ≈ 4.9 TB/yr before indexes, about double with them. Append-only and kept seven years. The last 18 months stay on the shards (~1 TB each across 16), partitioned by month; older partitions move to columnar storage with the same schema.
- Cross-shard share: ~94 % of transfers. Accounts are hashed across 16 shards, so the receiver is on the sender’s shard with probability 1/16: 15/16 ≈ 94 % of transfers cross shards. The two-leg path is the normal path, not the exception, so it has to be fast and boring.
- One balance row’s ceiling: ~330 updates/s. Every debit or credit holds the account’s row lock for its whole transaction, ~3 ms with a synchronous standby commit, so one row serialises at most 1 ÷ 0.003 ≈ 330 updates a second. A viral fundraiser receiving 2 k/s is 6× over. That is why credits to one receiver are batched (~200 per transaction) and system accounts get no balance row at all.
- ACH money at risk: ~$150 M/day lent · ~$1.5 M/day returned. 3 M bank-funded sends a day × $50 = $150 M credited before the debits settle; a holiday weekend keeps about four days, ~$600 M, outstanding. At a 1 % return rate, $1.5 M a day comes back after the recipient may already have cashed out, and unauthorised returns can arrive up to 60 days later.
- Feed fan-out: ~1.8 B timeline writes/day. 15 M payments × 60 % not private × 2 participants × ~100 active friends each = 1.8 B writes a day, ~21 k/s average and ~125 k/s at peak. That is 20× the ledger’s postings and none of it is money: it belongs in its own store and may lag by seconds.
- Reconciliation volume: ~7 M external records/day. ACH debits (3 M bank-funded sends + 0.5 M add-money) + ACH credits (1.2 M standard cash-outs) + push-to-card (0.8 M instant cash-outs) + card charges (1.5 M card-funded sends) ≈ 7 M lines a day to match against ~90 M postings. A batch job does it in minutes; the cost is investigating breaks, not the volume.
Components
- Venmo app (iOS · Android · web): Sends, requests, cash-outs and the feed. Generates an idempotency key when a payment screen opens and reuses it on every retry of that payment, so a double tap or a lost response can never send twice. Shows the available balance as the big number, with pending items and holds listed under it.
- API gateway (auth · device binding · step-up): Authenticates the user and the device, rate-limits per account and device, and routes to the transfer and feed services. Carries step-up challenges (a one-time code, an ID check) back to the app when risk asks for them. Rejects any money-moving call without an Idempotency-Key.
- Identity & limits (KYC tiers · sanctions): Holds each user’s verification tier (phone only, identity verified, enhanced) and the weekly send, receive and cash-out limits that go with it, and screens users against sanctions lists at sign-up and on every list update. The limit values are cached by the transfer service; the running weekly totals are enforced inside the debit transaction on the sender’s shard, so two concurrent sends cannot both slip under a limit.
- Risk service (rules + GBT · ≤ 50 ms): Scores every send and cash-out within 50 ms from precomputed features and live velocity counters, returning allow, block, step-up, or allow with a hold on the received funds. On timeout it falls back to rules by irreversibility: sends from balance inside the wallet continue, money leaving the wallet does not. Logs every decision with its features, for training on labels that arrive weeks later.
- Risk features (Redis · velocity counters): Counts and sums per sender, receiver, device, IP and bank link over 1 minute, 1 hour and 24 hours, incremented atomically at decision time so a burst of parallel sends sees itself. Slower features (account age, return history, graph links to known fraud) are written here by offline jobs. Losing it degrades risk to fallback rules; it holds no money.
- Transfer service (sends · requests · cash-outs): Owns the workflow of a send, a request and a cash-out: resolves the recipient, checks limits, asks risk, picks the funding path, then asks the ledger to post. Stateless: the transfer record is written by the ledger on the sender’s shard, in the same transaction as the debit. Routes every send to the sender’s shard, the only place a send is decided.
- Money movement (ACH · cards · push-to-card): Talks to the slow outside rails. Creates ACH debits and credits and submits them in Nacha files through the partner bank, charges cards through a processor, sends instant cash-outs over push-to-card, and processes return files. Each external transfer has its own state machine (created, submitted, settled, returned) and posts its outcome to the ledger idempotently.
- Card networks (processor · Visa Direct / Mastercard Send): Card charges for card-funded sends go through a processor; instant cash-outs go out as push-to-card credits, which reach a debit card in seconds to minutes and settle from our settlement account the next business day. A push can time out after it has paid, so every call carries our reference and can be queried.
- Feed service (read model · privacy): Builds the social feed from completed transfers: applies the stricter of the two people’s privacy settings, drops the amount, and fans the item out to friends’ timelines. On read it re-checks each item’s current visibility and any blocks, because a timeline is a copy and a payment made private must vanish at once. It never reads the ledger; if it lags or fails, no money is affected.
- Feed store (Cassandra · timeline per user): One partition per viewer, clustered by time: payment id, the two people, note and a visibility version, never the amount. ~1.8 B writes a day, 20× the ledger, with a 30-day horizon. Beside it a small visibility table keyed by payment id holds the current setting that every read checks.
- Ledger service (double entry · two legs): The only writer of money. Posts balanced entries, keeps each user’s cached balance in the same transaction, places and resolves holds, and runs a transfer as two local legs: the debit on the sender’s shard, the credit on the receiver’s. Leg-2 appliers consume the bus by receiver and batch credits for hot accounts; every leg is idempotent on (transfer_id, leg).
- Partner banks (pooled FBO accounts · ACH): Hold user money in pooled for-benefit-of accounts, originate our ACH files, and send prior-day statements (BAI2) and return files. They see only aggregates, never our 80 M user accounts. Our ACH return rates are their regulatory exposure, so they watch them too.
- Event bus (Kafka · from outbox): Transfer events published by an outbox relay, so an event exists exactly when its transaction committed. Keyed by the receiver’s account, so every credit to one account is in order on one partition and one applier can batch them. Consumers: leg-2 appliers, the feed, notifications, risk feature jobs and analytics.
- Ledger shards (Postgres × 16 · by account): Journal entries, postings, balances (current, held, sent this week), transfers with their idempotency keys, requests and the outbox, for the accounts hashed to that shard. Postings in an entry must sum to zero, and the application role can insert them but never update or delete them. Each shard has its own transit, fee and clearing accounts with no balance row, so system accounts never become hot rows.
- Reconciliation (daily close · as of cutoff): Runs after midnight Eastern. Checks that every entry balances and every cached balance equals its postings, then matches each pooled bank account and clearing account to statements and settlement reports, line by line. Proves that user balances equal cash held plus money in flight, and that cash covers what users are owed. Breaks become cases; it never posts a correction itself.
User flows
- Send $40 from balance to a friend. The core write. Alice’s shard decides everything (funds, limits, idempotency) in one local transaction; Bob’s shard applies a credit that cannot fail. Two local transactions joined by an outbox event, with no lock held across the network.
- Alice taps Pay; the app sends the recipient, amount, note, visibility and the idempotency key it generated when the payment screen opened. The key is created once per payment screen and reused on every retry, so a double tap or a response lost in a tunnel is the same payment, not a second one. The gateway checks the session is bound to this device and rejects money-moving calls without a key. Amounts are integer cents.
- The transfer service checks Alice’s tier and limits and asks risk for a decision with a 50 ms deadline. Alice is identity verified, which allows $5 000 of sends a week. Risk reads precomputed features and increments velocity counters for sender, device and receiver in the same call, so twenty parallel sends from a script each see the ones before them. Bob is a known payee and the amount is small: allow.
- Leg 1 runs on Alice’s shard as one transaction: insert the transfer with its key, debit Alice only if her available balance covers it, credit the shard’s transit account, and write an outbox event. The funds check is inside the UPDATE, so two concurrent sends serialise on Alice’s row and the second is re-checked against the new balance. The weekly limit is checked in the same statement. The transit account has no balance row, so it never contends. A repeated idempotency key fails the unique insert and the stored result is returned instead.
- Alice’s app gets “sent” as soon as leg 1 commits. Leg 1 is the commit point: the money has left Alice, and the credit is promised by a committed outbox row, so the answer does not wait for Bob’s shard. If Bob’s shard is mid-failover, Alice still sees sent and Bob sees the credit seconds later. A balance-funded send takes about 150 ms end to end; the status turns to completed when leg 2 lands, usually within 200 ms.
- The outbox relay publishes transfer.debited to the event bus, keyed by Bob’s account. The event exists if and only if leg 1 committed, because it was written in the same transaction. Keying by the receiver puts every credit for one account on one partition, in order, which is what later lets a hot receiver’s credits be batched.
- Bob’s shard’s credit applier runs leg 2: debit its transit account, credit Bob, raise his cached balance, and emit transfer.credited. There is no funds check, because receiving money needs no permission: a frozen account can still receive, and only a closed one is refused (credited to a suspense account, then reversed to Alice). The unique (transfer_id, leg) makes a redelivered event a no-op. Leg 2 usually lands 50 to 200 ms after leg 1.
- The feed service consumes transfer.credited, applies the stricter of the two people’s privacy settings, and writes the item, without the amount, to their friends’ timelines. The payment is friends-only, so about 200 timelines get it: Alice’s and Bob’s active friends, deduplicated. At ~1.8 B such writes a day the feed is 20× the ledger’s postings, which is why it is its own store and may lag by seconds. If the feed is down, nobody’s money is affected.
- A friend opens the feed; each item’s current visibility and any blocks are re-checked before it is shown. A timeline is a copy. If Bob switched the payment to private ten seconds ago, the visibility table has a newer version and the item is dropped even though it still sits in the timeline; a background job deletes it later. Precomputed copies are never trusted for privacy.
- A fundraiser receives 2 k payments a second. The scale-breaking case. One cached balance row takes about 330 updates a second, and a viral relief drive on a holiday evening sends six times that. Credits cannot fail, so they are batched; debits from a hot account can fail, so that work is reshaped instead.
- A relief fundraiser goes viral on New Year’s Eve: 2 k payments a second to one account, from senders on every shard. Each sender’s leg 1 runs on the sender’s own shard, so the 2 k debits a second spread across all 16 shards and never touch the fundraiser’s row. The whole pressure lands on leg 2: one account on one shard.
- Risk sees the receiver’s velocity spike and checks that the account is verified for this volume. A sudden flood into one account is also what a scam looks like. A verified charity profile has a high receive limit and passes; an unverified personal account getting this would keep receiving, with its cash-outs held for review. One Redis counter at 2 k increments a second is far below what a single key can take.
- Every leg-2 event for the fundraiser arrives in order on one bus partition, because events are keyed by the receiver. 2 k small events a second is well within one partition’s throughput. Having them all in one place, in order, lets one applier instance own the account, so it never fights itself for the row lock.
- The applier drains up to 200 events at a time and posts them in one transaction: 200 entries and one balance update for their sum. 2 k credits a second become 10 to 20 transactions a second on the row, far under the ~330 ceiling, for at most ~50 ms of added delay. The sum covers only the entries actually inserted, so a redelivered event is never counted twice. This is safe only because credits cannot fail; debits could not be batched this way.
- The shard’s transit and fee postings pile up as well, but system accounts are append-only with no balance row, so they never contend. Nothing ever checks sufficient funds on a transit or fee account, so nothing needs a row updated in every transaction. Their balance is a one-minute rollup plus the postings since, and the nightly close checks them.
- Each payment fans out to the payer’s friends, not to the fundraiser’s 300 k followers; the fundraiser’s own page reads its activity directly. Accounts with more than 5 k friends or followers are left out of fan-out and merged at read time, the celebrity rule from the news feed. Otherwise one hot receiver would cost 300 k timeline writes per payment, 600 M a second.
- The organiser later pays 10 k volunteers: one conditional debit for the total into a batch account, then 10 k credits that never touch the organiser’s row. A hot sender cannot be batched blindly, because each debit must pass the funds check. So the job becomes one debit with one check, plus 10 k leg-2 credits from a system account with no balance row. The batch has one idempotency key, and each line’s transfer id is derived from it.
- Bank-funded send, then the debit bounces. Alice pays Bob $300 from her linked bank. ACH takes days, so either we lend her the money now within a limit or Bob waits. Two banking days after settlement her bank returns the debit for insufficient funds, and Bob has already cashed out.
- Alice, with $10 in her balance, pays Bob $300 funded by her linked bank; the transfer service checks her instant-credit limit and asks risk. Her instant-credit limit is $500 a week: the bank was linked by bank login months ago, the owner name matches her KYC name, and she has 20 settled debits and no returns. $300 fits, so Bob is paid now. Above the limit, the transfer would wait in awaiting_funds until the debit settles plus the two-banking-day return window, three to five business days.
- The funding service creates the ACH debit, keyed by the transfer id, and the ledger posts a provisional credit to Alice against an ACH receivable. The receivable is our risk, named in the ledger: the sum of ach_receivable is exactly the credit we have extended and not yet collected, ~$150 M a day and ~$600 M over a holiday weekend. Alice’s balance goes to $310 and leg 1 takes it back to $10, in one transaction on her shard.
- Legs 1 and 2 run as for any send; Bob has $300 within a second and cashes it out instantly. From Bob’s side the money is final. That is the product promise, and it is why the risk sits with us and the sender, never with a recipient who was paid in good faith.
- The funding service submits the debit in the next ACH file to the partner bank; it settles in one to two business days. Same Day ACH windows let us submit several times a business day, but a Friday-night send goes out on Monday morning, which is why holiday weekends carry the most credit outstanding. On settlement the bank statement shows the batch and the ledger posts DR bank_fbo, CR ach_receivable.
- Two banking days after settlement, the bank’s return file carries R01, insufficient funds, for Alice’s debit. Insufficient-funds returns must come back within two banking days; unauthorised ones (R10) can arrive up to 60 days later, which is why a fraudulent bank link is far more dangerous than an empty one. The funding service matches the return to the transfer by trace number.
- The ledger posts the reversal without a funds check, and Alice’s balance goes to −$290. This is the only path that can take a balance below zero: a system reversal of money that has already left. The negative balance is a receivable from Alice, and the ledger shows it honestly instead of hiding it in a loss account.
- Collections: Alice’s account is restricted, incoming money repays the debt first, the debit is re-presented once, and after 60 days the rest is charged off. Restricted means no sends, cash-outs or instant credit, and that bank link’s limit drops to zero. Nacha allows an insufficient-funds debit to be re-presented up to twice; we try once, timed after a likely payday. A charge-off posts DR bad_debt, CR alice, bringing her to zero while the debt goes to an agency, and the return becomes a label for the risk model.
- Instant cash-out to a debit card. Bob moves $500 to his debit card in seconds for a 1.75 % fee. Push-to-card is irreversible and can time out after it has paid, so the debit is a hold until the network’s answer is known, and settlement is checked the next morning.
- Bob asks to cash out $500 instantly to his linked debit card; the app shows the $8.75 fee next to the free standard option. Standard is an ACH credit: free, one to three business days. Instant is 1.75 %, with a $0.25 minimum and a $25 cap. The fee pays for per-transaction network costs and for the risk of an exit nobody can reverse.
- Risk checks the cash-out, because money leaving the wallet is the last point where fraud can be stopped. A card linked in the last 24 hours, a receive hold, or money that arrived through instant-credit ACH not yet settled would make this standard-only. Bob’s $500 came from friends’ settled payments and his card is eight months old: allow. His tier allows $20 000 of cash-outs a week.
- The ledger places a hold for $508.75: a pending entry that lowers Bob’s available balance but not his current one. Holding first, rather than posting, keeps the books honest while the outcome is unknown. Available drops at once, so Bob cannot spend the same money while the push is in flight.
- The funding service sends a push-to-card credit over Visa Direct or Mastercard Send, with the cash-out id as its reference. The outcome has three values: approved, declined, or unknown (a timeout or a 5xx), the same discipline as a payment system’s calls to its processor. The reference is what makes a later status query, and the settlement report, findable.
- The call times out; the hold stays, and the funding service queries the push by its reference until it has an answer. Releasing the hold on a timeout would let Bob cash out the same $500 twice if the first push had actually landed. Status queries are safe to repeat; a push still unknown after an hour pages someone, and the next morning’s settlement report settles it for good.
- The answer is approved: the pending entry is posted, the hold is released, and current drops by $508.75. A decline would archive the entry and only release the hold. Either way it is one conditional transition out of pending, so a late callback and the poller cannot both apply it.
- The next morning, the network’s settlement report and the bank statement are matched against the push-to-card clearing account. The network settles the day’s pushes from our settlement account the next business day, so treasury keeps that account funded for a long weekend of cash-outs, ~$400 M on our numbers. Once the settlement line posts (DR p2c_clearing, CR bank_fbo), p2c_clearing should hold only pushes that have not settled yet.
- The daily close against the pooled accounts. The proof that no money was created or lost. Every account that faces the outside world is matched to an outside record as of one cutoff, and the books must show that cash at the banks covers what users are owed.
- At midnight Eastern the day closes; after a 15-minute grace for legs in flight, the job sums every shard’s postings as of the cutoff, on replicas. Entries are immutable and carry effective_at, so “everything effective before midnight” is a fixed set once the grace period has passed, whenever the job runs. Replicas keep the load off the primaries. A transfer whose leg 1 is before midnight and leg 2 after shows up as transit balance, which is exactly money in flight.
- Internal checks: every entry balances, every cached balance equals its postings, and transit across shards equals the legs that were in flight. Only accounts touched today are re-summed, about 15 M: in one snapshot of each shard, yesterday’s verified balance plus the postings since must equal the row. A mismatch freezes that account’s outflows and opens a case, because it means some code path changed a balance without an entry.
- The partner banks’ prior-day statements for the pooled accounts arrive: ACH batch settlements, returns, wires, and the card network’s net settlement. Banks see only aggregates: one line per ACH batch, one for the network’s daily settlement. The detail behind each line comes from our own files and from the return and settlement files, which is why every entry stores the external reference it will be matched on.
- Line matching: ACH batches by file and batch id, returns by trace number, card charges and pushes by network reference, about 7 M records a day. Every record lands in one bucket: matched, timing (ours, settling tomorrow), or unexplained. Each clearing account (ach_receivable, ach_payable, p2c_clearing, card_receivable) must equal its open timing items exactly. The job takes minutes; the cost is investigating breaks, not the volume.
- The invariant: the ledger’s pooled-account balances equal the statements to the cent, and pooled cash covers every positive user balance. Because every entry balances, once every outward-facing account matches its outside record, the sum of user balances equals cash held plus receivables in flight minus payables in flight, by arithmetic. Treasury tops the pooled accounts up with company money to cover credit we have extended, because negative balances are receivables, not offsets: the coverage test money transmitter rules care about is cash against the sum of positive balances.
- Breaks become cases with both sides’ evidence; a correction is a new entry approved by two people, and the affected accounts’ outflows are held meanwhile. Recon never posts a plug to make the numbers tie: an unexplained credit is still somebody’s money until we know whose. A timing item older than three business days becomes a break. The break count, its dollar value and the oldest open item are the first numbers finance reads each morning.
Deep dives
Balances: a ledger plus a cached row
Why not keep a balance column per user and decrement it on every payment?
Every send has to answer one question without error: does Alice have $40 available right now? At a 1 k/s peak, with concurrent sends from a double tap, two devices or a script, the check and the debit must be atomic, and the answer must be explainable afterwards, to the cent, to Alice, an auditor and a regulator. A wallet also has two numbers, not one: current (settled money) and available (current minus holds for cash-outs in flight and risk holds).
The ledger and a cached balance are not alternatives. The append-only journal is the truth and explains every cent; the cached row is a derived value that exists so the funds check can be one conditional update. The trick is to write both in the same database transaction on the same shard, so they cannot disagree, and to verify them every night anyway.
- Append-only double-entry journal plus a cached balance row, written in one transaction chosen
- Balance column only rejected
- Journal only: sum the postings on every check situational: System accounts that nobody checks for funds (transit, fees, clearing): append-only, with a one-minute rollup
- Balances in Redis with a Lua check-and-decrement, journal written afterwards rejected
The answer: Each ledger shard holds journal_entries (id, transfer_id, leg, status pending, posted or archived, effective_at), postings (entry_id, account_id, debit or credit amount) that must balance per entry, enforced by a deferred constraint trigger, and balances (account_id, current, held, sent_7d, version). A debit is one transaction: UPDATE balances SET current = current − 4000 WHERE account_id = ? AND current − held ≥ 4000, then the entry, its postings and the outbox row; zero rows updated means insufficient funds and nothing is written. A hold is a pending entry that raises held, so available drops and current does not; it ends exactly once, as posted (captured) or archived (released or expired), and only holds whose safe default is “nothing happened”, such as a 72-hour risk hold, are allowed to expire on their own. Only user accounts get a synchronous balance row; system accounts are append-only with a rollup, so they can never be hot rows. A balance can go below zero only through a system reversal after a bank return, never through a user debit. Stripe’s balance API makes the same split between available and pending funds.
Two sends from the same account arrive in the same millisecond and together exceed the balance.
Both run the conditional UPDATE on the same row. The row lock serialises them, and the second re-evaluates its WHERE clause against the row the first committed, matches zero rows and rolls back as insufficient funds. Because the check lives inside the UPDATE rather than in a read before it, read committed isolation is enough. The lock is held for the transaction, about 3 ms, and never across a network call.
How do you know the cached balance has not drifted from the postings?
Three layers. The write path puts both in one transaction; the nightly close re-sums every account touched that day against its row; and a sampler re-checks 1 % of accounts every hour. A mismatch freezes that account’s outflows and pages someone, and the row is recomputed from the postings, which are the truth, with an audit record. A mismatch is never noise: it means a code path changed a row without an entry, and that path must be found.
What does the app show: current or available?
Available, as the big number, because that is what the user can spend; anything else invites sends that fail. Under it, every hold and pending item with a reason and a date: cash-out in progress, deposit available on Tuesday, on hold for review until Friday. Current appears in the history and statements, where settled money matters.
A cash-out hold reaches its expiry and the network outcome is still unknown. Does it release?
No. Expiry is only for holds where “nothing happened” is the safe default, like a risk hold or a card authorisation, whose capture would otherwise simply never arrive. A cash-out whose push may have landed stays pending until the network’s status query or settlement report decides it, because releasing it could let the same money leave twice. Unknown holds older than an hour page someone instead.
Why allow negative balances at all?
Because the ledger must describe reality. After an ACH return the money already left with the recipient, and clamping the sender at zero would hide a receivable and break the invariant that the books balance. The rule is about who can make a balance negative: every user-initiated debit passes the funds check, and only system reversals bypass it, each tagged with a reason and the original transfer id.
Cross-shard transfers and hot accounts
Alice and Bob are on different shards. How do you move $40 between them atomically, and what happens when one account receives 2 k payments a second?
With accounts hashed across 16 shards, 94 % of transfers cross shards, so the cross-shard path is the normal path. One local transaction cannot span two Postgres primaries. And concurrency concentrates: every debit and credit takes its account’s row lock for about 3 ms, which caps one account near 330 updates a second. A viral fundraiser on a holiday evening receives 2 k a second, and a global fee or clearing account would see every transfer.
The observation that unlocks it: a transfer has exactly one decision, and it is on the sender’s side. The debit can fail (funds, limits, risk); the credit cannot, because receiving money needs no permission. So the transfer does not need an atomic commit across shards. It needs the debit to be atomic with a durable promise to credit, and the credit to be idempotent. And a credit that cannot fail can safely be delayed by a few milliseconds and batched.
- Route by sender: leg 1 on the sender’s shard (debit, transit, outbox), leg 2 on the receiver’s (transit to receiver), idempotent on (transfer_id, leg) chosen
- Two-phase commit across the two shards rejected
- Distributed SQL (Spanner, CockroachDB) with serializable cross-shard transactions situational: The company already runs it well in production; credits to hot receivers would still be batched
- All balances on one shard (one large primary) situational: An earlier-stage wallet, or a first version: defensible below a few thousand transfers a second, with history archived off the primary
The answer: Accounts hash into 256 logical shards placed on 16 Postgres primaries with synchronous standbys, and the transfer service routes every send to the sender’s shard. Leg 1 is one transaction there: insert the transfer with the client’s idempotency key (unique per sender), conditionally debit the sender’s row, post sender to transit, and write the outbox event keyed by the receiver’s account. Leg 2 is the receiver shard’s credit applier: post transit to receiver and raise the receiver’s row, with a unique (transfer_id, leg) so redelivery is a no-op. When both accounts share a primary, both postings go in one transaction, locking the two rows in account-id order. The applier batches up to 200 credits per receiver per transaction, so 2 k a second becomes 10 to 20 row updates. System accounts (transit, fees, clearing) exist once per shard and have no synchronous balance row, and a hot sender, a business paying 10 k people, is reshaped into one debit for the total plus 10 k credits from a batch account. It is the clearing-account pattern accountants use for money moving between two sets of books: each side balances alone, and the money in flight is a visible number.
Leg 1 commits and Bob’s shard is down for two minutes. What do Alice and Bob see?
Alice sees “sent” and a lower balance, which is true: her money left. Bob sees nothing until his shard is back, then the applier drains the backlog in order and his balance catches up within seconds. Transit on Alice’s shard shows the money in flight, and the oldest-unapplied-leg metric pages after 60 seconds. Nobody retries anything by hand: the event is in the outbox and on the bus.
Bob closed his account a minute before the credit lands.
Leg 2 cannot credit a closed account, so it posts transit to a suspense account on Bob’s shard and emits a reversal event. A third leg on Alice’s shard posts transit back to Alice and marks the transfer returned, with a notification. Three entries, each local and idempotent, and Alice has her money back within a second. The transfer service checks the receiver’s status before leg 1, so this is a narrow race, not a common path.
Why key the event by the receiver rather than the transfer id?
So every credit to one account arrives in order on one partition and is applied by one instance, which can batch them and never contends with itself for the row lock. Keyed by transfer id, a hot receiver’s 2 k credits a second would spread over every applier, all fighting for the same row, which is exactly the contention we are avoiding.
A shard fills up. How do you rebalance without stopping payments?
Move logical shards, never individual accounts. Copy a logical shard to its new primary with logical replication, let it catch up, pause leg 1 for those accounts for a few seconds, flip the routing map, and resume. Credits for them wait on the bus during the pause and apply in order afterwards. With 256 logical shards on 16 primaries, each move is about 1/256 of the data.
How do you know the two-leg path never loses money?
The transit invariant: the sum of transit balances across all shards equals the amounts of legs 1 with no leg 2, checked continuously. At peak it should hover around 100 ms of traffic, about $5 000; a leg 1 older than 60 seconds without its leg 2 alerts, and the nightly close proves it to the cent at the cutoff.
Bank funding that settles in days
Alice pays Bob from her bank account. ACH takes days and can bounce. Does Bob get the money now?
ACH debits settle in one to two business days, an insufficient-funds return (R01) can arrive two banking days after settlement, and an unauthorised return (R10) up to 60 days later. Users expect a bank-funded payment to land instantly. At 3 M bank-funded sends a day averaging $50, that is $150 M a day of money we do not have yet, about $600 M outstanding over a holiday weekend, and at a 1 % return rate $1.5 M a day comes back after the recipient may already have cashed out.
The real question is who carries the settlement risk, and the answer is: the wallet does, deliberately and with a limit. Instant credit is a small loan to the sender, so it is sized like one, per user, by evidence that the money exists and that the bank account is really theirs. And the ledger must say so: provisional money sits against a receivable, so the amount at risk is a number on a dashboard, not something discovered in a loss report.
- Instant credit within a per-user limit; hold until settled above it chosen
- Always hold until the debit settles, plus the return window situational: New accounts in their first days, banks linked in the last 24 hours, users with a return history, and amounts above the limit
- Instant credit for everyone, recovered from recipients on a return rejected
- Pull from the bank over a real-time rail (an RTP request for payment) situational: The user’s bank supports request for payment; route there first as coverage grows
The answer: Each user has an instant-credit limit, recomputed daily and on events: zero for a bank linked in the last 24 hours or whose owner name does not match our KYC name, about $250 for a new account with a bank-login link and a balance check, and up to $2 500 a week for users with months of settled debits and no returns. Within the limit, the funding service creates the ACH debit (a WEB entry, keyed by the transfer id) and the ledger posts DR ach_receivable, CR sender, after which the send runs like any other. Above it, the transfer waits in awaiting_funds and completes when the debit settles plus two banking days. A return posts DR sender, CR ach_receivable without a funds check, so the sender’s balance goes negative; the account is restricted, incoming money repays it first, an R01 is re-presented once, and after 60 days the rest is charged off to bad debt. Money that arrived through instant credit can move inside the wallet but cannot be cashed out instantly until the debit settles. We watch return rates against Nacha’s thresholds (0.5 % unauthorised, 15 % overall), because breaching them is the partner bank’s problem as much as ours.
A fraudster links a stolen bank login, pays a mule $500, and the mule cashes out instantly. Sixty days later: R10.
That loss is why a new link gets zero instant credit and why money that arrived on instant credit cannot leave by instant cash-out until the debit settles: inside the wallet we can still freeze it. The signals are a link whose owner name does not match KYC, a new device, and a new receiving account taking money from several unrelated new senders and cashing out within minutes. When the R10 arrives we trace the money downstream, freeze what is left in the mule’s account, and use the chain as a training label.
Why not take the money back from Bob?
Bob was paid in good faith; reversing a completed payment to an innocent recipient makes every payment feel provisional, and he may have spent it. The sender’s bank refused, so the debt is the sender’s, and the ledger records it as such. The exception is a recipient who is part of the fraud, a mule account, where we freeze and recover whatever remains.
How large can ach_receivable get, and who decides?
It peaks over a holiday weekend: Friday to Tuesday is about four days of instant-credit sends, roughly $600 M on our numbers. That is a balance-sheet decision, because recipients can cash out money we have not collected, so treasury must keep company cash in the pooled accounts to cover it. Risk sets per-user limits; finance sets a global cap and can tighten instant credit ahead of a holiday.
When can you re-present a returned debit?
Nacha allows a debit returned for insufficient or uncollected funds (R01, R09) to be re-initiated up to two more times within 180 days. We re-present once, timed after a likely payday such as the 1st or the 15th. Unauthorised returns are never re-presented. Each attempt is a new entry with its own trace number referencing the original transfer.
How do you know the limits are set right?
Loss rate (charge-offs over instant-credit volume) by limit bucket and cohort, return rate by SEC code against Nacha’s thresholds, and the share of bank-funded sends that were instant. Limit changes run as experiments on cohorts, because the trade is approval rate against losses and only measurement tells you where it sits. Losses are dated by the transfer, not the return, since labels take up to 60 days.
Instant vs standard cash-out
Standard cash-out is free and takes days; instant costs 1.75 % and takes seconds. How does instant work, and why does it cost money?
Standard cash-out is an ACH credit to the user’s bank: it costs cents, arrives in one to three business days, and if the account is closed the credit comes back (R02, R03) and the balance is restored. Instant cash-out uses a push-to-card rail, Visa Direct or Mastercard Send, which credits a debit card in seconds to minutes and is effectively irreversible. At ~800 k instant cash-outs a day, about $120 M, it is the wallet’s main exit, and the one fraud uses.
Two facts shape the design. The push call can time out after the money landed, so its outcome has three values, not two, and the debit must be a hold until the network answers. And instant is something we pay for (per-transaction network and processing fees) and take risk on (money leaves before anyone can look), so the fee prices speed and risk, and risk decides who gets instant at all.
- Push-to-card for instant, ACH for standard, both behind a hold-then-post ledger entry chosen
- Real-time bank rails (RTP, FedNow) to the bank account situational: The user’s bank is reachable on RTP or FedNow; route there before push-to-card when it is cheaper
- Same Day ACH situational: A cheaper “faster than standard” tier on business days
- Post the debit first and reverse it if the push fails rejected
The answer: A cash-out places a hold for the amount plus the fee, a pending entry DR user, CR p2c_clearing, CR fee_revenue, so available drops at once and current drops when the entry posts. The funding service calls the network with the cash-out id as its reference: approved posts the entry, declined archives it and releases the hold, a timeout leaves it pending while the funding service queries status by reference, and the next day’s settlement report is the final word. The fee is 1.75 % with a $0.25 minimum and a $25 cap, close to what Venmo charges for instant transfers. Risk decides eligibility: a card linked in the last 24 hours, a receive hold, or money from unsettled instant-credit ACH makes the request standard-only. The network settles the day’s pushes from our settlement account the next business day, so treasury keeps that account funded for a long weekend, ~$400 M on our numbers. Standard cash-out posts DR user, CR ach_payable straight away, because an ACH credit that fails comes back as a return we can reverse.
The push times out and the user taps cash out again.
The second tap is a new cash-out with a new idempotency key, but the first hold is still pending, so available is already $508.75 lower; the second succeeds only if what remains covers it, which makes it a genuinely separate cash-out. The app shows the first as in progress, which discourages the retap. What must never happen is releasing the first hold because of the timeout.
The user’s card does not support fast funds.
The network’s card eligibility lookup, run when the card is linked and again before the push, says whether the issuer makes pushed funds available within minutes. If not, we say how long it will take or offer standard instead. We never charge an instant fee for a delivery that is not instant.
Why charge 1.75 % when RTP costs a few cents?
Coverage and risk. Push-to-card reaches almost every debit card and RTP reaches only some banks; the fee also covers fraud losses on irreversible exits and the cost of keeping the settlement account prefunded. Routing to RTP or FedNow when the user’s bank supports it improves the margin. The price itself is a product decision, not a cost-plus calculation.
A standard cash-out goes to a closed bank account.
The ACH credit comes back as R02 or R03 within a few days. The ledger posts DR ach_payable, CR user, restoring the balance, the bank account is marked invalid, and the user is asked to link another. Nothing was lost because the money never left the pooled account.
How would you know instant cash-out is being abused?
Instant cash-out volume by account age and by where the money came from (instant-credit ACH, new senders), cash-outs within minutes of receiving, and many accounts cashing out to the same card fingerprint. Above all, join each later R10 return to the transfers it funded and the cash-outs downstream of them. That chain, return to transfer to exit, is the loss you are trying to shrink.
Fraud checks in the transfer path
Where do fraud checks run in a send, and how much time do they get?
A balance-funded send should finish within 500 ms at p99, and a risk check that takes 500 ms on its own makes the product feel broken. Yet peer-to-peer payments are irreversible in practice: transfers to strangers are how scams, mule networks and account takeovers turn into cash. Most fraud does not look like one bad payment; it looks like a pattern: a new device, a new payee, ten sends in a minute, an instant cash-out right after.
Money inside the wallet is still recoverable; money that has left is not. So the synchronous check can be fast and conservative (allow, block, step up, or allow but hold), the expensive analysis can run asynchronously over the next seconds and hours, and the strictest gate sits at cash-out, where money leaves.
- Synchronous score within 50 ms with live velocity counters, plus async review, holds and a strict cash-out gate chosen
- Everything synchronous, including heavy models and graph queries rejected
- Asynchronous monitoring only rejected
- Hold every payment to a first-time payee for review situational: Large amounts to first-time payees from new accounts, as a step-up rather than a hold
The answer: The transfer service calls the risk service with a 50 ms deadline. Risk reads precomputed features (account age, device history, payee history, bank link age, return history, graph features computed offline) and atomically increments velocity counters in Redis, sends and amounts per sender, device, IP and receiver over 1 minute, 1 hour and 24 hours, so a burst sees its own earlier sends. A gradient-boosted model plus rules returns allow, block, step-up (a one-time code or an ID check) or allow-with-hold, where the receiver is credited but the funds are held for up to 72 hours. New accounts get low limits and holds on money they receive in their first days. If risk times out, fallback rules apply by irreversibility: sends from balance to known payees under $250 continue, bank-funded instant credit is refused, and cash-outs are standard-only. Asynchronous scoring, AML monitoring and a review queue ordered by dollars at risk can freeze an account’s outflows, and every decision is logged with its features for training on labels that arrive late: ACH R10 returns, chargebacks and Reg E unauthorised-transfer claims.
A scammer persuades Alice to pay $900 for concert tickets. Your model is sure Alice is Alice. What then?
Scams where the real user sends the money defeat identity checks, so the signals have to come from the payee: a new account, many first-time payers, quick cash-outs, reports from earlier victims. At the moment of sending, a large amount to a first-time payee gets friction: a warning that this person is new and that payments for goods should use purchase protection. And holds on the receiving account’s cash-outs keep the money inside long enough for victims’ reports to arrive.
Why increment velocity counters synchronously rather than from the event stream?
The stream lags by about a second, so a script firing 20 sends in parallel would read a counter of zero on every one. INCRBY at decision time makes each check see the ones before it. The stream still feeds the slower features and corrects counters for sends that later failed.
The risk service is down at 9 pm on New Year’s Eve.
Fall back by irreversibility. Sends from balance to known payees continue, because the money stays inside the wallet and can be frozen later. Bank-funded instant credit and instant cash-out stop, and new payees above a small amount get a step-up. Never fail open on money leaving the system.
How do you measure whether risk is doing its job?
Loss rate by product (peer-to-peer, instant credit, instant cash-out), dated by the transfer rather than the label; false positives estimated from reviewer overturns and a small holdout of allowed borderline cases; step-up completion rate; and p99 decision latency against the 50 ms budget. The goal is a business trade-off between losses and friction, not zero fraud.
Reconciliation against the pooled accounts
How do you prove every morning that user balances add up to the money actually sitting in the banks?
User money sits in a handful of pooled accounts at partner banks, and the banks see only aggregates: ACH batches, a card network’s net settlement, wires. The ledger has 80 M user accounts and ~90 M postings a day across 16 shards, and money is always in flight: ACH debits not yet settled, cash-outs not yet presented, transit legs between shards. An error of one dollar in a million is $750 a day on our volume, and an error in the wrong direction means the cash no longer covers what users are owed, which is a regulatory problem.
Double entry makes the proof tractable. If every entry balances, and every account that faces the outside world (the pooled bank accounts, ACH receivable and payable, push-to-card clearing, card receivable) matches its outside record, then the sum of user balances must equal cash held plus money owed to us in flight minus money we owe out. So reconciliation is a set of per-account matches against statements, plus internal checks, all evaluated as of the same cutoff.
- A daily close as of a cutoff: internal checks plus line matching of every outward-facing account chosen
- Compare total user balances with total bank balances rejected
- Continuous intraday reconciliation situational: For the riskiest rails, instant cash-out and ACH returns, alongside the daily close rather than instead of it
- Auto-correct breaks with adjusting entries rejected
The answer: The day closes at midnight Eastern. After a 15-minute grace for legs in flight, the job sums every shard’s postings as of the cutoff on replicas, using effective_at, so the result does not depend on when it runs. Internal checks: every entry sums to zero, every touched account’s cached balance equals its verified balance plus the postings since, and the transit accounts sum to the legs in flight at the cutoff. External checks: each pooled account in the ledger equals the bank’s BAI2 statement, and each clearing account equals its open items, matched line by line on trace numbers and network references. The invariant then follows (user balances equal cash held plus receivables minus payables in flight), and separately pooled cash must cover the sum of positive balances, the coverage test money transmitter rules care about. Timing items become breaks after three business days; breaks become cases, and a correction is a new entry approved by two people, never a plug.
The bank statement is $12 000 higher than our pooled-account balance.
Start from the lines that did not match. Usually it is one unmatched credit: a returned cash-out whose return file is late, or an incoming deposit we did not originate. Each unmatched line becomes a case with the bank’s reference. We never post a plug entry to make it tie; an unexplained credit is somebody’s money until we know whose.
How do you reconcile 16 shards at one instant without stopping writes?
We do not need an instant. Entries are immutable and carry effective_at, so “everything effective before midnight” is a fixed set once the grace period has passed. Each shard is summed on its own replica, and a transfer whose legs straddle midnight shows up as transit balance, which is exactly the in-flight amount and is matched by transfer id.
One account’s cached balance does not match its postings.
The postings are the truth. Freeze that account’s outflows, page, recompute the row from the postings with an audit record, and find the code path that caused it. One mismatch is a bug, not noise: something changed a balance without an entry, which the same-transaction rule should make impossible.
A regulator asks for every movement on one account last March, and who changed anything.
The journal is append-only and kept at least five years, the Bank Secrecy Act’s minimum; we keep seven. Partitions older than 18 months live in columnar storage with the same schema, so it is a filter by account and date. Every entry links to its transfer and external reference, and every manual adjustment to its case and its two approvers.
What breaks first at ten times the volume?
Not the matching: it partitions by rail and day, and the internal re-sum partitions by shard. What stops scaling is people reviewing breaks, so the metric to hold flat is breaks per million records, which means making matching keys exact at the source rather than hiring reviewers.
The theory behind it
- Double-entry ledger: an interactive playground. Record four money transfers as balance updates and as double-entry journal entries, add a retried request and a crash mid-transfer, and see which approach loses or duplicates money.
- Digital wallets and stored balances: Designing wallets that hold money or credits: balances derived from a double-entry ledger, top-ups and withdrawals, peer-to-peer transfers, holds and authorisations, concurrency on hot balances, limits and KYC, multi-currency, reconciliation with banks, and in-app credits and virtual currencies.
- Money in system design: ledgers, idempotency and reconciliation: How to handle money correctly: integer minor units, double-entry ledgers, balances derived from entries, idempotent payment calls, the unknown-outcome state, authorisation and capture, and daily reconciliation with providers.
- Real-time fraud detection: Designing fraud and risk systems for payments and marketplaces: the decision point and latency budget, rules plus machine learning, real-time velocity features, device and network signals, graph features, step-up actions, review queues, feedback and label delay, and measuring precision and losses.