Building a BaaS Ledger, Part 1: Designing Your Ledger Architecture
Building a BaaS ledger: the ledger architecture that answers whose money is in the FBO pool in minutes, not a weekend of reconstruction.
Banking-as-a-Service has a limitation: the bank only sees the pool. This is the FBO (for-benefit-of) accounts tied to you, the BaaS provider.
Everything else is your responsibility, and your entire business rests on getting it right. A fintech disputes their month-end total. A support ticket asks why a customer's balance is short. An incident at 2am turns on who was owed what that afternoon. An auditor flags a discrepancy between the bank’s statement and your ledger. Every one of those is the same question: whose money is this?
How fast you can answer it depends on your ledger. If done well, your ledger leaves a clear audit trail to trace the gap in minutes. Done badly, and it becomes weeks of reconstruction across three systems and a spreadsheet, a remediation filing, and a sponsor bank asking questions you have no answers to.
No rail will produce that answer for you either. Each one reports only what it did. None of them knows what was supposed to happen, so none of them will ever tell you a payout went out twice or a fee was never collected. Only your ledger holds the expectation, so only your ledger can show you the gap.
This article covers exactly how to do that. We’ll use Blnk Core, our open-source ledger, to illustrate the code implementation, but the same principle applies if you build the same thing yourself.
Principle 1: Design for attribution
Say your BaaS offers wallets, accounts, and cards, and it’s month-end. The auditor emails you asking for every balance belonging to Acme Finance across all your products. Your database has 40,000 balances in one table; some have an org_id, some don’t; some balances were created before you started tracking org_id; others are scattered across three tables because six months ago you redesigned “wallets” as “accounts.”
It turns what should be a routine lookup into a tedious, weekend-long forensic investigation.
The way out is to design your ledger with attribution built in from the start. Every balance must be addressable by parameters defined by what you actually manage in the real world. Using our earlier example, we have two:
- Who owns it? The fintech, not the end user.
- What product does it belong to? Accounts, wallets, or cards.
Ignore this or get it wrong and you’re not just running a migration later, your reporting and reconciliation would become a mess you have to deal with every week like our example above.
Blnk enforces this at the top level with ledger folders:
// The name and the metadata both answer the two questions.
async function enableAccountService(orgName: string, orgId: string) {
return blnk.Ledgers.create({
name: `${orgName}_Accounts`,
meta_data: {
org_id: orgId, // the fintech's ID in your system
product: "Accounts"
},
});
}
// Onboarding a fintech is one call:
await enableAccountService("Acme Finance", "org_3f9a1c");
await enableAccountService("OrbitPay", "org_7b22e0"); {
"ledger_id": "ldg_9e2c4a1f-7b3d-4f08-a651-3c8d2e6a9b47",
"name": "OrbitPay_Accounts",
"created_at": "2026-08-27T06:51:03Z",
"meta_data": {
"org_id": "org_7b22e0",
"product": "Accounts"
}
} With this, each balance simply names its container when it is created (see below), and Blnk makes sure that it cannot be created without one. Ownership is now defined at write time instead of being reconstructed at read time.
Add a new product later and the design still holds. If you launched loans six months from now and OrbitPay still wants in, you simply add one new folder for OrbitPay to your ledger:
async function enableLoanService(orgName: string, orgId: string) {
return blnk.Ledgers.create({
name: `${orgName}_Loans`,
meta_data: {
org_id: orgId, // the fintech's ID in your system
product: "Loans"
},
});
}
// Onboarding OrbitPay to loans:
await enableLoanService("OrbitPay", "org_7b22e0"); {
"ledger_id": "ldg_4c1b8e20-2a9f-4d71-b6e3-0f5a8c1d2e90",
"name": "OrbitPay_Loans",
"created_at": "2026-08-27T06:54:18Z",
"meta_data": {
"org_id": "org_7b22e0",
"product": "Loans"
}
} Alternatively, if you retired a product or OrbitPay offboards from Accounts, you simply stop creating balances in that folder. You don’t delete it and you don’t move its balances elsewhere. You retain the history while safely closing the balances and returning the customers’ funds back to the fintech.
Of course, this is not the only valid ledger design. Your use case, regulatory environment, and reporting needs may push you toward a different architecture. However, what cannot change is that your ledger still has to clearly track attribution on every level that matters to your product.
Principle 2: Identity before balance
Ledger folders give you clean attribution. But attribution means nothing if the identity behind a balance is fake or non-existent.
The bank sees one FBO pool. You see thousands of balances. When law enforcement or your sponsor bank flags a transaction, you are the only one who can answer who owns the money. One unverified or missing identity is enough. You cannot reliably answer the bank’s question: who made this transaction? And the bank will not wait while you work it out. The lien lands on the entire FBO account, and every fintech on your platform loses access to their funds.
To solve this, every balance must link to an identity. One way is to link a user ID in the metadata during balance creation:
// The balance carries its owner's ID in metadata.
async function createAccountBalance(customerId: string) {
return blnk.LedgerBalances.create({
ledger_id: "ldg_orbitpay-accounts", // OrbitPay_Accounts
currency: "USD",
meta_data: {
customer_id: customerId, // the end user in your system
},
});
} {
"balance_id": "bln_6d2a1c80-9e4f-4b17-a3c8-1f0e7d5b2a44",
"ledger_id": "ldg_orbitpay-accounts",
"balance": 0,
"inflight_balance": 0,
"currency": "USD",
"created_at": "2026-08-27T07:12:04.218Z",
"meta_data": {
"customer_id": "cus_8a2f41"
}
} Alternatively, Blnk supports managing the financial identity directly in your ledger:
Create the identity for the end user
Store the basic identity info for the end user directly in your ledger. Blnk supports the popular KYC fields out of the box. For custom fields, use the metadata to store them.
Exclude sensitive information such as National IDs; store those in a vault and link the ledger’s identity record via a vault ID.
Requestconst identity = await blnk.Identity.create({ identity_type: "individual", first_name: "Jordan", last_name: "Ellis", dob: new Date("1992-03-14"), email_address: "[email protected]", phone_number: "+14155550142", nationality: "American", category: "customer", meta_data: { org_id: "org_7b22e0", // OrbitPay's ID in your system customer_id: "cus_8a2f41", kyc_status: "verified", vault_id: "vlt_9f2e7c41", // sensitive documents live in your vault }, });200 OK{ "identity_id": "idt_jordan-ellis", "identity_type": "individual", "first_name": "Jordan", "last_name": "Ellis", "dob": "1992-03-14T00:00:00Z", "email_address": "[email protected]", "phone_number": "+14155550142", "nationality": "American", "category": "customer", "created_at": "2026-08-27T07:41:22.105Z", "meta_data": { "org_id": "org_7b22e0", "customer_id": "cus_8a2f41", "kyc_status": "verified", "vault_id": "vlt_9f2e7c41" } }Create a balance linked to the identity
Now create a balance in the right ledger container (from Principle 1) with the created identity.
Requestconst balance = await blnk.LedgerBalances.create({ ledger_id: "ldg_orbitpay-accounts", // OrbitPay_Accounts identity_id: identity.identity_id, currency: "USD", meta_data: { customer_id: "cus_8a2f41", account_number: "0112345678", account_type: "checking", }, });200 OK{ "balance_id": "bln_jordan-ellis-checking", "ledger_id": "ldg_orbitpay-accounts", "identity_id": "idt_jordan-ellis", "balance": 0, "inflight_balance": 0, "currency": "USD", "created_at": "2026-08-27T07:58:12.442Z", "meta_data": { "customer_id": "cus_8a2f41", "account_number": "0112345678", "account_type": "checking" } }You can now create multiple balances in different containers for the same end user:
User Ledger Balance (USD) Jordan Ellis OrbitPay_Accounts $13,231.42 Jordan Ellis OrbitPay_Loans – $1,890.00 Jordan Ellis OrbitPay_Cards $0.00 Elizabeth Mackey OrbitPay_Accounts $12.32 Elizabeth Mackey OrbitPay_Cards $234.23
It is best practice to treat identities as perpetual for the same reason folders are. When a customer offboards or their verification lapses, you stop new activity on their balances and close them out; you should not delete the identity.
Onboarding flows will keep being tuned for speed, and providers will return verdicts at different times. What cannot change is the link: no balance exists without an identity behind it. A balance you cannot attach to a person is money you cannot explain.
What's next in this series
Your ledger can now answer the questions everything else depends on: which fintech and product every balance belongs to, and which end user holds it.
The rest of this series builds on that structure:
- Transaction workflows: recording deposits, withdrawals, and transfers against these balances without creating money out of thin air, and why every movement should start on the ledger before it reaches a rail.
- Sponsor bank reconciliation: proving your ledger and the bank's statement agree, per FBO account, and catching drift while it is still cheap to fix.
- Multi-currency and FX: running cross-border products on this structure, and tracking your spread and revenue across fintechs.
- Reporting for fintechs: turning the ledger into the statements and exports your fintechs ask for, without handing out raw ledger dumps.
We can’t overstate how much your business’ future success rides on you getting your ledger right. If you want to see how these principles hold up in a running ledger, you can start with our Cloud Sandbox.