Your payment provider's dashboard says you took £84,210 last week. Your bank says £81,947 arrived. Both numbers are correct, and the £2,263 gap is a mix of fees, a dispute, a refund from the week before and one payout still in flight. Reconciliation is the job of proving that, line by line, before finance asks.
Most engineers meet it late, as a ticket titled "numbers don't match". The fix is rarely clever. It is knowing which identifier on the provider's side maps to which row on the bank statement, and which report to trust for which question. This guide covers how reconciliation works against Stripe, Adyen and Checkout.com, where it breaks, and how I would structure it.
What Is Payment Reconciliation and Why Does It Break?
Reconciliation means matching three records of the same money:
1. Your internal ledger: what your system believes happened (orders, refunds, payouts owed). 2. The provider's report: what the PSP says it processed, charged in fees and paid out. 3. The bank statement: what actually landed in your account.
Two-way matching (ledger to PSP) tells you the provider agrees with you. It does not tell you the money arrived. Three-way matching is the only version that survives an audit, and for anyone holding client money it is not optional. I covered the regulatory side for UK firms in the CASS 15 reconciliation guide; this piece is the PSP-facing counterpart.
The mismatches fall into a small number of buckets:
| Break | Why it happens | Where it shows up |
|---|---|---|
| Timing | Funds are "available" days before a payout, and payout arrival differs from bank posting date | Payout total ≠ bank credit on the day |
| Fees netted | Provider deducts fees before paying out | Gross sales ≠ payout |
| Refunds and disputes | Debited from a later payout, not the original | Negative lines in an unrelated payout |
| FX | Conversion at the provider's rate, with a separate fee | Amount in settlement currency differs from sum of charges |
| Reserves | Rolling reserve held back and later released | Payout smaller, then larger |
| Failed payouts | Bank rejected the credit, funds return to balance | Payout in report, nothing in bank |
| Rounding | Summing decimals instead of integers | Pennies, always |
How Does Stripe Payout Reconciliation Work?
Stripe's model is the balance transaction. Every movement of money on your Stripe balance is one balance_transaction object, and a payout is just one more balance transaction that sweeps a set of others.
The fields that matter:
amount: gross, in minor units (pence, cents). Positive when you are owed, negative when money leaves.feeandfee_details[]: fees, each taggedstripe_fee,application_fee,payment_method_passthrough_fee,taxorwithheld_tax.net:amountminusfee. This is what moves your balance.available_on: the date the net funds become available. It is not the date the money reaches your bank.exchange_rate: present when the charge currency differs from the settlement currency.source: the ID of the object behind it (ch_,re_, a dispute).typeandreporting_category.
reporting_category for accounting, not type. Stripe's own guidance says so, and the reason is practical. type is a long, open-ended enum (it includes charge, payment, refund, payment_refund, payment_failure_refund, adjustment, payout, payout_failure, reserve_hold, reserve_release, payment_unreconciled and more), and Stripe adds values. Treat it as open. reporting_category collapses card and local-method variants, so charge and payment both become charge, and refund and payment_refund both become refund. Disputes arrive as adjustment types with the dispute as the source.
To list everything inside a payout:
GET /v1/balance_transactions?payout=po_1Nxxxxxxxx&expand[]=data.source&limit=100
Three catches I have seen bite people:
- Default page size is 10. A payout with 400 transactions silently reconciles 10 of them if you forget pagination.
- Only automatic payouts are itemised. Manual and instant payouts have no
payoutlinkage, so Stripe cannot tell you what is inside. You reconcile those against the balance history yourself. - Retrieving the payout object does not return its transactions. You need the list call above.
payout.reconciliation_completed when the itemised data is ready, and that event runs on a twice-daily cycle (data for 00:00 and 12:00 UTC). A payout can land in the bank before its reconciliation data exists. If your job runs on payout creation rather than on that event, you will intermittently reconcile an empty payout.
For bulk work, the Payout Reconciliation Report (payout_reconciliation.itemized.7 through the Reporting API) gives you automatic_payout_id, balance_transaction_id, charge_id, reporting_category, gross, fee, net, available_on, payout_reference_token and trace_id. Note the unit change: reports are in major units (12.34), the API in minor units (1234). That is a classic source of 100x errors, and the reason to keep money as integers everywhere and convert only at the edge. The same discipline is covered in the ISO 4217 minor units guide.
How Does Adyen Settlement Reconciliation Work?
Adyen organises around batches rather than a running balance. Money is grouped into a numbered settlement batch, the batch closes, and a MerchantPayout is generated. The Settlement details report is one row per transaction event, with a Batch Number column tying each row to its batch.
The default columns include Company Account, Merchant Account, Psp Reference, Merchant Reference, Payment Method, Creation Date, Type, Modification Reference, Gross Currency, Gross Debit (GC), Gross Credit (GC), Exchange Rate, Net Currency, Net Debit (NC), Net Credit (NC), Commission (NC), Markup (NC), Scheme Fees (NC), Interchange (NC) and Batch Number. The fee split is the useful part: Commission is Adyen's fee, Markup is the acquiring bank's, Scheme Fees go to Visa or Mastercard and Interchange to the issuer. Where interchange-level data is available, Commission is empty.
The Type column is where the logic lives:
| Type | Direction | Meaning |
|---|---|---|
Settled | Credit | Payment sent for settlement |
SettledReversed | Debit | Adyen did not receive the funds within 30 days of capture |
Refunded | Debit | Refund |
RefundedReversed | Credit | Refund could not be credited to the shopper |
Chargeback | Debit | Dispute lost or undefended |
ChargebackReversed | Credit | Successful defence, or shopper re-paid |
SecondChargeback | Debit | First chargeback defence failed |
Fee | Debit | Adyen transaction fees |
MerchantPayout | Debit | The payout to your bank |
BalanceTransfer | Either | Carries a balance into a later batch |
ReserveAdjustment | Either | Reserve movement |
Two rows deserve a second look. BalanceTransfer appears when a batch cannot be paid out, for example because the net is negative after refunds, and it rolls the shortfall into the next batch. If you only match MerchantPayout rows to bank credits you will miss the batch that paid nothing. And SettledReversed means a payment you counted as revenue is clawed back weeks later. Revenue recognised at Settled needs a reversal path.
The aggregate Settlement details batch report gives you Batch Number, Batch Closed Date and transaction counts, which is the level you match to the bank statement. The Payment Accounting Report is the fee-focused companion: it carries 50-plus record types and fee columns in settlement or fee currency, built for invoice reconciliation rather than cash matching.
Stripe vs Adyen vs Checkout.com: Which Reconciles Best?
The three providers disagree on the unit of reconciliation, and that decides how much code you write.
| Stripe | Adyen | Checkout.com | |
|---|---|---|---|
| Unit | Balance transaction | Settlement batch | Payout ID |
| Link to payout | payout filter on balance transactions | Batch Number column | 12-character payout ID in Financial Actions by Payout ID |
| Fee detail | fee_details[] per line | Separate Commission, Markup, Scheme, Interchange columns | Fee categories: interchange, scheme, gateway, authentication and more |
| Real-time API | Yes, plus webhook | Reports mainly, scheduled | Reports API |
| Gotcha | Manual payouts are not itemised | BalanceTransfer batches pay nothing | Payouts Report misaligns with invoicing periods |
My view: Stripe is the easiest to automate because everything is an API object with a stable ID and a webhook that says "ready". Adyen is the most transparent about fees, since interchange and scheme fees are separate columns, which matters if you are checking whether you really are on interchange-plus pricing. Checkout.com sits between them. Its docs tell you to avoid the Payouts Report for invoicing and use monthly Balance or Financial Actions reports in UTC instead, which tells you what the timing problem looks like in practice.
If you run more than one PSP, normalise to your own schema at ingestion. Do not let each provider's vocabulary leak into your ledger.
How Do You Match a PSP Payout to a Bank Statement Line?
This is the step the provider docs underplay. You have a payout of £12,418.37 on the provider side and a credit on the bank feed. Matching on amount alone works until two payouts have the same amount, or one is split.
What you can use:
- Stripe exposes a
payout_reference_token("displays on the beneficiary's bank statement") and, viatrace_id, a bank-assigned identifier fetched up to 10 days after the payout is paid. The trace ID is unavailable for some countries and for UK Instant Payouts.statement_descriptoris displayed inconsistently by banks, so do not make it your primary key. - Checkout.com has the 12-character payout ID, which you match against the bank line and the "Payout Amount" field.
- Adyen ties the
MerchantPayoutrow and itsBatch Closed Dateto the credit. I could not find a documented reference format that Adyen writes to the bank statement, so test this on your own account before relying on it.
camt.053, where each Ntry can wrap a batch and the detail sits in TxDtls, with EndToEndId (up to 35 characters) and AcctSvcrRef as reference fields. Older feeds use MT940 or BAI2, which cram the detail into free text. If you are choosing a bank feed, camt.053 is the one that keeps references intact. The message families are laid out in the ISO 20022 message types guide.
One UK trap. ClearBank documents that its Faster Payments reference field accepts 35 characters but anything over 18 is truncated before it reaches the scheme. A long reference you thought was a unique key arrives cut off. Design matching to survive a truncated reference by combining amount, date window and a short token.
A workable algorithm, in order:
1. Exact match on provider reference or trace ID. 2. Amount plus date window (value date within the provider's stated arrival window ± 2 business days). 3. Group match: one bank credit equals several payouts, or the reverse. 4. Anything left goes to a human queue with the candidate matches attached.
What Reconciliation Breaks Should I Expect in 2026?
Timing is the largest source of false alarms. Stripe's default UK settlement runs 3 business days after an initial 7 calendar days for new accounts; US card payments are typically 2 business days; ACH debit 4; SEPA Direct Debit 6. A charge created on a Saturday starts its business-day clock on Monday. Build the expected-arrival calculation per payment method, then alert on payouts that are late against it, not on every unmatched row. Fees netted at source distort revenue. Reconcile on gross first, then fees as their own ledger entries. If you book net, you lose the ability to audit your effective rate. Disputes land in the wrong week. A chargeback debit is taken from a later payout. The line in that payout references an old charge. Your reconciler must followsource (Stripe) or the PSP reference (Adyen) back to the original payment, not treat the line as new activity.
Partial captures hide a second entry. On Stripe, a partial capture produces a charge for the full authorisation plus a refund-type entry for the uncaptured portion, categorised as partial_capture_reversal. Count only the charge and your totals are off by exactly the uncaptured amount. The expiry rules behind this are in the card authorisation expiry guide.
Stale unreconciled funds. Stripe has a payment_unreconciled type for customer funds that stayed unreconciled for more than 90 days. If you ever see it, a customer paid and nothing matched.
Rounding. Sum integers. Convert once, at display.
What I Would Build: Opinion
I would not build reconciliation as a nightly script that diffs CSVs. I would build it as an append-only ledger of provider events, each with a provider ID and bank-match status, and treat "unmatched" as a first-class state with an owner and an age. A double-entry ledger gives you the right shape: a clearing account per provider, credited on Settled or charge, debited when the payout is matched to a bank line. The balance of the clearing account is your reconciliation status. If it is not zero (allowing for in-flight payouts), something is unmatched, and the account tells you how much.
That is an opinion, but a defensible one. A diff tells you today's numbers disagree. A clearing account tells you when the disagreement started and which event opened it.
What This Means for Engineering Teams
- Pick the matching key per provider before writing code. Stripe payout ID plus
payout_reference_token; Adyen batch number; Checkout.com payout ID. Store the bank-side reference next to it. - Trigger on readiness events, not creation. Use Stripe's
payout.reconciliation_completed, and schedule Adyen report pulls after batch close. - Paginate everything. Set
limit=100and followhas_more. Test with a payout containing more than 10 items. - Store gross, fee and net separately, in integers. Convert from report major units at ingestion.
- Model reversals explicitly.
SettledReversed,RefundedReversed,payout_failureandChargebackReversedare real states, not edge cases. - Run matching with a tolerance window and a human queue. Expected-late is not the same as missing.
- For UK firms holding client money, tie the clearing account to your daily safeguarding check rather than keeping two processes.