Sending money to a friend looks like the simplest thing a payments company does: type an email address, type an amount, press send. Underneath, the system has to pick where the money comes from, decide in a few hundred milliseconds whether the request is fraud, move value between two accounts without ever creating or destroying a cent, tell both people, and still be correct when the bank debit that funded it bounces four days later.

This article builds a reference architecture for that problem. It is honest about sources: PayPal has not published an end-to-end design of its peer-to-peer system, so what follows is a design that satisfies the same requirements, with the few pieces PayPal has described publicly called out as such. It covers the ledger, the send path with a worked example, idempotency, funding and holds, risk, events, withdrawals, reversals, scaling, failure modes and a checklist. Marketplace pay-ins and payouts are covered separately in Designing a Payment System.

Advertisement

What is public and what is a reference design

PayPal has written publicly about some of its infrastructure. Two pieces are open source. JunoDB, released in 2023, is a distributed key-value store built around a client library in the application, a proxy layer that routes and load-balances requests, and storage servers behind it; PayPal lists risk analysis, user authentication and transaction processing among the workloads it serves. Hera is a data access gateway that sits between services and relational databases and provides connection multiplexing, read/write splitting and sharding.

That is not an architecture for peer-to-peer transfers, and this page does not pretend otherwise. Where the design below uses a low-latency key-value store for risk features or a connection-multiplexing proxy in front of the ledger database, it does so because those are the problems those tools solve, not because a PayPal document says the P2P path is wired that way.

Requirements and a capacity sketch

Functionally, a user can send money to another user identified by email, phone or handle; request money; choose or accept a default funding source; see the transfer in both activity feeds; and withdraw a balance to a bank, either standard (free, slower) or instant (fee, seconds). Some transfers are held for review.

The non-functional requirements dominate. Money must be conserved: every movement is balanced, and the sum of all balances plus all external clearing accounts never changes except through recorded external events. A retried request must not move money twice. The send call should answer in well under a second at the 99th percentile, because the user is staring at a spinner. And every number shown to a user must be explainable from immutable records, because regulators and auditors will ask.

For sizing, pick illustrative numbers and keep them visibly illustrative: 50 million transfers a day averages about 580 per second; with a five-times peak that is roughly 3,000 per second. If each transfer writes one journal and four to six postings, the ledger absorbs around 15,000 to 21,000 row inserts per second at peak, plus outbox rows. That is well within a sharded relational store and well beyond a single primary, which is why sharding shows up later.

Advertisement

The architecture

Sender appsend $50 to ben@API gatewayauth, rate limitTransfer serviceidempotency + stateRisk servicescore within deadlineFeature storelow-latency KVFundingbalance/card/bankpost journalDouble-entry ledgerjournals + postings, sum = 0same txnOutbox tablerelayEvent streamNotificationsBalance viewsWithdrawalsbank railsWrites: ledger is theonly source of truth;everything below theoutbox is derived
Reference design: the transfer service orchestrates, risk decides within a deadline, the ledger is the only source of truth, and everything downstream is derived from events written in the same transaction as the ledger.

The transfer service owns the lifecycle of a send: it validates the request, enforces idempotency, asks risk for a decision, asks the funding service to secure money from outside sources if the balance is short, and finally posts a journal to the ledger. It keeps a small state machine per transfer so that a crash at any step can be resumed rather than guessed at.

The ledger is an append-only double-entry store. It knows nothing about users' intentions, only accounts, journals and postings. Balances are either computed from postings or maintained in the same transaction as each posting, never updated independently. The outbox table is written in that same database transaction, and a relay publishes its rows to an event stream; notifications, activity feeds, search, analytics and the withdrawal pipeline all consume events and can be rebuilt from them. The pattern is covered in the outbox pattern.

The ledger: accounts, journals, postings

Each user has several ledger accounts rather than one balance: available, pending (money received but held) and sometimes a reserve. The company has its own accounts: card clearing, bank clearing, fee revenue, and a loss account for written-off negative balances. A transfer is a journal, and its postings are signed amounts against accounts that must sum to zero within the journal and within each currency.

CREATE TABLE ledger_account (
  account_id   BIGINT PRIMARY KEY,
  owner_id     BIGINT,            -- user, or NULL for system accounts
  kind         TEXT NOT NULL,     -- AVAILABLE, PENDING, CARD_CLEARING, FEES ...
  currency     CHAR(3) NOT NULL,
  balance      NUMERIC(20,2) NOT NULL DEFAULT 0,
  version      BIGINT NOT NULL DEFAULT 0
);

CREATE TABLE journal (
  journal_id   BIGINT PRIMARY KEY,
  transfer_id  BIGINT NOT NULL,
  reason       TEXT NOT NULL,     -- P2P_SEND, CARD_TOPUP, REVERSAL ...
  reverses     BIGINT REFERENCES journal(journal_id),
  created_at   TIMESTAMPTZ NOT NULL
);

CREATE TABLE posting (
  journal_id   BIGINT REFERENCES journal(journal_id),
  account_id   BIGINT REFERENCES ledger_account(account_id),
  amount       NUMERIC(20,2) NOT NULL,   -- positive credit, negative debit
  currency     CHAR(3) NOT NULL
);

Amounts are fixed-point decimals or integer minor units, never floats. Postings are never updated or deleted; a correction is a new journal with reverses pointing at the original. That single rule is what makes every balance auditable: you can replay postings to any point in time and get the number the user saw.

Worked example: Asha sends Ben $50

Asha has $20 available and a linked debit card. She sends Ben $50 and accepts the suggested split: $20 from balance, $30 from the card. The transfer service first asks the funding service to authorise and capture $30 on the card. Only after the card network approves does the ledger see anything, and then it sees one journal that does both moves atomically:

AccountPostingBalance after
Card clearing (system)-30.00owed by card network
Asha available+30.00 then -50.00 (net -20.00)0.00
Ben available+50.0050.00 (or pending, if held)
Sum of postings0.00money conserved

If risk had decided to hold the transfer, the credit would land in Ben's pending account instead of available, and a later release journal would move it across.

Notice the order: external money is secured first, internal money moves second. The reverse order would let Ben spend money that the card network later refuses to provide.

An idempotent send API

Mobile clients retry on timeouts, and a timeout says nothing about whether the server acted. The client therefore generates an idempotency key per user intent and sends it with every retry. The server stores the key with a hash of the request and the transfer it created, under a unique constraint, before doing anything irreversible. The general technique is in idempotency.

The sketch below shows the shape. The important properties are that the key is claimed atomically, that a retry with the same key but a different body is rejected rather than silently answered, and that a retry while the first attempt is still running gets a 'processing' answer instead of starting a second attempt.

def send(user_id, key, req):
    h = sha256(canonical_json(req))
    claimed = db.insert_if_absent("idem", (user_id, key), {"hash": h, "state": "STARTED"})
    if not claimed:
        row = db.get("idem", (user_id, key))
        if row["hash"] != h:
            raise Conflict("idempotency key reused with a different request")
        if row["state"] == "DONE":
            return row["response"]                 # replay the original answer
        return Accepted({"status": "PROCESSING", "transfer_id": row.get("transfer_id")})

    t = transfers.create(user_id, req, state="CREATED")
    db.update("idem", (user_id, key), {"transfer_id": t.id})
    decision = risk.score(t, deadline_ms=250)        # falls back to rules on timeout
    if decision == "DECLINE":
        return finish(user_id, key, transfers.fail(t, "RISK_DECLINED"))
    if t.needs_external_funding():
        funding.capture(t, idempotency_key=f"{t.id}:fund")   # downstream keys are derived
    ledger.post(t, held=(decision == "HOLD"))        # journal + outbox row, one DB txn
    return finish(user_id, key, transfers.complete(t))

Downstream calls carry keys derived from the transfer id, so a resumed transfer re-sends the same capture request and the card processor deduplicates it. A recovery worker scans transfers stuck in intermediate states and drives them forward from the last durable step; that is a small saga, discussed in the saga pattern.

Funding sources and holds

Funding sources differ in how final they are, and that difference drives the whole risk model. A balance is final the moment the journal commits. A card capture is approved in seconds but can be charged back weeks later. A bank debit is the riskiest: the transfer to the recipient can complete in seconds while the debit can still be returned days later for insufficient funds or because the account holder says they did not authorise it.

So the platform is effectively lending money whenever it credits a recipient before a bank debit settles. The tools for managing that exposure are: prefer balance, then card, then bank as default funding order; limit how much unsettled bank money a new account may send; place the recipient's credit in pending when risk is elevated; and keep a per-user exposure figure, the sum of unsettled funding, that risk can read in a single key lookup. When a bank return arrives, a reversal journal debits whoever still holds the value, and if that drives an account negative, collections and eventually the loss account take over.

Send-time risk

Risk scoring sits on the synchronous path, so it has a hard latency budget. The risk service reads precomputed features (account age, device history, recent velocity, recipient graph, exposure) from a low-latency key-value store and combines them with request features in a model. The answer is APPROVE, HOLD, STEP_UP (ask for extra authentication) or DECLINE.

The transfer service, not the risk service, enforces the deadline: if no answer arrives within the budget, a conservative rule set decides, for example approve small balance-funded sends between long-standing contacts and hold everything else. Decisions are also recorded with the model version and feature snapshot, because disputes and regulators will ask why a payment was allowed months later.

Events, feeds and withdrawals

Every committed journal produces an outbox event such as TransferCompleted{transfer_id, from, to, amount, held}. Consumers must be idempotent on the event id because the relay delivers at least once. Notifications send push and email; feed builders write per-user activity rows; the balance view keeps a cache for display only. None of these may be used to authorise spending: spending checks always read the ledger account row under a lock or conditional update.

Withdrawal is the outbound mirror of funding. A request debits available into a withdrawal-in-flight account, then a payout worker submits to the bank rail. Standard withdrawals batch onto slower, cheaper rails; instant withdrawals use push-to-card or real-time bank rails where available and charge a fee posted to the fee account in the same journal. When the rail confirms, in-flight is cleared against bank clearing; when it rejects, a reversal journal restores the balance.

Scaling the ledger and hot accounts

Shard ledger accounts by owner so a user's accounts live together. A send between two users on different shards cannot be one local transaction, so either use a database with distributed transactions, or split it: debit the sender into a per-shard transfer-in-flight account in one transaction, then credit the recipient from in-flight on the other shard, driven by the outbox. Money in flight is visible and accounted for at every instant, which auditors prefer to an invisible two-phase commit.

Hot accounts, such as a charity receiving thousands of donations a minute or a system clearing account, serialise on one row lock. Split them into N sub-accounts chosen by hash of the transfer id and sum them for display, or append postings without updating a balance row and compute the balance periodically. A connection-multiplexing proxy, the role Hera plays at PayPal, keeps thousands of service instances from exhausting database connections.

Failure modes

FailureWhat goes wrongDefence
Client retry after timeoutSecond transfer createdIdempotency key claimed before any side effect
Crash after card capture, before ledger postCard charged, no transferRecovery worker resumes from CAPTURED state
Event published but DB rolled backRecipient notified of money they do not haveOutbox written in the ledger transaction
Risk service slowSend latency blows upCaller-side deadline and rule fallback
Bank debit returned after recipient withdrewNegative balance, real lossExposure limits, holds, collections, loss account
Balance cache used for authorisationDouble spend under concurrencyAuthorise only against ledger row with conditional update
Postings edited to fix a bugHistory no longer explains balancesCorrections only as reversal journals

Trade-offs

Holding recipients' money reduces fraud losses and annoys honest users; the threshold is a business decision that risk models only inform. Crediting before bank settlement makes the product feel instant and turns the platform into a lender. Splitting cross-shard transfers keeps databases simple and adds an in-flight state that support staff must understand. A pure append-only ledger without balance rows scales writes beautifully and makes authorisation reads more expensive.

For a contrast with a public real-time rail where the central switch, not the wallet, moves money between banks, see UPI's architecture.

What to do next

  1. Draw your ledger accounts per user and per system role, and write down the invariant that every journal sums to zero per currency.
  2. Implement the send endpoint with an idempotency table that stores a request hash, and test same-key-different-body and concurrent-retry cases.
  3. Put a caller-enforced deadline on risk scoring and define the rule-based fallback before you need it.
  4. Order every send as: secure external funds, then post one internal journal with its outbox row.
  5. Model bank returns and chargebacks as reversal journals and run a game day where a funded transfer is returned after withdrawal.
  6. Build a nightly reconciliation that recomputes balances from postings and compares clearing accounts with processor and bank statements.
Key takeaway: A P2P transfer is a small saga wrapped around one balanced journal: claim the idempotency key, get a risk decision within a deadline, secure external funds, then post debits and credits that sum to zero together with an outbox event. PayPal has published pieces of its infrastructure, not this design, so treat it as a reference. Its strength comes from an append-only ledger, reversals instead of edits, explicit holds and exposure limits for money that is not final yet, and derived views that never authorise spending.