What reconciliation actually is

Some of our withdrawals got stuck, and we were fixing them by hand. Here's what that taught us about reconciliation, why a timeout mismatch costs the most, and how we handle it now.

Abraham Udele

Cloove

9 min read

At Cloove, we run a mobile banking service: people hold money with us, pay bills, and send transfers. Some of their withdrawals got stuck.

A customer would move money out of their account, and it would just sit there. Not completed, not failed. Our system sent the request to the payment provider and never got a clear answer.

So we did what most small teams do: we handled it by hand. Someone would find the transaction, check it with the provider, and work out whether the money had actually left. If it had, we updated the record. If it hadn't, we decided whether to try again or refund the customer. Then on to the next one.

That works while there are only a few. But every case depended on someone remembering to look, every check cost a person's time, and the riskiest decision in the whole process, whether to send the money again, was being made by hand, with a customer waiting.

That's what pushed us to build reconciliation into the product properly. This post covers what we learned along the way: what reconciliation actually is, where it goes wrong, and how we handle it now.


It's just two sets of books

If you move money, you keep a record of it, and so does whoever moved it for you. Throughout this post, your ledger means your own record (your app, database, or accounting system), and the provider's records mean the bank or payment company's.

Here's the problem in its simplest form. Your dashboard says you paid out ₦300,000 last week. The provider's report says ₦250,000 actually left. Every row on your side says "Success," and yet ₦50,000 is unaccounted for.

Fig. 1. Four payouts, two records. The totals differ by exactly one row. Illustrative figures, not real customer data.

Reconciliation means putting the two records side by side, checking they match, and when they don't, finding out why and fixing whichever one is wrong.

That last part needs care, because "wrong" isn't always on your side. Each record is the authority on a different question:

  • The provider knows whether money moved, how much, and when it settled.
  • Your ledger knows why it moved: which customer, what for, which order or invoice.
Fig. 2. Neither record is complete on its own. A shared reference is what lets you join them.

Why bother

Because one gap is easy to spot in four rows and almost impossible to spot in two hundred.

Fig. 3. 200 payouts, one still pending. At this scale, nobody finds it by looking. Illustrative figures.

Say a business sends 200 payouts in a day. By evening, their ledger says all 200 succeeded, but the provider says 199 settled and one is still pending. Without reconciliation, that payout stays marked successful, and nobody looks at it again. The recipient waits and complains, and someone spends a week piecing together what happened. With reconciliation, the gap is flagged that evening, and the recipient hears it's on the way before they have to chase anyone.

That's the everyday case. There are bigger reasons too:

  • Trust. Customers forgive a late payment far more easily than money that's gone with no explanation.
  • Reporting. Your revenue numbers are only as good as the records behind them. If nobody has checked those records, every decision made from them carries the error along.
  • Early warning. A growing pile of mismatches is often the first sign of something else: an integration that broke quietly, a fee set up wrong, or someone trying to take advantage.
  • Audit. Auditors and regulators will ask you to show that your books match reality. This is how you show it.

Why the records drift apart

Two independent systems describing the same event will disagree sometimes and usually for boring reasons.

Some of it is timing and delivery. A payment made on Friday night might not settle (actually land in the receiving account) until Monday, so for a couple of days one side has it, and the other doesn't. A webhook, the automatic message a provider sends when something happens, can fail to arrive, and then your system never hears about a payment that went through.

Some of it is data. A provider deducts a fee you weren't expecting. Someone marks a payment as received by hand and types the wrong amount. The same event arrives twice and gets counted twice.

None of that means something is broken. It's what you get when two systems keep their own books.


Three ways the records can disagree

When you compare them, every difference you find is one of three things.

Fig. 4. Every difference falls into one of these three buckets, and each needs a different response.

You have it; they don't. You think a payment happened, and the provider has no trace of it. Maybe it never left, or it was rejected. Find out before you tell a customer it went through.

They have it; you don't. Money moved, but your system never found out, usually because a notification got lost. This is the one customers notice first. They've paid, and nothing shows up.

You both have it, but it doesn't match. Different amount, different fee, different status. These are easy to miss, and they add up. A fee that's a little off on every transaction becomes a big number by the end of the quarter.

If you build payment systems, the core of a reconciliation job is small. Here's a simplified sketch, not our production code:

TypeScript
enum TxnStatus {
	Succeeded = 'succeeded',
	Failed = 'failed',
	Pending = 'pending',
	Unknown = 'unknown', // no answer yet, e.g. a timeout. Never the same as failed.
}
 
enum MatchResult {
	Matched = 'matched',
	MissingAtProvider = 'missing_at_provider', // you have it, they don't
	MissingInLedger = 'missing_in_ledger', // they have it, you don't
	AmountMismatch = 'amount_mismatch', // both have it, amount or currency differs
	StatusMismatch = 'status_mismatch', // both have it, status differs
	Duplicate = 'duplicate', // one side has the same reference twice
}
 
interface Txn {
	reference: string; // an ID both sides recognize
	currency: string; // e.g. 'NGN'
	amountMinor: number; // integer in the smallest unit (kobo), never floating-point naira
	status: TxnStatus; // normalized from each side's own words
}
 
interface Finding {
	reference: string;
	result: MatchResult;
}
 
// 'pay-1044 ' and 'PAY-1044' are the same payment.
const normalizeRef = (ref: string) => ref.trim().toUpperCase();
 
// Index one side by reference. A reference seen twice is flagged, never overwritten.
function index(txns: Txn[], findings: Finding[]) {
	const byRef = new Map<string, Txn>();
	for (const txn of txns) {
		const ref = normalizeRef(txn.reference);
		if (byRef.has(ref))
			findings.push({ reference: ref, result: MatchResult.Duplicate });
		else byRef.set(ref, txn);
	}
	return byRef;
}
 
function reconcile(ledger: Txn[], provider: Txn[]): Finding[] {
	const findings: Finding[] = [];
	const ours = index(ledger, findings);
	const theirs = index(provider, findings);
	// Which copy of a duplicate is the real one is a question for a person.
	const duplicated = new Set(findings.map((f) => f.reference));
 
	for (const [ref, mine] of ours) {
		if (duplicated.has(ref)) continue;
		const match = theirs.get(ref);
		theirs.delete(ref);
 
		let result = MatchResult.Matched;
		if (!match) result = MatchResult.MissingAtProvider;
		else if (
			match.currency !== mine.currency ||
			match.amountMinor !== mine.amountMinor
		)
			result = MatchResult.AmountMismatch;
		else if (match.status !== mine.status) result = MatchResult.StatusMismatch;
 
		findings.push({ reference: ref, result });
	}
 
	for (const ref of theirs.keys())
		if (!duplicated.has(ref))
			findings.push({ reference: ref, result: MatchResult.MissingInLedger });
 
	return findings;
}

Run that over a day's transactions, and almost everything matches. What's left is the short list a person actually needs to look at.

Fig. 5. The matcher's job is to shrink a thousand rows to the handful that need a human. Illustrative figures.

A few details in the sketch do most of the work:

  • Match on a shared reference, and normalize it, not on amount and date. Two ₦5,000 payments on the same afternoon are indistinguishable otherwise, and "pay-1044 " with a stray space shouldn't count as a different payment.
  • Store amounts as integers in Kobo, with the currency. Floating-point naira will eventually produce a "mismatch" of ₦0.0000001 that wastes someone's morning. The currency stops 5,000 naira from matching 5,000 of something else.
  • Normalize statuses before comparing. Your "success" and their "settled" mean the same thing, but only if you say so.
  • Keep "unknown" separate from "failed." A payment you never got an answer for shows up as a status mismatch and gets checked, not retried. More on why in the next section.
  • Flag duplicates instead of picking one. If either side has the same reference twice, matching "the right copy" is a guess. A person should look.
  • Say what doesn't match. An amount problem and a status problem get different results, so whoever picks up the row knows where to start.

One thing the sketch leaves out: give recent transactions a grace period before you flag them. A payment made ten minutes ago that the provider hasn't reported yet is probably just slow, not missing.


The mismatch that costs real money

Most mismatches are an annoyance. One kind can cost you real money, and it's the kind behind our stuck withdrawals. It starts with a timeout.

You send a payout. The request hangs and then fails, so your system does what seems sensible and marks it failed. But the provider did receive the request, and it paid the recipient anyway.

Fig. 6. The red window opens the moment the request times out and stays open until someone asks the provider.

For as long as your ledger says "failed" and the provider says "paid," you're in that red window. The recipient has the money. Your system says they don't. If anyone hits "retry," they get paid twice.

The fix starts before reconciliation does. A timeout tells you that you didn't get an answer. It doesn't tell you the payment failed. So don't record it as failed. Record it as unknown, ask the provider, and only then decide.

Fig. 7. Succeeded, failed, and don't know yet. The third state is the one that keeps you from paying twice.

If your provider supports it, send the same reference on any retry. Then a duplicate request is rejected by the provider instead of being paid out a second time.


What a good process looks like

A spreadsheet, or a person checking by hand, is fine at ten transactions a day. It stops being fine much sooner than most people expect. We found that out first-hand.

Fig. 8. Five parts. Skip any one and the gap shows up somewhere else.
  1. A shared reference. Every transaction carries an ID both sides recognize. Without it, nothing else works reliably.
  2. Automatic comparison on a schedule. Daily is common. The point is to see a gap while it's small and recent.
  3. An exceptions queue. Anything that doesn't match gets a status and an owner, not a spot in someone's inbox.
  4. A trail for every payment. You can see what happened and when, so "where's my money?" takes a minute to answer instead of an afternoon.
  5. Reviewed fixes. When someone corrects a record, a second person approves it, because that correction changes real balances.

Leave any of those out, and the gap doesn't go away. It turns up somewhere else, in a spreadsheet nobody trusts or a customer who's tired of waiting.


How we handle it now

Remember those stuck withdrawals. Here's what happens to one today.

Fig. 9. A stuck withdrawal today. Simplified illustration with invented data.

The timeout no longer turns into "failed." The withdrawal is held, and if the provider still hasn't answered, it lands in a queue instead of waiting for someone to notice. One requery later, we know the money went out, and the record says so. Nobody had to guess, and nobody sent the money twice.

Getting there took four pieces, built in order.

References came first. A provider doesn't know what we call a payment internally, so every transaction now carries an identifier the provider recognizes, and we applied it to older records as well as new ones. When we check a payment, we ask in terms the provider understands.

Then the ability to ask. When a payment's outcome is unclear, our team can requery the provider, meaning look the payment up directly, and see what actually happened before anything is marked done or failed.

Then a place for differences. When our records and the provider's disagree, the transaction lands in an exception queue. Each row says what kind of problem it is, how old it is, and what the next step should be. For any payment, our team can also open a timeline showing every event in order, so most questions get answered from the record itself.

Fig. 10. What used to be a mental list is now a queue. Simplified illustration with invented data.

Then reviewed fixes. Some fixes change a balance, like refunding a customer or correcting a status. For those, one person requests the change and a different person approves it. It's slower on purpose. The checking we used to do by hand was quick, but nobody double-checked it.

Fig. 11. Each step depends on the one before it.

The order mattered. There's no point comparing records you can't match, and no point queueing exceptions if you can't tell what the provider actually did. Since then we've applied the same approach to incoming deposits and to the statements our providers send us.

People still make the final call on anything that moves money. The difference is that they now make it with the evidence in front of them and with a second person checking.


Where does this leave you?

Reconciliation comes down to not taking anyone's word for it, including your own system's. Compare the two records often, chase down the differences, and keep a record of every fix.

If your business moves money, you're already doing some version of this. We were, one stuck withdrawal at a time. The only question is whether you do it on purpose and on a schedule, or by accident when a customer complains.

It never really finishes. Providers change, odd edge cases turn up, and new ways to pay keep arriving. The goal is to catch differences early, be able to explain them, and tell the person affected before they have to ask.

If you've run into this yourself, or you want to know more about how we handle it, write to me at chat@clooveai.com.

Keep reading

Follow along

New posts on how we build Cloove: agents that move money, the systems behind them, and what we learn in production.