← Back
Home › Systems › Payments and commerce

marketplace-payouts

Accepts a buyer's payment and splits what is owed among sellers, the platform and other parties using a double-entry ledger of replayable postings.

Stripe Connect

Open the marketplace-payouts pagemarketplace-payouts Hdr histogramdata-structures: Log-linear bucketed histograms with bounded relative error for latency percentilesDeterministic simulationtestingdistributed: Run the whole system in one thread under a seeded scheduler with simulated network, disk and clock, so every failure replays from its seed.›Durable workflow executiondistributed: Ordinary code made crash-proof by logging every side-effect result and replaying the code from that log; activities run at least once, workflow logic must be deterministic.›Primary-backup replicationdistributed: One node orders all writes and ships them to standbys; the acknowledgement rule (none, one, all) sets what a crash loses, and an external arbiter decides who takes over.›Transactional outboxdistributed: Write the event as a row in the same local transaction as the state change; a relay publishes it later at-least-once, so state and events never diverge.›Currency cross ratetriangulationfinance: Deriving cross rates through a base currency with correct precision, inversion and spreadsDouble-entry ledgerfinance: Append-only journal of transactions whose postings sum to zero per currency; balances are derived, so money can only move, never appear or vanish.›Idempotency keysfinance: Client-chosen unique key per logical operation, stored with a request fingerprint and the final response, so retries replay the first result instead of acting twice.›Iso 20022 messagesfinance: ISO 20022 XML payment and securities messages (pacs, pain, camt, sese): structure and validationLargest remainder roundingfinance: Splitting an amount into parts that sum exactly (largest remainder / Hamilton) for allocations, splits and prorationsMoney representationfinance: Store money as integer minor units with an explicit currency exponent, compute in wider integers, and round once with a named rounding mode.›Multilateral nettingfinance: Netting obligations across many parties into one net position per participant per currencyPayment state machinefinance: Persisted state machine for a payment lifecycle (authorize, capture, void, refund) that treats timeouts as unknown, retries with the same key and applies webhooks idempotently.›Reconciliationfinance: Match internal ledger records against bank and processor statements by reference, then amount and date window; every unmatched or mismatched item becomes an exception.›Process supervisionhardware-os: A parent process starts services, reaps their exits and restarts them under a delay and burst-limit policy, then stops them with SIGTERM before SIGKILL.›Webhook deliverynetworking: Reliable server-to-server HTTP callbacks: signed POSTs, 2xx-means-done, retries over days with jitter, endpoint disabling, SSRF guards and idempotent receivers.›Coverage-guided fuzzingsecurity: Mutate inputs, run an instrumented target, and keep any input that reaches a new code edge; feedback turns blind guessing into incremental search.›Envelope encryptionsecurity: Encrypt each object with its own data key and wrap that key under a KMS-held key, so rotation re-wraps 60 bytes and deleting a key shreds data.›Mutual TLSsecurity: TLS where the client also proves a certificate key, so every connection carries a verified, rotatable machine identity.›Point-in-time recoverystorage: Periodic base backups plus a continuously archived change log let you rebuild the state as of any instant, then branch from there.›Business day calendarssystems: Holiday calendars, business-day adjustment rules (following, modified following) and settlement cyclesGraceful drainingsystems: On a termination signal, stop taking new work, finish in-flight work until a deadline, tell clients to reconnect elsewhere, then exit; clients resume from a token.›Sliding-window counterssystems: Count events in the last W seconds with a ring of sub-bucket counters; the two-bucket weighted estimate gives rate limits in 16 bytes per key.›Cooperative schedulingconcurrency: Green threads/coroutines yielding at await points on a user-space schedulerFault injectiondistributed: Inject latency, errors, kills and partitions via proxies, netem or hooksRecord replaydistributed: Record every nondeterministic input (time, randomness, I/O, model and tool outputs, per-tick player input) and re-execute deterministically for debugging, replays and state rebuildHierarchical timing wheelsystems: Timers bucketed by expiry into wheels of slots, coarser wheels for far deadlines: O(1) insert and cancel for millions of timeouts.›Health checkingsystems: Decide per backend whether to route to it using probes and real-traffic errors, with consecutive-count hysteresis, capped ejection and a panic fallback.›Event deduplication windowdistributed: Keep the ids of recently accepted events for a time window and drop repeats; memory stays bounded by rotating time buckets instead of per-entry timers.›Change data capturestorage: Decode the database's own commit log into row-change events in commit order, with a resume position, so any consumer can mirror the data exactly.›Leasesdistributed: A lock that expires on its own: bounded-time ownership that survives crashes and partitions, given bounded clock drift and fencing tokens on writes.›Event sourcingfinance: Persist state changes as an append-only stream of immutable events per entity; current state is a fold over them, guarded by expected-version appends.›Cryptographic hashes (SHA-2,BLAKE3)security: Collision-resistant fingerprints of data; SHA-256 for interoperability, BLAKE3 for speed, HMAC/HKDF for keyed uses.›Durable job queuedistributed: Persisted tasks that workers lease for a visibility timeout, ack on success and retry with backoff; at-least-once execution that survives worker and queue crashes.›PRNGs (PCG, xoshiro)numerics: Small, fast, statistically strong non-cryptographic generators: PCG32 and xoshiro256**, with seeding, parallel streams and unbiased ranges.›+ 18 moreTransparent interceptionnetworking: Redirect traffic to a local proxy (iptables TPROXY/REDIRECT, eBPF) without app changesSyscall interceptionsecurity: Intercept syscalls via ptrace, seccomp user notify or a user-space kernelSlab allocatorsystems: Pre-carve pages into equal-size slots per size class with an in-place free list: O(1) alloc and free, no external fragmentation, cache-warm reuse.›Consumer offsetsdistributed: Each consumer group stores one integer per partition, the next position to read, so a restarted consumer resumes where it left off with at-least-once delivery.›Incremental sync cursorsdistributed: The client keeps one opaque token; each pull returns only the changes after it plus the next token, so sync cost scales with what changed, not with the dataset.›Message sequence numbersdistributed: A per-stream counter on every message lets the receiver detect loss, duplicates and reordering, ack cheaply and resume after reconnect with "send from N".›Fencing tokensdistributed: A number that grows with every lock grant and is checked by the protected resource, so a paused ex-holder's late write is rejected.›Optimistic concurrency controlstorage: Run transactions without locks, record what they read, and validate at commit that nothing changed; fastest when conflicts are rare.›+ 7 moreEbpfhardware-os: Verified in-kernel programs on hooks (kprobes, XDP, tc) with mapsPtrace process controlhardware-os: Ptrace: attach, read and write registers and memory, single-step, intercept signalsSeccomp bpfsecurity: Syscall filtering with seccomp BPF programsSegmented logdistributed: Split an append-only log into files named by their first offset, write only the newest, and find any record by binary search plus a sparse index.›Cursor paginationnetworking: Keyset/opaque-cursor pagination for stable incremental reads from APIs and tables+ 6 more+ 1 more

How it is built

Architecture overview

A marketplace takes one payment from a buyer and owes money to several parties: the seller or driver or host, the platform's own fee, sometimes a courier, a tax authority or a referrer. The system that does this has three jobs. It accepts and settles the buyer's payment through a payment processor. It splits the amount into exact shares and records who is owed what. And it pays out each party to a bank account or card on a schedule, after handling refunds, disputes and holds that can reverse earlier shares. The money moves through real banks that are slow, can fail, and settle on business days, so the heart of the design is bookkeeping that is correct under retries and crashes.

The idea that makes it work is a double-entry ledger with every business event as an idempotent, replayable posting. Amounts are integers in minor units; every posting sums to zero per currency; balances are derived from entries, never overwritten. A payment, a split, a refund, a chargeback and a payout are each a small state machine whose transitions write ledger entries in the same transaction as the state change, and whose external effects (call the processor, send a bank transfer, notify the seller) are triggered from the same transaction through an outbox. If anything crashes, retrying the step either finds the work done (same idempotency key) or finishes it.

Parts:

 buyer ► checkout ► payment service ──► PSP / acquirer      ◄── webhooks (signed, retried)
                         │ state machine, idempotency key            │
                         ▼                                           ▼
                 DB transaction: state row + ledger entries + outbox row
                         │ outbox relay / CDC (at least once)
                         ▼
        ┌──── event stream ────┬───────────────┬──────────────┐
        ▼                      ▼               ▼              ▼
   split engine           payout engine     notifier      analytics
   (shares → ledger)   (schedule, holds,    (sellers)
                        bank instruction)
                            │ bank file / API ──► bank rails ──► statement
                            ▼                                      │
                 reconciliation: ledger ⟷ PSP report ⟷ bank statement ► exceptions queue

How the parts fit together

Money model: integers and exact splits

All amounts are integers in minor units with an explicit currency exponent, as in Money representation: cents for USD, no decimals for JPY, three decimals for some dinar currencies. Arithmetic is done in a wider integer and rounded once with a named mode. A split is the first place this matters. Taking 12.5 percent of 1,003 minor units and 87.5 percent of the rest gives fractions; shares must sum to exactly the total. largest-remainder-rounding (no page yet) gives each party the floor of its exact share and hands the leftover units, one each, to the largest fractional remainders, with a deterministic tie-break (party id) so a replay gives the same answer. The platform's fee is computed as an explicit share, never as "whatever is left", so rounding is attributed. Cross-currency payouts add currency-cross-rate-triangulation (no page yet): rates quoted against a base currency are inverted and combined with stated precision, and the applied rate is stored on the posting.

Ledger: the source of truth for who is owed what

A Double-entry ledger is an append-only journal of transactions whose postings sum to zero per currency. Each party has accounts (pending, available, reserve), the platform has fee and clearing accounts, and the PSP has a receivable account that mirrors what the processor holds. A buyer payment posts: debit PSP receivable, credit seller pending, credit platform fee. Money cannot appear or vanish because an unbalanced transaction is rejected. This is Event sourcing in a restricted and well-understood shape: immutable events, state as a fold, and a correction is a new entry that reverses the old one, never an edit. Two operations racing on the same account (a payout and a refund) are serialised with Optimistic concurrency control: read the account version, compute, write with an expected-version check, and retry on conflict. For very high rates a dedicated ledger engine does the same with batching.

Platforms that use a processor's marketplace product mirror this. Stripe Connect documents that the platform account and each connected account hold their own balances with pending and available amounts, that a platform can also hold a Connect reserved balance used to cover negative balances of connected accounts, and that moving funds between platform and connected balances is not retried automatically on failure (source: Stripe docs, Connect account balances). The caller's own ledger therefore has to record the attempt and own the retry.

Payment lifecycle: unknown is a state

Payment state machine persists each payment's state (created, authorised, captured, voided, refunded, disputed). Its key rule is that a timeout is not a failure but an unknown, resolved by re-sending the same request with the same key or by querying the processor. Idempotency keys is the contract: the client generates a unique key per logical operation, the server stores a request fingerprint and the final response, and a retry replays the first result. Stripe's API documents the same behaviour: it saves the status code and body of the first request made with a given key and returns them on retries, keys are up to 255 characters, and for API v1 keys may be pruned after at least 24 hours (source: Stripe API reference, idempotent requests). The same fingerprint check catches a key reused with different parameters. A Hierarchical state machine keeps the transition table small: a shared parent state ("settled") holds the reactions common to captured, partially refunded and disputed children, and a child overrides only what differs.

Event plumbing: state and messages must not diverge

After a capture commits, other services (split, notifier, payout) must hear about it. Writing to the database and then publishing to a queue can lose or duplicate the message across a crash. The Transactional outbox writes the event as a row in the same local transaction as the state change; a relay publishes it at least once afterwards. The relay is either a poller or Change data capture, which reads the database's own commit log so events come out in commit order with a resume position. Consumers see duplicates, so they apply Event deduplication window or make handling idempotent by event id. Incremental sync cursors lets a consumer, or a seller-facing API, ask "everything after this token" and be cheap regardless of history. Inbound, processors call back by Webhook delivery: signed POSTs, 2xx means done, retries with jitter, and endpoints that must be idempotent; Stripe documents signature verification of the raw body against an endpoint secret (source: Stripe docs, webhooks). Webhooks may arrive out of order or twice, so handlers compare against the stored state and ignore stale transitions.

Payout engine: long, slow, and must not run twice

A payout is a workflow: check the seller's available balance and holds, apply the schedule, create the bank instruction, wait for the bank, mark paid or reverse. It spans days, so Durable workflow execution fits: workflow code is logged and replayed after crashes, activities (call bank API, post ledger entry) run at least once and are idempotent, and timers are durable. The engine underneath is a Durable job queue with leases, backoff and a dead-letter path for payouts that keep failing (wrong account, closed account) and need a human. Scheduling thousands of "pay this seller on Friday" timers uses a Hierarchical timing wheel for O(1) insert and cancel. Dates come from business-day-calendars (no page yet): holidays, the "following" or "modified following" adjustment rule, and settlement cycles (a transfer started Friday evening arrives Monday or Tuesday). Schedules are a product feature as well: Stripe documents automatic payouts to connected accounts configured by interval (for example weekly on named days), a minimum balance per currency, and a settlement delay in days (source: Stripe docs, manage payout schedule).

For bank instructions between institutions, iso-20022-messages (no page yet) covers the XML message families (the graph names pacs, pain, camt, sese). When many parties owe each other, multilateral-netting (no page yet) collapses obligations into one net amount per participant per currency before anything moves, which cuts bank fees and exposure.

Only one payout run may be active per account. A leader that pauses (a long GC, a network split) can wake up and write after a new leader took over; Leases gives time-bounded ownership and Fencing tokens lets the database reject the old holder's late writes.

Reconciliation: trust but verify

The ledger says what should have happened; the PSP report and the bank statement say what did. Reconciliation matches records by reference first, then by amount and a date window; unmatched or mismatched items become exceptions for a person. Matching millions of rows is a Hash join on the reference key; summaries by day and currency use hash-aggregation (no page yet). Run it daily; an exception queue that is never empty is itself a finding.

Protection

Bank account numbers and identity documents are encrypted per record: Envelope encryption wraps a data key under a KMS key, so rotating the key re-wraps small values and deleting a key shreds data; the cipher is an AEAD (AES-GCM, ChaCha20-Poly1305) that also detects tampering. Connections to partners that offer client certificates use Mutual TLS. Velocity limits (payout attempts per seller per hour, onboarding attempts per IP) use Sliding-window counters. A Circuit breaker in front of each PSP and bank client stops piling timeouts onto a failing dependency and lets traffic route to a second processor.

Storage and recovery

The ledger database is the system. Its durability rests on the Write-ahead log: fsync before acknowledging. Primary-backup replication with a synchronous standby limits what a crash can lose, at the cost of write latency; the alternatives are quorum replication and consensus. Point-in-time recovery rebuilds state as of an instant, which is how a bad deploy that posted wrong entries is investigated, though corrections are still made by new entries and not by rewinding history.

Verification

Bugs here are interleavings: a webhook arriving between two steps of a payout, a crash after the bank call but before the ledger write. Deterministic simulation testing runs the services in one thread with seeded scheduling and injected faults (the graph lists fault injection as a nested technique), so every failure replays from its seed. Coverage-guided fuzzing targets the parsers of partner files and webhook bodies.

Operations

Process supervision keeps the API, the processor-event consumers, the outbox publisher and the payout workers running and restarts them when they exit; a restarted worker resumes from persisted state, never from memory. Graceful draining is how they are deployed: a node stops accepting new payment requests and new webhook deliveries, finishes in-flight postings and bank calls (or leaves them in a persisted, resumable state), and tells the load balancer to send traffic elsewhere, so a deploy cannot cut a payout between the bank call and the ledger write. hdr-histogram (no page yet) records latency of the payment and refund paths and the delay between a processor event arriving and its ledger posting as percentiles; a growing tail on the posting delay is the early sign that consumers are falling behind.

Data and control flow

Flow 1: a purchase with a split

  1. Checkout creates a payment with an idempotency key; the payment row starts in created.
  2. The service calls the PSP with the same key. A timeout leaves the payment unknown and a reconcile job asks the PSP for the outcome.
  3. On authorisation and capture, one DB transaction updates the state, posts ledger entries (PSP receivable debit; seller pending, courier pending and platform fee credits, computed by largest remainder) and writes outbox rows.
  4. The relay publishes events; the notifier tells the seller; the split engine has already written exact shares in step 3.
  5. After the dispute or holding window, a release job moves the seller's share from pending to available with a ledger transaction.

Flow 2: a weekly payout

  1. A timer fires for the seller's schedule and starts a workflow with a deterministic id (seller, period), so a duplicate start is a no-op.
  2. An activity computes payable: available balance minus holds and reserve, rounded to minor units, above the minimum.
  3. It posts a ledger transaction moving funds from seller available to a payout-in-flight account, and records a bank instruction in the same transaction.
  4. The bank instruction is sent through the outbox; the workflow waits for the rail's confirmation with a timeout.
  5. On confirmation it posts payout-in-flight to cash; on failure it reverses the earlier posting and flags the seller account.
  6. Reconciliation later matches the transfer against the bank statement.

Flow 3: refund after payout

  1. A refund request is validated against the original payment and remaining refundable amount.
  2. Ledger: debit seller available (or create a negative balance), credit PSP receivable; the PSP refund call uses an idempotency key derived from the refund id.
  3. If the seller's balance is negative, later payouts net against it, or the platform covers it from a reserve; Stripe describes a reserved balance for this purpose on the platform side.

Scaling

  1. Correctness before throughput. Single-writer-per-account ordering and idempotency are limits that matter at one transaction per second as much as at a thousand.
  2. Hot accounts. The platform's fee account takes a posting per payment; batch or shard it into sub-accounts and sum, rather than serialising every payment on one row.
  3. Database writes. Group commit; a dedicated ledger store for very high rates; read replicas for seller dashboards.
  4. Payout fan-out. Timers and activities are cheap; the limit is the bank API rate. Queue per rail with rate limits and backoff.
  5. Reconciliation volume. Partition by day and PSP; hash join keyed by reference; stream rather than load.
  6. Regions and currencies. Multiple currencies mean multiple balances per account; cross-border payouts add netting and cut-offs. Keep each currency's ledger separately balanced.

Failure modes

FailureSymptomCauseFix
Double payoutSeller paid twiceRetried activity without idempotency; two leadersWorkflow id per (seller, period); idempotency key to bank; fencing tokens
Lost eventSeller never notified or shares not postedPublish after commit failedTransactional outbox or CDC
Duplicate postingBalance too highWebhook delivered twiceDedupe by event id; unique key on ledger transaction
Cents offShares sum to total plus or minus oneFloating point or per-share roundingInteger minor units; largest remainder
Payment stuck in unknownBuyer charged, order pendingTimeout treated as failure or ignoredPersist unknown; query PSP; reuse key
Negative seller balancePayout blocked, support ticketsRefund or chargeback after payoutReserve policy; net against future payouts; platform covers by rule
Payout returnedBank returns funds days laterWrong or closed accountReverse posting; notify seller; hold until bank details change
Reconciliation driftExceptions growMissed statement, fee not modelled, timingDaily run; model fees as postings; age exceptions
Leaked bank detailsSensitive data in logs or backupsPlain storageEnvelope encryption; log redaction; restricted decryption role
Webhook storm or outagePSP retries pile upSlow handler or downtimeFast ack plus queue; idempotent handlers; endpoint health checks

Real implementations

namewhat it isrepolicencenotes
Kill BillSubscription billing and payments platformhttps://github.com/killbill/killbillApache-2.0Payments across gateways, invoicing; docs https://docs.killbill.io
TigerBeetleFinancial transactions databasehttps://github.com/tigerbeetle/tigerbeetleApache-2.0Debit and credit ledger engine; docs https://docs.tigerbeetle.com/
BlnkOpen-source double-entry ledgerhttps://github.com/blnkfinance/blnkApache-2.0Transactions, balances, reconciliation
HyperswitchComposable open-source payments infrastructurehttps://github.com/juspay/hyperswitchApache-2.0Modules for payments infrastructure; docs https://docs.hyperswitch.io
TemporalDurable execution platformhttps://github.com/temporalio/temporalMITWorkflow engine for payout runs; docs https://docs.temporal.io/
DebeziumChange data capture platformhttps://github.com/debezium/debeziumApache-2.0Row-level change events from database logs
LagoUsage-based billinghttps://github.com/getlago/lagoAGPL-3.0Billing side rather than payouts

Stripe Connect and Adyen for Platforms are proprietary services of this shape with public documentation: https://docs.stripe.com/connect (account balances, separate charges and transfers, payout schedules) and https://docs.adyen.com/platforms/ (legal entities, balance accounts, transfer instruments, split payments). The graph lists Uber, PayPal, DoorDash, Airbnb and eBay as users of this kind of system.

Sources

  • Stripe Connect documentation: https://docs.stripe.com/connect ; account balances https://docs.stripe.com/connect/account-balances ; separate charges and transfers https://docs.stripe.com/connect/separate-charges-and-transfers ; payout schedules https://docs.stripe.com/connect/manage-payout-schedule
  • Stripe API idempotent requests, https://docs.stripe.com/api/idempotent_requests ; webhooks, https://docs.stripe.com/webhooks
  • Adyen for Platforms documentation, https://docs.adyen.com/platforms/
  • Transactional outbox pattern, https://microservices.io/patterns/data/transactional-outbox.html
  • PostgreSQL logical decoding, https://www.postgresql.org/docs/current/logicaldecoding.html (streaming row changes from the write-ahead log); continuous archiving and point-in-time recovery, https://www.postgresql.org/docs/current/continuous-archiving.html
  • Repositories listed in the table; licences read from each LICENSE file because the GitHub API was rate-limited.

Similar systems

Share the most building blocks with this one.