Case studies
Reconciliation
How to compare your ledger with the PSP's settlement records on a schedule, match each payment, flag the gaps and treat unexplained differences as bugs.
Reconciliation compares two records of the same money: yours and the PSP's. The two will sometimes disagree, because a webhook is late, a refund settles tomorrow or a network call failed halfway. Reconciliation finds each difference, sorts it into a category, and sends the unexplained ones to a person or a bug queue.
The big idea
Run the comparison on a schedule, such as nightly, or continuously for high volume. Match each payment by its shared ID, such as a payment intent or charge ID. Anything that matches within the settlement window is fine. Anything that does not match is either expected lag or a real problem.
Walking through it
The process has four steps:
- Import the PSP's settlement file or API pages for the period.
- Match each record on the shared ID.
- Flag each mismatch: missing locally, missing at the PSP, amount mismatch, or duplicate.
- Route the flags. Engineering automates the obvious cases, such as a late webhook that should post to the ledger. Finance resolves the rest.
Here is the matching logic with four small records:
ledger = {"pi_1": 4200, "pi_2": 1500, "pi_3": 900}
psp = {"pi_1": 4200, "pi_2": 1600, "pi_4": 700}
for pid in sorted(set(ledger) | set(psp)):
if pid not in psp:
print(pid, "missing at PSP")
elif pid not in ledger:
print(pid, "missing locally")
elif ledger[pid] != psp[pid]:
print(pid, f"amount mismatch: {ledger[pid]} vs {psp[pid]}")
else:
print(pid, "matched")Output:
pi_1 matched
pi_2 amount mismatch: 1500 vs 1600
pi_3 missing at PSP
pi_4 missing locallyEach line means something different. pi_1 is clean. pi_2 has the wrong amount, and someone must look at it. pi_3 is in our ledger but not in the PSP's file, so money we recorded may never have moved. pi_4 is in the PSP's file but not ours, so a payment may have happened without our system hearing about it.
Discrepancies are normal during lag. Unexplained ones after the settlement window closes are bugs or fraud. Treat reconciliation as a product feature with its own dashboard, not as spreadsheet work.
Common mistakes
- Doing it by hand. Manual reconciliation does not scale, and it misses gaps.
- Auto-correcting without matching. A fix that is not tied to a matched record can create a new error.
- Ignoring the settlement window. Flagging a payment that is still settling creates noise.
Check yourself
-
Why is
pi_3flagged as missing at the PSP, and not marked as matched?Answer: our ledger has a payment the PSP's settlement file does not list, so the money we recorded may never have moved.
-
When is a mismatch a bug rather than lag?
Answer: after the settlement window has closed, an unexplained difference is a bug or fraud.
Key takeaways
- Reconciliation compares your ledger with the PSP's records, match by match.
- Sort every difference: matched, missing on one side, or amount mismatch.
- Expect differences during lag, and treat unexplained ones after the window as incidents.
- Build reconciliation into the product, with an exceptions queue and a dashboard.
Further learning
Return to the payment system overview to see how reconciliation fits with the rest of the design.
Related
Case study: payment system
Moving money correctly, with payment intents, idempotency at every hop, a double-entry ledger, webhooks and reconciliation when the network lies. Start here for the payments map.
Double-entry ledger
How a ledger records every money movement as balanced, immutable journals in integer cents, including refunds and payouts, and why balances should be derived from journals.
Webhooks and outbox
How to receive PSP webhooks safely, with signature checks, fast acks and deduplication, and how the transactional outbox makes calls to the PSP survive a crash.