Payment reconciliation and ledgers for fintech apps
How a double-entry ledger, idempotent webhooks, and a daily reconciliation job keep your records in sync with your payment provider, and the failure cases most teams skip.

Here is the moment every fintech founder dreads. It is month-end, your dashboard says you hold $4,210,338, and Stripe says $4,210,205. A hundred and thirty-three dollars has gone missing, or never existed, and nobody can tell you which. The number has been wrong for weeks. You just could not see it, because nothing in your app was watching.
Reconciliation is the discipline of making sure your records and your provider's records agree, every day, down to the cent, and being able to explain every difference. It is unglamorous, it never demos well, and it is the single thing that separates a fintech product you can trust from one that quietly loses money. This is a deeper look than our general fintech build guide goes, written for the founder or engineering lead who must never lose track of money.
A ledger is not a balance column
The first mistake is storing a balance as a number you update. A user deposits, you set balance = balance + 500. They withdraw, you subtract. It feels obvious and it is a trap. The instant two requests touch that row at once, or a crash lands between the read and the write, the number is wrong and you have no way to prove what it should have been.
The fix is six hundred years old: double-entry bookkeeping. You never overwrite a balance. You record immutable entries, and every movement of money is two of them that net to zero. Money leaves one account (a debit) and arrives in another (a credit), always in the same transaction, always balancing. The balance of any account is the sum of its entries, not a field you maintain.
That symmetry is the whole point. If every transaction's legs sum to zero, then the sum of every entry in your entire system must also be zero. That invariant is a tripwire. The day it stops holding, you have a bug, and you find out in seconds instead of at audit. The append-only history also means a wrong number is traceable rather than a guess, you can replay exactly how an account reached its current state. Postgres is the natural home for this, for reasons we get into in why we reach for it first, chiefly real transactions and constraints that refuse to let the books go unbalanced.
Your ledger and the provider's are two different books
Here is the conceptual leap teams miss. You now have a beautiful internal ledger. The payment provider has its own. These are separate sources of truth and they will drift. Your job is not to trust one over the other, it is to keep them reconciled.
They drift for honest reasons. The provider settles a payout two days after the charge. A card refund clears on their side before your webhook arrives. A dispute deducts a fee you have not recorded. A payment your system thinks succeeded was actually reversed. None of these are bugs in the dramatic sense, they are just two systems observing the same money at different moments. Reconciliation is the process that catches the differences that aren't benign before they compound.
The practical model is a third internal account that mirrors the provider. When you initiate a charge, you record it as pending. When the provider confirms settlement, you move it from pending to settled and match it against their record by the provider's own transaction ID. Anything sitting in pending for too long, or settled on their side but missing on yours, is an exception a human needs to look at.
Idempotency: the gate that stops double-counting
Webhooks are how the provider tells you what happened, and webhooks are gloriously unreliable. They arrive twice. They arrive out of order. They arrive a day late after three failed retries. They occasionally do not arrive at all. If your handler runs your ledger logic every time a webhook lands, a single retried payment.succeeded event books the money twice, and now your numbers are wrong in a way reconciliation will surface but customers may notice first.
Every event needs an idempotency key, stored, so a replay is a no-op. The key is the gate, not an afterthought:
async function handleEvent(event: ProviderEvent) {
// The provider's event ID is the natural idempotency key.
// INSERT ... ON CONFLICT DO NOTHING returns false if we've seen it.
const fresh = await db.recordEventId(event.id);
if (!fresh) return; // already processed, do nothing
// Only now, inside the same transaction, touch the ledger.
await db.postLedgerEntries(event);
}The subtle part is that recording the event and posting the ledger entries must happen in the same database transaction. If you record the ID, then crash before writing the entries, you have marked the event handled without handling it, and the retry will skip it forever. We wrote a whole field guide to this in webhooks done right, because it is where more fintech bugs hide than anywhere else.
The failure cases most teams skip
A demo handles the happy path. Real money lives in the unhappy ones, and these are the cases that separate a senior build from an optimistic one.
- Out-of-order events. The
refund.createdwebhook arrives before thecharge.succeededit reverses. If your handler assumes the charge already exists, it throws, the webhook is marked failed, and the refund never books. Design handlers to tolerate arriving first: record what you know, reconcile the rest later, never assume ordering. - Partial failures. You posted the debit, then your process died before the credit. With a single database transaction this cannot happen, both legs commit or neither does. The danger is when one leg lives in your database and the other is a call to the provider's API. That gap is exactly where money disappears, and it is why the provider call and the ledger write need a carefully designed boundary, usually a pending state plus reconciliation rather than a distributed transaction.
- The duplicate that is not a duplicate. A customer legitimately buys the same item twice in one second. Naive deduplication on amount and account collapses two real charges into one. Idempotency keys must come from the operation, not from guessing based on the data.
- Timing windows. A balance check and a withdrawal that race can let an account go negative. Enforce the rule as a database constraint or a row lock, not an
ifstatement in application code that two requests can both pass.
Reconciliation is a job, not a vibe
Knowing reconciliation matters is not the same as running it. The mechanism is a scheduled job that pulls the provider's record of truth, compares it line by line against your ledger, and alerts on any difference it cannot explain. Daily is the floor. Hourly is better once you move real volume.
The point worth tattooing somewhere: nobody watches background jobs on a Tuesday night, which is exactly why they fail there. A reconciliation job that runs silently and never alerts is worse than none, because it gives you false confidence. It needs to be loud when it finds a gap and reassuring when it does not, with a number you check like you check your own bank balance. Building and monitoring that job is steady API and backend engineering work, and it is precisely the kind of plumbing an agency that only quotes you the screens has not priced.
The audit trail that makes it all defensible
When something is off, and one day something will be, you need to answer "who did what, to which account, when, and why" months after the fact, including actions by your own staff and support tools. That is the audit trail, and in fintech it is a product requirement, sometimes a legal one, not a debug log.
Keep it append-only, timestamped, and separate from the operational data it describes. The reason to build it on day one is brutal and simple: retrofitting an audit trail means reconstructing history you no longer have. The ledger gives you the money story, the audit trail gives you the human story around it, and together they turn "we think it was a glitch" into "here is exactly what happened, line by line." Both belong inside the security posture we lay out in our web application security checklist. If you are weighing whether to build this layer yourself or lean harder on your provider's tooling, that is a genuine build versus buy decision worth making deliberately, and it is core to the work we do for fintech teams.
The takeaway
Get the foundations right and reconciliation stops being scary. Store money in an append-only double-entry ledger, never a balance column. Make every webhook handler idempotent, with the event ID and the ledger write in one transaction. Treat your records and your provider's as two books that must be reconciled by a daily job that alerts on any gap it cannot explain. Test the unhappy paths, out-of-order events, partial failures, races, because that is where real money goes missing. And build the audit trail before you need it.
This is squarely the work we do, and the work we get called in to untangle when the numbers stop matching. If you want a second pair of eyes on your ledger and reconciliation before a mismatch finds you first, tell us what you are building and we will give you a straight answer.

