All posts8 September 2026

Reconciling Stripe payouts with your own ledger

A payout is the net of charges, fees, refunds and currency conversion over a window Stripe picked. Reconcile it against your own row per money movement.

W. Akram9 min readengineering

A Stripe payout almost never matches a day's sales, and it was never going to. It is the net of everything that settled inside a window Stripe picked: charges from several different days, processing fees, refunds against old orders, disputes, currency conversion. Reconciling it means matching that one figure against rows in your own database, one per movement of money, written the moment the movement was decided.

Reconciliation is proving that every movement of money through your platform has a matching row in your own ledger, and that those rows add up to what the bank received.

Most of the difficulty is settled long before an accountant asks. If you are still choosing how money flows, start with destination charges or separate transfers in Stripe Connect. This is about the record you keep afterwards.

The question that arrives in month 13

Somebody eventually puts a bank statement next to your database and asks why the payout on the 4th does not appear anywhere. Fair question, boring answer, and getting to it takes an afternoon or most of a week depending on what you wrote down a year earlier.

The week version: someone exports charges from the dashboard, sums them by day, and finds the total bigger than the payout. They subtract fees and it is still wrong, because two refunds against September orders landed in the October window. Then a dispute. Then somebody notices a charge taken at 23:50 on the last day of the month filed on the first of the next, and nobody agrees whose clock decided that.

The afternoon version is a report that reads your ledger, groups rows by payout, and names the rows involved when the difference is not zero.

What separates them is a decision made around week two of the build: whether the platform keeps its own record of money, or treats the provider's records as the record.

A payout is a window, not a day's takings

Every Stripe account holds two balances, pending and available, and funds move between them on a delay that varies by country and by account. Under Connect, the platform account and each connected account carry their own pair, which Stripe's account balances documentation sets out. A payout sweeps whatever is available on whatever schedule that account is set to. So the payout on the 4th holds whatever became available since the last, and its overlap with a calendar day's charges is coincidence.

Fees have already gone by then. With destination charges and with separate charges and transfers, Stripe debits fees from the platform account, which its guide to charge types states plainly. The payout arrives net of them and nothing inside the number says so.

The unit that reconciles is the balance transaction: every movement on a Stripe balance has one, and those sum to a payout. Store its id on your row and reconciliation becomes a join. Skip it and you are matching on amount and timestamp, which works right up until two sellers are owed the same amount on the same afternoon. We treat a missing id as a bug, not tidying for later.

Refunds and disputes land across the period boundary

A refund issued in October against a September charge reduces October's payout. So does a dispute raised weeks after delivery. Neither changes September, and neither should. This is the most common reason a month's revenue and a month's banking disagree.

Stripe assigns negative transactions to the account the original charge was made on, so when you charge on your platform account, a refund or chargeback comes out of your platform balance first. What a refund does after the seller has already been paid is covered in refunds, chargebacks and negative balances in a marketplace.

The ledger answer is append-only. A refund is a new row referencing the movement it reverses, dated when it happened, with its own balance transaction id. Never an edit, never a deletion. Once rows can be edited, a historical report describes the database as it is today rather than as it was, and last quarter's numbers stop being reproducible.

Currency conversion produces two true numbers

Conversion happens whenever the starting currency of a funds flow differs from the destination currency, which Stripe's multiple currencies guide describes for payments, transfers, payouts and fees alike. A customer paying in one currency against a balance settling in another converts at the payment. A platform balance transferring to a connected account that holds a different currency converts at the transfer.

So a single movement has two amounts that are both correct, and storing one makes the other unrecoverable. A money row carries the presented amount with its currency, the settled amount with its currency, and the balance transaction id tying them together. Amounts live in minor units as integers, because a decimal that has been through a floating point type once is a decimal you cannot defend to an auditor.

A money column with no currency column beside it is a bug that has not met an international customer yet. It costs nothing on day one and is miserable later.

One ledger row per payable unit

On a coaching platform we built, the platform holds money it does not yet own. A client pays up front for a block of sessions, and the practitioner earns each session's share only after that session has happened.

Every payable unit therefore gets a row of its own, written when the obligation is created rather than when the money moves, with a release date that stays empty until the thing has been delivered. What that buys at reconciliation time is a filter instead of an investigation. Rows with no release date are what the platform owes and has not paid. Rows released inside the period are what left. Neither number has to be inferred from Stripe's side of the story.

A scheduler wakes every fifteen minutes and pushes out whatever has come due, each transfer referencing the originating charge through Stripe's source_transaction. Per the guide to separate charges and transfers, that makes funds draw from the charge rather than the platform's general balance, and lets the transfer succeed even when the charge has not settled.

It is more machinery than you need when the seller is owed the money at checkout, and we would not build it for that.

Where the quiet errors live, and why the checks throw

Splitting a payment across units is where quiet errors live. Our splits check themselves against what came in before a single row is written, and the check throws rather than logging.

Throwing is the point. A rounding bug that logs is a rounding bug that ships, because nobody reads a log line about three pence. Later it is three hundred pounds, nobody can explain it, and eleven months of rows sit on the same arithmetic. A ledger permitted to be approximately right will be.

Two other places took the same line. Every payment intent is tagged in metadata with the identifiers tying it back to a tenant and an invoice, and the webhook handler refuses the event outright if any is missing or unparseable. And a refund is worked out from the transfer rows that have not been released yet, never from whatever balance the customer appears to be holding. Only one of those two numbers came from money moving.

A period boundary is a midnight somebody chose

Sessions on that platform were stored under a date key, and for a while the client app supplied the date. The fixes were dull and correct: the backend derives the date itself, in the timezone of the thing being dated, and ignores what arrives in the request.

The case that matters for reconciliation is the boundary one. A class starting at 23:30 and running past midnight files under yesterday while everyone looking for it computes today, so nobody can get in. That version announces itself within the hour. Move the same off-by-one onto a payout straddling a month end and nothing breaks visibly at all. Two reports for the same month simply disagree, and the argument about which is right runs longer than the fix.

So: derive the date on the server, in the timezone of the thing being dated, never from the caller. Store the instant and derive the key from it. Give every date-keyed lookup a rule for the boundary. Make the current date injectable for tests, and refuse the override outside development.

What the accountant asks, and where the answer lives

What the accountant asksWhere the answer lives
Why does this payout not match any day's sales?The payout's balance transactions, stored against the payout id
What are these deductions?Fee rows written as their own entries, not netted into the charge
Which of last month's sales are in this payout?Charge date and settlement date, as separate columns on every movement
Why did this seller get less than the invoice?The transfer rows for that invoice, with amounts and release dates
Where did this refund come from?An append-only row referencing the movement it reverses
What do we owe sellers but have not paid?Transfer rows with no release date set
Why do two reports for the same month disagree?The timezone the period boundary was computed in

Each answer is a column that had to exist before anyone asked. Run once a year, reconciliation is an investigation. Run nightly from launch, it is an alert that fires while the cause is still in somebody's head.

Common questions

Why does my Stripe payout not match my sales for the day?

Because a payout is a settlement window rather than a day. It holds whatever became available since the last payout, net of fees, minus refunds and disputes against earlier charges. Reconcile against balance transactions grouped by payout, never charges grouped by calendar date.

What should I store from Stripe to reconcile later?

Every identifier attached to a movement of money: charge, refund, transfer, payout, and above all the balance transaction id. Store the presented and settled amounts with their currencies, the timestamp, and what it relates to internally. Missing ids turn a join into a spreadsheet.

Should reconciliation checks throw or log?

Throw, when the check guards money about to be persisted or moved. A logged mismatch is a mismatch that ships and compounds. Logging is right for differences you are only monitoring, such as a projected balance disagreeing with the ledger that outranks it.

Do I still need my own ledger if I use destination charges?

Yes, though a smaller one. Destination charges move money for you, but they do not record why, which delivered unit a transfer covers, or what you owe a seller who has not been paid. Stripe answers what happened to money. Your ledger answers what it was for.

Let's talk

Ready to build the thing?

Book a free 30-minute call. We'll dig into your idea, your stack and your timeline, and give you an honest read on what it will take to build and launch. You'll leave with a clearer plan whether or not you hire us.

Free 30-min call. No pitch.