Most developers copy the provider payment statement into their product as-is. The rail says succeeded, so you write a row and flip a status. Deposit in looks like a deposit in your app. Payout out looks like a payout. Same shape, same field, as if the two records meant the same thing.
In real life, this is not the case. A payment could mean different things depending on what product you have: an FX conversion, a spread, an escrow payment, etc. If you store only what the provider tells, you lose all of that history. You now have to hope that nothing breaks, and the provider has your interest at heart. Unfortunately, this is rarely ever the case.
Say Ada deposits $1,000 to pay out to a GBP account. You convert at 0.77, send £770, keep £20. The deposit rail reports $1,000. The payout rail reports £770. If she complains that the recipient was short a couple pounds, how do you respond?
Payment and ledger transaction are different records
A payment is what your payment provider reported. Money came in, or money went out. You get a provider id and a status. That is the rail event. It is one aspect of what happened: the edge. A ledger transaction is one movement of that money: where it came from, where it went, how much, in which currency, and why.
Ada’s send was three movements:
- USD reduced for the convert. $1,000 off Ada.
- GBP sent to the payout account. £770.
- Spread kept as revenue. £20.
A payment does not show them. The deposit provider reports $1,000 in. The payout provider reports £770 out. Each payment is one rail’s in or out. Those movements are what the ledger should hold.
Ignore it and the $1,000 in and the £770 out stay as two successes that cannot explain each other. To do this correctly, you must be able to do the following:
- Record each phase of the movement.
- Reconstruct the path.
- Audit the payments against the movements.
Record each phase of the movement
Ada’s $1,000 does not jump from deposited to paid out:
INSERT INTO transactions (
id,
source_account_id,
destination_account_id,
amount_minor,
currency,
status,
reference,
posted_at
)
VALUES (
'txn_7c91e2a4',
'acc_ada_usd',
'acc_gbp_payout',
100000,
'USD',
'posted',
'pay_in_ada_1001',
now()
); That row is $1,000 leaving Ada. It does not say £770 left, or that you kept £20, or that 0.77 is why those numbers belong together. The payout rail still reports £770 out. You have the same two successes and nothing in the middle. If you flip it and post £770 from her dollars, you invent an amount her USD balance never held.
The right way is to record the movement logically. You reduce the USD first, $1,000 off Ada onto your USD book. Then you record £770 GBP leaving for the payout from your GBP book, and £20 GBP as spread. Since its one conversion event, these three movements should be recorded together. If one side records successfully and the other does not, you have a hole in your ledger that neither payment will show.
You can do this with Blnk using bulk transactions:
const depositId = 'pay_in_ada_1001';
const { data: exchange } = await blnk.Transactions.createBulk({
atomic: true,
inflight: true,
transactions: [
{
precise_amount: 100000,
precision: 100,
currency: 'USD',
reference: `${depositId}_usd_nostro`,
source: 'bln_ada_usd',
destination: '@NostroUSD',
description: 'USD collected for GBP convert',
meta_data: {
deposit_id: depositId,
exchange_rate: '0.77',
type: 'customer',
},
},
{
precise_amount: 77000,
precision: 100,
currency: 'GBP',
reference: depositId,
source: '@NostroGBP',
destination: '@WorldGBP',
allow_overdraft: true,
description: 'GBP to payout rail',
meta_data: {
deposit_id: depositId,
exchange_rate: '0.77',
type: 'customer',
},
},
{
precise_amount: 2000,
precision: 100,
currency: 'GBP',
reference: `${depositId}_spread`,
source: '@NostroGBP',
destination: '@SpreadGBP',
allow_overdraft: true,
description: 'FX spread',
meta_data: {
deposit_id: depositId,
type: 'fee',
},
},
],
}); {
"batch_id": "bulk_c62f200b-905f-4983-a349-cadd279234aa",
"status": "inflight",
"transaction_count": 3
} | What it does | |
|---|---|
| atomic: true | All three succeed together or none of them do. |
| inflight: true | Reserves Ada’s $1,000. Her settled balance does not move until you commit. https://docs.blnkfinance.com/transactions/inflight/creating-inflight |
| @NostroUSD, @NostroGBP | Internal balances for the two FX books. The $1,000 lands on USD. The £770 leaves from GBP. Neither appears or disappears. https://docs.blnkfinance.com/balances/internal-balances |
| @SpreadGBP | The £20 you earned as revenue. |
| @WorldGBP | Money that left to the payout rail. |
Once that convert is reserved, the movement triggers the payout. This function listens for the bulk and sends the £770 with the reference on the GBP leg:
async function onBulkInflight({ batch_id, transactions }) {
const gbpLeg = transactions.find((txn) => txn.destination === '@WorldGBP');
const payout = await payoutProvider.createPayout({
amount: 770,
currency: 'GBP',
reference: gbpLeg.reference,
});
await blnk.Transactions.updateStatus(batch_id, {
status: payout.succeeded ? 'commit' : 'void',
});
} Commit or void is what marks the send done.
That call waits until the payout provider confirms. If they say the £770 left, you commit. If they fail, you void and Ada gets her money so she can try again. This way, your ledger keeps the payment providers honest: you do not apply a convert they never paid, and you do not leave a payment pending if it's already succeeded.
Reconstruct the transaction history
On Ada’s USD balance she only sees that the $1,000 was deducted. The rate, the fee, and what the recipient got sit on the other movements. To show Ada the full details of the transaction, you have to reconstruct it from your records.
Since each movement is now recorded, you can pull each movement from your database and build that history yourself. This wasn't possible prior.
| Field | Value |
|---|---|
| Conversion created at | 11 Mar 2026, 10:02 |
| Amount sent | $1,000 USD |
| Rate charged | 0.77 |
| Fee deducted | £20 GBP |
| Recipient received | £770 GBP |
| Conversion completed on | 11 Mar 2026, 10:04 |
The convert already gave you a batch id. Pull those rows:
const response = await blnk.Search.filter(
{
filters: [
{
field: 'meta_data.QUEUED_PARENT_TRANSACTION',
operator: 'eq',
value: exchange.batch_id,
},
],
include_count: true,
},
'transactions',
); Audit the rails against your ledger
You now have two payment providers saying they did what you asked.
The deposit rail says $1,000 came in. The payout rail says £770 left. If the only row you posted is $1,000 to GBP, you have nothing to check those claims against. A $980 deposit or a £750 payout still looks like success on their dashboards. You would not know, and that is real money lost.
Now that you track money movement, you can audit the providers against the ledger. Reconciliation is how you verify what they said happened is what you expected to happen.
Blnk supports this with the reconciliation engine. Set your matching rule, and periodically reconcile their statements with your ledger, daily or weekly:
await blnk.Reconciliation.createMatchingRule({
name: 'Payment reconciliation',
criteria: [
{ field: 'reference', operator: 'contains' },
{ field: 'amount', operator: 'equals' },
{ field: 'currency', operator: 'equals' },
{ field: 'date', operator: 'equals', allowable_drift: 1800 },
],
}); | How it matches | |
|---|---|
| reference contains | How you verify that both records refer to the same event. |
| amount equals | $1,000 in has to be $1,000. £770 out has to be £770. |
| currency equals | USD against USD. GBP against GBP. |
| date equals | The timestamps can be 30 minutes apart. Rails and the ledger rarely post at the same second. |
A statement line that fails the rule will not match your ledger even though the rail says it succeeded. That gap is the money: a $980 deposit, a £750 payout, a wrong reference that is not this send.
You can open that line, send it back to the provider, and show your finance the $1,000 and £770 you expected. If Ada says she was short, you have an answer as to why that happened. Without this, everything would look fine, until you find out from a customer complaint or capital shortage.
Conclusion: all fintech is money movement
The habit at the top is still the default: copy the rail into a payment row and call it the transaction. That row will stay true. It will also stay incomplete. The rail payment and what your product did with the money were never the same event.
Every product that takes money in on one rail and sends it out on another has the same gap. A marketplace takes a charge, then pays out later. Merchant, rider, and fees are movement. Fund accounting takes LP capital in, then sends it to a deal. Allocations, management fees, and carry are movement. An escrow takes money in, then releases or returns it. The hold is movement.
If you'd like to explore money movement on our open-source ledger, you can start here: Blnk Cloud.