Omnibus Accounts: Structure, Sub-Ledgers, and Reconciliation
Post your first omnibus deposit
Clone the Formance Ledger on GitHub, run it locally, and post one pending-to-available omnibus deposit against your own per-customer account paths.
Post your first omnibus deposit
Clone the Formance Ledger on GitHub, run it locally, and post one pending-to-available omnibus deposit against your own per-customer account paths.
Your customers' money sits across one or more omnibus accounts at sponsor banks. Each bank reports a single balance per account, and your team's transactional table is the only record of which customer owns each dollar. The arrangement works until a sponsor bank, an auditor, or a regulator asks you to prove ownership down to the cent.
At the end of the week, the omnibus account statement closes to one number. The sum of your per-customer balances is different. Two automated clearing house (ACH) batches are still in flight, and a payout file settles at the end of the day. No team can say whether the difference is a timing issue or a double-posted callback, and money may have left.
Keeping the sub-ledger provably equal to the omnibus account's balance requires four storage-layer invariants, a five-step cutoff procedure that keeps in-flight funds visible, and an evidence package a sponsor bank or examiner can trust.
An omnibus account, also called a for-benefit-of (FBO) account, is a single bank account that holds funds on behalf of many underlying owners.
The bank records one balance, and you record who owns each unit of the balance.
The bank balance is the figure the statement reports, while the operational balance is money you own inside the same account, such as earned fees and float you pre-funded.
The mechanics and per-customer liability sub-ledger records each customer's claim. At any cutoff, the sum of settled customer sub-ledger balances plus the operational balance must equal the omnibus bank balance.
The account paths in an omnibus sub-ledger look like this:
For outbound flows, @users:48213:pending_payout reserves funds the customer has authorized but the rail hasn't yet confirmed.
The @platform:banks:bankA:ach: pending_out holds customer payout funds in flight. It is money that has left the customer's pending path and is awaiting rail confirmation before it reaches @world.
The sub-ledger stays provably equal to the bank balance only if four properties hold in the storage layer, and application-code checks are not a substitute. The properties are double-entry balance to the omnibus cash account, append-only immutability, write idempotency, and per-customer isolation and provenance.
Formance is an open-source, programmable core ledger that unifies fiat and digital assets with regulatory-grade traceability. It enforces all four invariants in the storage layer: double-entry accounting, an immutable log, atomic postings keyed by reference, and a multi-segment account namespace with metadata. A per-customer attribution file becomes a query against the ledger.
Every posting that changes a customer balance must change the named bank boundary or pending boundary by the same amount, so the sum identity holds by construction.
When customer balances live in a separate table updated by application code, the sum identity is only an assertion the code must maintain.
A worker can crash between the balance update and the cash update. A retry might update one side twice, or someone might manually fix one table. A partial, duplicate, or one-sided manual update leaves the sum drifting from the bank balance, with no posting explaining why.
The nightly check then reports a difference without a cause. In a double-entry core ledger, the posting that credits @users:48213:available is part of the same atomic transaction that debits the source path, so the two sides cannot diverge.
Corrections are new postings, never edits, so the record a bank or examiner sees months later is the same record you saw at cutoff.
Append-only immutability makes a sub-ledger reproducible by someone who is not you. If a balance can be edited in place, the sub-ledger loses the ability to state what it knew and when.
If every change is a dated posting, you can replay the state as of any past reconciliation point. Ordering, cutoff, and reference integrity are fundamental for ledger integrity.
A bank or payment-rail callback retried three times must produce exactly one posting, keyed on the external reference, or the sub-ledger will overstate what the bank holds.
Rail callbacks and payment service provider (PSP) webhooks may retry on timeout with the same payload, and without a key each retry posts as a new deposit. A deduplication key on each payment catches the second run.
Writing idempotency in the sub-ledger means using an idempotency key or reference tied to a stable, unique transaction identifier. A second write carrying the same key returns the first posting instead of creating another.
Each customer's balance must be addressable on its own path. When funds sit at more than one bank, the sub-ledger must also record which bank holds each customer's units. The customer ID in a path such as @users:48213:available provides the isolation.
Bulk transfers without owner identification leave transactions missing from attribution. Per-customer provenance closes attribution so an omnibus account withdrawal doesn't implicate any other customer's custodial assets.
Sub-ledger to bank balance reconciliation has five steps: pick a reconciliation point, model in-flight funds as pending, compare settled cash at cutoff, resolve any residual discrepancy, and run the aggregate balance check daily, then automate it.
Reconciliation must use a fixed daily cutoff because it compares each bank and currency's sub-ledger settled bank-boundary balance against the corresponding statement balance.
Pick a reconciliation point and keep it at the same time every day. The specific hour matters less than keeping it fixed.
Once the cadence drifts, you lose the ability to compare transactions. The practical choice is a point after the bank's last intraday statement. For teams on 24/7 rails, pick an hour of lowest volume and freeze it.
The cadence belongs in policy and should run as both an internal check (sub-ledger against itself) and an external check (sub-ledger against the bank statement).
The internal check confirms the sum identity still holds inside the sub-ledger. The external check confirms the sub-ledger still matches what the bank thinks it holds. Both run at the same cutoff.
Model deposits as pending until the bank confirms settlement, and payouts as pending until the rail confirms. A customer's funds exist on @users:48213:pending from the moment the file arrives and move to @users:48213:available only on confirmation.
In Numscript, Formance’s purpose-built language for describing financial transactions, a $2,500,000 ACH deposit for customer 48213 arriving at bank A is recorded as two transactions.
The accounts are @platform:banks:bankA:ach:pending for cash reported by the ACH file but not yet settled, @platform:banks:bankA:main for settled cash at bank A, and the customer's pending and available paths.
On the file receipt, the amount moves from the pending bank boundary to the customer's pending path.
// Posted via API with reference="ach48213-received" for idempotency
// ACH_DEPOSIT_RECEIVED
// Event: record customer 48213's ACH deposit as pending at bank A
send [USD/2 250000000] (
source = @platform:banks:bankA:ach:pending allowing unbounded overdraft
destination = @users:48213:pending
)
set_tx_meta("event_type", "ach_deposit_received")
set_tx_meta("deposit_id", "ach48213")
On rail confirmation, the amount moves from the customer's pending payout path to the outbound bank boundary (@platform:banks:bankA:ach:pending_out), then from that outbound boundary to @world.
// Posted via API with reference="ach48213-settled" for idempotency
// ACH_DEPOSIT_SETTLED
// Event: clear the pending ACH boundary and make customer 48213's deposit available
send [USD/2 250000000] (
source = @users:48213:pending
destination = @platform:banks:bankA:ach:pending
)
send [USD/2 250000000] (
source = @platform:banks:bankA:main allowing unbounded overdraft
destination = @users:48213:available
)
set_tx_meta("event_type", "ach_deposit_settled")
set_tx_meta("deposit_id", "ach48213")
Payouts follow the mirror pattern. On initiation, the amount moves from the customer's available path to a payout-specific pending customer path.
// Posted via API with reference="payout9988-initiated" for idempotency
// PAYOUT_INITIATED
// Event: reserve customer 48213's funds for an outbound ACH payout
send [USD/2 50000000] (
source = @users:48213:available
destination = @users:48213:pending_payout
)
set_tx_meta("event_type", "payout_initiated")
set_tx_meta("payout_id", "payout9988")
On rail confirmation, the amount moves from the customer's pending path to the pending outbound bank boundary, then from the settled bank boundary to @world.
// Posted via API with reference="payout9988-confirmed" for idempotency
// PAYOUT_CONFIRMED
// Event: release customer 48213's pending payout as settled outbound at bank A
send [USD/2 50000000] (
source = @users:48213:pending_payout
destination = @platform:banks:bankA:ach:pending_out
)
send [USD/2 50000000] (
source = @platform:banks:bankA:ach:pending_out
destination = @world
)
set_tx_meta("event_type", "payout_confirmed")
set_tx_meta("payout_id", "payout9988")
The @platform:banks:bankA:ach:pending source identifies bank A for each pending deposit at posting time.
In the four-path model, the negative balance of @platform:banks:bankA:main records settled cash introduced through bank A, and the absolute value of the balance can be reconciled to the statement. It is offset by settled @users: balances plus @platform:operational.
When the sub-ledger write carries the ACH trace number or payout ID as the transaction reference, a replayed file returns the original transaction instead of creating a duplicate.
At the cutoff, compare settled cash for each bank and currency against the most recent statement. List any amount still on @users:48213:pending separately.
Only pending balances may explain a timing difference. Anything that cleared the sub-ledger as settled but doesn't appear on the statement is a real discrepancy.
The comparison runs per bank and per currency. A sub-ledger that matches in total but drifts for one bank or one currency is still broken. The mismatch just hasn't surfaced yet.
Treat any residual variance as a discrepancy and resolve it without undue delay, unless the variance arises solely from timing differences. If one record shows that a greater amount is needed, assume that record is accurate and fund the shortfall from your own operational balance while you investigate.
Fund the shortfall and then find it. If the difference persists after you rule out timing and find no duplicate, the posting history identifies the outbound transaction, the authorization, and the counterparty, so the investigation starts from a reconstructable timeline instead of a blank page.
The baseline is an aggregate balance check at the cutoff. Compare settled cash in the sub-ledger against the bank statement, for each bank and currency, every day.
Line-by-line comparison of postings against statement lines is a separate job. The sum identity sits behind the aggregate check.
Formance Reconciliation runs balance checks at an explicit point-in-time cutoff. Reconciliation compares Formance Ledger records with external balances ingested through Connectivity, so you can reconcile as of the chosen cutoff regardless of when the statement data arrived.
A sponsor bank or regulator typically asks for a per-beneficial-owner balance file as of the reconciliation point and the omnibus statement the file reconciles to. The package also includes the posting history that produced both. The evidence must be delivered daily and remain reproducible later.
The per-owner file consists of beneficial-owner records, the balance attributable to each owner, and the ownership category. Beneficial-owner records must be kept in a specified electronic format.
After the April 2024 collapse of Synapse Financial Technologies, consumers were frozen out of their across-partner-bank omnibus accounts. The Consumer Financial Protection Bureau (CFPB) attributed the loss to inadequate recordkeeping between Synapse and its partner banks.
Sponsor banks have tightened what they require from fintech partners since. A daily data feed is only useful if the bank can rebuild per-customer positions from the feed alone, and rebuilding those positions requires the posting history behind each balance.
If you can't attribute every dollar in your omnibus account to a named customer before the next reconciliation point, you can't prove ownership when the sponsor bank, auditor, or regulator asks.
Pick any customer and answer three questions: which bank held the balance, which postings produced it, and what was still pending.
If any answer requires joining tables by hand, run the following checklist against your current sub-ledger:
Every failed answer is an acceptance criterion for a control to fix before the next reconciliation point. Starting with the ledger means your per-beneficial-owner file, your reconciliation output, and your audit response all read from the same source of record instead of three systems you have to keep in sync.