Your records and your payment provider's report will disagree, mostly for boring reasons. Reconciliation matches the two line by line, explains the expected differences and flags the rest. It only works if you saved the provider's ID when you took the payment.
Your API returned 200. The customer saw "Payment sent". Your own
records say succeeded. Three days later the payment company sends
its report of what it actually paid you, and that payment is on it for
a different amount. Or it's on it twice. Or it isn't on it at all.
The instinct is to ask which side is right. The honest answer is usually "both, about different things". Your records show what you believed at the moment you made the call. Their report shows what the provider actually moved, after fees, conversion and a few days of things happening that you were never told about.
Reconciliation in plain terms
If you've ever gone through a bank statement ticking off each line against your receipts, you've reconciled. A business that takes payments does the same thing every day, just with thousands of lines and a computer doing the ticking. The pieces have names:
| Term | What it is | In the receipts version |
|---|---|---|
| Ledger | Your own record of every payment, written the moment you took it | Your pile of receipts |
| Payment provider | The company that actually moves the money, like #stripe or Adyen | The card company |
| Settlement report | The provider's list of what it paid you for, usually daily, after its fees | The card statement |
| Payout | The lump sum the provider sends to your bank for a batch of payments | The deposit in your account |
| Break | Any line that doesn't match between your record and theirs | A receipt with no matching line |
Reconciliation is the job of turning every break into either "fine, expected" or "someone needs to look at this today".
Why they disagree, even when nothing is broken
Most mismatches aren't bugs. They're the gap between a payment as an API call and a payment as money arriving in a bank account.
- Timing. You record the payment when the call succeeds. The provider settles it in a batch one or two business days later, after its own cut-off. A payment made at 23:58 on a Friday can land in Tuesday's report.
- Fees. You charged 100.00. The provider pays out 98.60 and lists the 1.40 fee as its own line, or nets it out with no line at all.
- Currency conversion. You quoted a rate at checkout. The provider converted at the rate on settlement day, and the pennies don't match.
- Batching. One bank credit for 4,812.33 is really 63 payments, minus 2 refunds, minus fees. Nothing in your ledger says 4,812.33.
- Late status changes. A payment you marked
succeededis reversed, charged back or fails a later check. The provider knows; your ledger doesn't, unless a webhook told it. - Your own retries. A timed-out call you retried without an idempotency key really did charge twice. The report is right, and your ledger only has one row.
That last one is a real bug, and it's covered in why payment retries need idempotency keys. The rest is just how money moves.
Three sources, not two
It's tempting to reconcile your ledger against the provider's report and stop. That only proves the provider agrees with itself.
A full match has three legs:
| Source | What it tells you |
|---|---|
| Your ledger | What you think happened, per payment |
| Provider report | What the provider says it moved, per payment and fee |
| Bank statement | What actually arrived, usually one line per payout |
Ledger against report catches missing and mismatched payments. Report against bank statement catches a provider whose payout doesn't add up to its own line items. You need both, because either side can be the one that's wrong.
Matching on a key you control
Matching is the step everything else depends on, and it's only as good as the identifiers you kept.
- 1
Exact match on the provider's ID. If you stored the provider's payment ID on your ledger row when the call returned, most payments match in one join. This is the cheapest decision in the whole pipeline, and it's made at write time, not reconciliation time.
- 2
Exact match on your reference. Most providers let you attach your own reference or metadata to a payment. Send your internal payment ID there, and you can still match a payment whose provider ID you never got back, such as a call that timed out.
- 3
Fallback match on amount, currency and a time window. For what remains, look for one ledger row and one report line with the same currency, an amount within a small tolerance, and timestamps within the settlement window. Only accept it when there's exactly one candidate on each side.
- 4
Everything left over is a break. Don't force it. An unmatched line is information.
Here's that process on a week of eight payments. Your ledger is on the left, in the order you took them. The provider's report is on the right, in its own order, grouped by payout. Press Next pass to run the passes one at a time: each match draws a line across, and anything left without a line is a break.
Pass 1 of 3. Matching on the provider's payment ID. Three payments timed out before the provider answered, so they have no ID to match on, and their report lines look like money you can't see. 2 matched, 0 guesses, 11 breaks.
- r1 matched on provider ID to pay_101
- r2 matched on provider ID to pay_102
- r5 matched on provider ID to pay_106
- r8 matched on provider ID to pay_110
- r3: not in the report yet
- r4: not in the report yet
- r5: reversed after the fact
- r6: not in the report yet
- r7: not in the report yet
- r8: amount differs, and the fee doesn't explain it
- pay_103: money you can't see
- pay_104: money you can't see
- pay_105: money you can't see
- pay_108: money you can't see
- pay_109: money you can't see
Sorting the breaks
Unmatched lines fall into a handful of shapes. Naming them is most of the value: each one has a likely cause and a default action.
| Break | Likely cause | Default action |
|---|---|---|
| In ledger, not in report | Still in flight, or never really sent | Wait one settlement cycle, then escalate |
| In report, not in ledger | Lost webhook, retry without a key | Escalate now: money moved you can't see |
| Amount mismatch | Fee, FX or partial capture | Auto-clear if it's a known fee or rate |
| Duplicate in report | Double charge | Escalate now, and refund |
| Status mismatch | Reversal or chargeback after the fact | Post the reversal, notify the owner |
The two "escalate now" rows are the ones that cost real money. Most others clear themselves on the next run or are explained by a fee schedule you can encode once.
Automate the boring, escalate the rest
A reconciliation job that sends every mismatch to a person gets ignored by the end of its first week. One that silently fixes everything eventually hides a real loss. The useful middle:
- Age the timing breaks. A payment missing from today's report is normal. The same payment missing from three reports in a row isn't. Escalate on age, not on first sight.
- Encode known differences. If the fee schedule says 1.4% plus a fixed amount, an amount difference that matches it is explained. Store the fee as its own entry rather than shrugging it off.
- Never edit a settled row. When the report proves the ledger wrong, post an adjusting entry that points at the original. The ledger stays append-only, and you can always see what you believed and when it was corrected.
- Give the person context. An escalation should carry both sides of the mismatch, the candidate matches that were rejected, and how long it has been open. Nobody should have to rebuild the case by hand.
Make tomorrow's report easier today
Every one of these is cheaper at write time than at reconciliation time:
- 1Store the provider's ID on the ledger row the moment the call returns, and your own ID in the provider's metadata before you send it.
- 2Use idempotency keys on every money-moving call, so a retry can never become a second payment.
- 3Record fees and conversion separately from the amount, so a difference has somewhere to land.
- 4Treat webhooks as the start of a correction, not the end of the
story. A late
failedneeds an adjusting entry, not an update. - 5Reconcile daily. A break found the next morning has one cause. A break found at month end has thirty.
None of this makes the report agree with you. It makes the disagreement small enough, and well-labelled enough, that you can tell the boring differences from the one that matters.