8 min read

Building a BaaS Ledger, Part 3: Sponsor Bank Reconciliation

A match diagram of statement lines against ledger transactions, with one unmatched node

In Part 2, we recorded OrbitPay’s deposits, withdrawals, and transfers, and those workflows are on the ledger. You cannot run a successful BaaS on that alone; you still have to reconcile those transactions with the sponsor-bank file so you know the ledger and the bank still agree.

If you skip it, the sponsor bank can hold one balance and your ledger another, and you will not notice the drift until a payout has to go out or a fintech needs their customers’ money. The ledger already has the history. What it cannot tell you is if the bank disagrees, until you reconcile especially when the funds are kept with multiple sponsor banks.

Continuing our example

Same BaaS example as Part 1 and Part 2. OrbitPay is org_7b22e0. Jordan Ellis still sits in OrbitPay_Accounts. You already recorded:

  • a $250 deposit under dep_ach_8841
  • a $100 payout (plus our $1.00 fee) under wd_ach_2290

Both transactions are already on our ledger. The reason you reconcile is to check that the FBO received the $250 and sent the $100. After those two movements, you expect it to hold $150. If you don’t check this as a regular part of operations, 9 out of 10 times you absorb the liability, not your bank provider.

To do that, you need an independent data source that should tell the same story as the ledger. For this BaaS, that is the statement from the bank provider. Whether every fintech shares one FBO or each has its own, you still get their statement and match it to what you recorded.

A provider that doesn’t give you a bank statement is a red flag and you shouldn’t partner with them.

We'll use Blnk's built-in reconciliation engine to match the statement to the transactions already on the ledger.

How you reconcile with Blnk

Reconciliation is a loop you run every time a new statement arrives. You import the file, use the matching rules you already prepared, and run the match. Matched means the statement and the ledger told the same story for that transaction. Unmatched means they did not.

Each run needs three things:

  1. The bank statement. The file from the bank provider. This doesn't come from you.
  2. The matching rule. You prepare this once. It tells the engine how to decide a statement line is the same movement as a ledger transaction.
  3. The run. The engine compares the upload to the ledger.

Importing the bank statement

There is no shared format for statements. If you had more than one sponsor bank, it's likely that their statements come in different shapes. To import this cleanly, Blnk gives you a shared format to convert those statements into. Each line item should become a JSON or CSV row with the following fields extracted from the original file: id, amount, currency, source, description, reference, date.

FieldWhat to put
idUnique id for this line import.
amountAmount the bank printed, e.g. 249.70, the bank took a $0.30 fee.
currencyISO 4217 code, e.g. USD.
sourceWhich provider this file came from, e.g. SponsorBank.
descriptionThe bank's narration for the line.
referenceThe shared id you will match to the ledger transaction. Must be unique.
dateWhen the bank booked it, in UTC, e.g. 2026-03-12T14:02:00Z.

Here's what a sample statement for OrbitPay's transactions would look like:

orbitpay-sponsor-2026-03-12.json
const statement = [
  {
    id: 'stmt_dep_8841',
    amount: 249.7,
    currency: 'USD',
    source: 'SponsorBank',
    description: 'ACH credit, Jordan Ellis',
    reference: 'dep_ach_8841',
    date: '2026-03-12T14:02:00Z',
  },
  {
    id: 'stmt_wd_2290',
    amount: 100,
    currency: 'USD',
    source: 'SponsorBank',
    description: 'ACH debit, Jordan Ellis',
    reference: 'wd_ach_2290',
    date: '2026-03-12T16:40:00Z',
  },
];

Write that array to JSON and upload it. The response gives you an upload_id. That id is the statement, inside Blnk, waiting for a rule.

Request
const uploaded = await blnk.Reconciliation.upload(
  'orbitpay-sponsor-2026-03-12.json',
  'SponsorBank',
);
200 OK
{
  "upload_id": "upload_8c700d1b-09c0-4ef4-9ab1-a0decf3d0aa3",
  "status": "success",
  "record_count": 2,
  "source": "SponsorBank"
}

The deposit row is $249.70, not $250. The payout row is $100, not the $101 you recorded with the fee. The rule has to know this bank writes that way.

Prepare your matching rules

A matching rule defines how Blnk decides whether a ledger transaction and an external record represent the same movement. A rule can contain one or more criteria, and all criteria must match for the records to pass.

The most important criterion is the reference. It identifies the movement across both systems. Amount, date, and other criteria help confirm the match, but they should not be responsible for identifying it. For example: two $100 ACH debits on the same afternoon can have the same amount and date but still be different movements.

Matching by reference

The reference must exist on both records, and the value on the ledger must be the same value the bank or provider uses in its statement.

For deposits, the bank usually creates the reference. Take that value from the notification and use it when creating the ledger transaction. Do not create your own reference after receiving the notification. If the bank's dep_ach_8841 appears on the statement, that exact value needs to be on the ledger transaction.

For withdrawals and other outbound movements, generate the reference before creating the ledger transaction, then send that same value to the bank or payment provider. The provider needs to return that value in its statement. The same approach applies to cards, ACH, and other outbound providers. Do not create a second provider-specific ID and keep your original reference only in meta_data.

The reference finds the pair, but it's not enough. The bank can still write the line differently than you recorded. That is why OrbitPay’s statement said $249.70 on a $250 deposit and 16:40 when your clock said 15:10.

Plan for amount and date drifts

Use amount, currency, and date to validate a match found by reference. In some cases, however, the bank's record may differ slightly from the ledger even when the reference identifies the same movement. This is where allowable_drift comes in.

Amount drift is a decimal percentage of the ledger amount. For example, 0.01 allows a 1% difference. A $100 ledger transaction can therefore match an external amount between $99 and $101. In the same way, date drift is measured in seconds. 3600 allows a one-hour difference.

Do not choose drift values arbitrarily. Start with transactions you already know represent the same movement because they share a reference. Measure the differences between the ledger and external record.

For example:

  • A $250 deposit recorded as $249.70 has a $0.30 difference, or 0.12%.
  • A transaction at 15:10 appearing in the statement at 16:40 has a 5,400-second difference.

Set the drift to cover the largest known difference, then add enough room for the provider's normal behaviour. If the bank can regularly report transactions up to two hours late, 7200 seconds gives you that window.

Your rule looks like this:

createMatchingRule.ts
const rule = await blnk.Reconciliation.createMatchingRule({
  name: 'OrbitPay sponsor bank',
  description: 'Same reference, room for bank fee and clock drift',
  criteria: [
    {
      field: 'reference',
      operator: 'equals',
    },
    {
      field: 'currency',
      operator: 'equals',
    },
    {
      field: 'amount',
      operator: 'equals',
      allowable_drift: 0.01,
    },
    {
      field: 'date',
      operator: 'equals',
      allowable_drift: 7200,
    },
  ],
});

Reuse the rule for each statement from that provider. The statement changes every day, but the provider's formatting and reporting behaviour usually do not. Keep the drifts as narrow as possible and widen it only for a known discrepancy you can explain. If an amount window is wide enough that unrelated transactions can match, reconciliation loses its value.

Run the reconciliation

Start the reconciliation run with the uploaded statement and your matching rule. Both the run and get endpoints return a reconciliation_id. Use it to poll the reconciliation until its status is completed or failed.

Request
const started = await blnk.Reconciliation.run({
  upload_id: uploaded.data.upload_id,
  strategy: 'one_to_one',
  dry_run: false,
  matching_rule_ids: [rule.data.rule_id],
});

const result = await blnk.Reconciliation.get(started.data.reconciliation_id);
200 OK
{
  "reconciliation_id": "recon_6e6feddd-930b-4e3a-8ba1-a3eee659bb3c",
  "status": "completed",
  "matched_transactions": 2,
  "unmatched_transactions": 0,
  "is_dry_run": false,
  "started_at": "2026-03-13T07:12:04.463849Z",
  "completed_at": "2026-03-13T07:12:04.508514Z"
}

In this example, matched_transactions: 2 means the $250 deposit and $100 withdrawal each matched a ledger transaction. The statement and ledger agree on both movements.

Investigating unmatched transactions

unmatched_transactions tells you which statement lines need attention. A deposit may have a reference that never reached the ledger. A withdrawal may have been recorded but never sent, or sent with a different reference. An amount or timestamp may also fall outside the drift allowed by your rule.

When a line should have matched but did not, check the reference, amount, and date before assuming money is missing.

A wrong reference is usually a problem with the integration. An amount or date difference may indicate a bank behaviour that your rule does not account for yet. Fix the underlying workflow or adjust the rule when the difference is a known, repeatable behaviour.

However, do not edit the historical ledger transaction just to make it match the statement.

Wrapping up

A BaaS ledger is only useful if you can trust that it still agrees with the money held at the sponsor bank.

The important part is not getting every statement to show zero unmatched transactions. It is knowing why something did not match. A missing reference points to an integration problem. An unexpected amount may be a fee or an incorrect posting. A timing difference may be normal bank behaviour. Each mismatch should have an explanation, and the ledger should not be changed simply to make the numbers agree.

Run that process whenever a new statement arrives, and reconciliation becomes part of the ledger's operating cycle rather than a cleanup task after something goes wrong. You have a record of what your system says happened, an independent record of what the bank says happened, and a process for finding the difference between them.

If you want to run this against a statement in a live ledger, start with our Cloud Sandbox.