Reading Stripe's ledger like an accountant
Balance transactions are the source of truth: their types map to journal entries, and refunds must reconcile to expectations, not merely exist.
Reading Stripe's ledger like an accountant
Every cent that moves through a Stripe account leaves exactly one trace: a BalanceTransaction row. This guide teaches finance teams to treat those rows as the general ledger — the object anatomy, the type vocabulary translated into journal-entry terms, and reconciling refunds against what should have happened rather than merely confirming that something did.
Start from the BalanceTransaction object
If you run the close for a Connect platform, this object is yours before anyone else's — controllers and finance operations own its interpretation (the controller's view). The anatomy is small enough to memorize; the table gives each field its accounting read.
| Field | Holds | Accounting read |
|---|---|---|
amount | Signed integer, smallest currency unit | Gross movement in or out |
fee | Positive integer | Costs attached to this line |
net | Signed integer | Cash impact; Stripe computes it as amount - fee |
fee_details | Itemized array | The split behind fee |
currency | ISO code | Never aggregate across these |
type | Enum | The chart-of-accounts hint |
source | Originating object ID | The join key back to reality |
created / available_on | Timestamps | Event date versus recognition date |
status | pending or available | Which clock the money is on |
All of it lives on the BalanceTransaction object, which also carries a reporting_category field grouping types for accounting use. The discipline that follows from the anatomy: book revenue from amount, book cash from net, and never book a payout lump as revenue — a payout is a movement of already-earned money, not income.
The arithmetic is self-evident but worth pinning. A $100.00 payment with a $3.20 processing fee posts as one transaction where amount = 10000¢ and fee = 320¢:
net = amount - fee = 10000 - 320 = 9680 → $96.80 reaches the balance
One object, three numbers, and each answers a different question: what was billed, what it cost, what settled.
Two clocks on the same money
Every transaction exists on two timestamps. created records when the event happened; available_on records when its net becomes usable, because card payments land in pending first and roll onto the available balance on a rolling schedule — typically about two business days, varying by country and risk profile. Payouts draw only from available funds (payouts), and both balances exist per account on Connect (account balances). The balance.available event fires at the moment of the transition if you want it pushed rather than polled.
An illustrative timeline, dates assumed for the example:
| Day | Ledger event | Money state |
|---|---|---|
| Mon | Charge created, available_on = Wed | Pending |
| Wed | Becomes available | Usable |
| Fri | Payout created against available | Leaving |
| Next Mon | Bank credit appears | Reconciled |
This clockwork explains most bank-recognition mismatches. The payout that hits the bank next Monday contains sales from the previous week; matching it against Monday's invoices fails by construction. Match payouts to bank lines by payout amount and arrival date, recognize revenue per your policy on the sales themselves, and treat available_on as the recognition boundary between the two ledgers. When bank reconciliation disagrees with Stripe, timing is the default hypothesis and error the exception worth proving.
The type vocabulary, translated
The type enum is the closest thing Stripe has to a chart of accounts, and the table below translates the commonly seen values into journal-entry terms. Grouped by role:
| Group | Types | Journal-entry meaning |
|---|---|---|
| Revenue side | charge, payment | Gross sales in |
| Revenue side | application_fee | Platform commission income on Connect charges |
| Contra | refund, payment_refund | Sales returns |
| Contra | application_fee_refund | Commission given back to the seller |
| Contra | adjustment | Post-settlement corrections; investigate each one |
| Movement out | transfer, payout | Funds to connected accounts; funds to bank |
| Movement failed or returned | payout_cancel, payout_failure, transfer_refund | Movements that bounced; a reversal returning transferred funds |
| Movement in | topup | Manually added funds |
| Stripe-side fees | stripe_fee, stripe_fx_fee | Processing and conversion costs |
| Connect machinery | reserve_transaction, reserved_funds, connect_collection_transfer | Risk holds; aged-negative collection |
Treat the table as a working subset. The authoritative enumeration, with a sentence on what each value represents, sits on Stripe's balance transaction types reference, and rare values appear only on some accounts — an unfamiliar type is a prompt to look it up, not to guess.
Two entries deserve special respect in month-end review. adjustment means money moved after the fact for reasons ranging from dispute outcomes to corrections, and each one should trace to a cause you can name. connect_collection_transfer means Stripe reached into platform reserves to zero out an aged negative connected-account balance — rare, material, and never a surprise if the negative balances were being watched.
One net, many GL lines: fee_details
A single net rarely maps to a single general-ledger line, and fee_details is where the decomposition happens. This is a seller-side direct charge as the connected account sees it — $100.00 sale, standard US pricing assumption of 2.9% + $0.30, $10.00 application fee:
{
"object": "balance_transaction",
"amount": 10000,
"fee": 1320,
"net": 8680,
"currency": "usd",
"type": "charge",
"fee_details": [
{ "description": "Stripe processing fee", "amount": 320, "type": "stripe_fee" },
{ "description": "Application fee", "amount": 1000, "type": "application_fee" }
]
}
Check the arithmetic: fee = 320 + 1000 = 1320, and net = 10000 − 1320 = 8680. One ledger row, three GL destinations; the table shows how a controller might map it:
| GL line | Amount |
|---|---|
| Revenue (gross) | $100.00 |
| Payment processing expense | $3.20 |
| Platform commission expense | $10.00 |
| Net settled | $86.80 |
With the check that 100.00 − 3.20 − 10.00 = 86.80. The platform side of the same economy splits across two transactions instead: a destination-charge sale nets $96.80 after only the Stripe fee, and the transfer to the seller posts separately at −$90.00, leaving margin of 96.80 − 90.00 = $6.80. The commission itself is an ApplicationFee object with its own amount, amount_refunded, and balance_transaction fields (application fee object) — the fields every fee-reconciliation check eventually reads.
Reconstructing a refund from three ledger lines
Refund flags state intent; the ledger states execution. Reading the cluster of transactions around a refund tells you which one you actually got.
Take the canonical destination charge: $100.00 sale, $90.00 transfer, $10.00 application fee, platform margin $6.80. Now refund it in full with both reverse_transfer=true and refund_application_fee=true. Three ledger rows appear:
| Line | Amount | Type | Source points at |
|---|---|---|---|
| 1 | −10000¢ | refund | The refund object |
| 2 | +9000¢ | transfer_refund | The original transfer |
| 3 | −1000¢ | application_fee_refund | The fee refund |
Sum the cluster: −10000 + 9000 − 1000 = −2000, so the refund-week cash effect is −$20.00. Stack it on the sale-time margin of $6.80 and the platform's lifetime position is 6.80 − 20.00 = −$13.20 while the seller ends up +$10.00 — the cluster reveals that flagging everything true over-compensates the seller on destination charges, because fee refunds compensate sellers, never buyers.
Now the default signature: same refund, no flags. The ledger shows a single bare row — −10000¢ typed refund — and nothing else. Lifetime position: 6.80 − 100.00 = −$93.20, seller untouched at +$90.00. One line versus three lines is the entire difference between a deliberate unwind and money left on the table.
That contrast is automatable without any judgment. For each refund entry, expect siblings proportional to the refund ratio: a reversal near round(refund_ratio × transfer_amount) and a fee refund near round(refund_ratio × fee_amount). Missing siblings are findings. Present siblings are policy, working as designed. The ledger answers not just "did we refund" but "did the refund do everything our policy promised."
Tying the ledger to the bank
Three bridges connect the Stripe ledger to external reality, and each has a failure mode worth instrumenting.
Payouts bridge to bank statements. Every payout groups the transactions inside it, and Stripe's reporting offers itemized balance-change and payout-reconciliation report types built for exactly this match (Stripe reports). Match by amount and arrival date; investigate residuals rather than forcing them.
Failed legs leave their own entries. A payout that bounces produces payout_failure activity and a payout.failed event; a refund that cannot reach the card reverses course and returns funds to your balance within up to about 30 days, exposed through failure_balance_transaction on the refund (refunds). The ledger type refund_failure marks the round trip. Neither failure closes anything until the return leg lands.
Disputes reinstate as well as debit. A won case returns the held funds, announced by the charge.dispute.funds_reinstated event, with the corresponding positive entries landing on the ledger (Connect disputes). Book them against the original reserve, not as new income.
Keeping all of this queryable year-round is a pipeline decision, not a spreadsheet one; syncing Stripe data into a warehouse makes every assertion in the next section a scheduled query (BigQuery sync), while export-driven teams can still systematize the CSV path (recovery from Excel exports).
Assertions worth automating
Three classes of assertion turn the ledger from a record into a control. Run all three per currency, per day.
The identity test proves completeness: opening available balance plus all non-payout activity minus payout totals must equal the closing available balance. In code:
function closesBalanced(
txs: Stripe.BalanceTransaction[],
openingAvailable: number,
payoutTotal: number,
closingAvailable: number,
): boolean {
const activity = txs.reduce(
(sum, t) => (t.type === "payout" ? sum : sum + t.amount),
0,
);
return openingAvailable + activity - payoutTotal === closingAvailable;
}
A false result means a missing or duplicated transaction — reconcile before anything else that day.
The expectation test proves correctness, not just completeness: every refund cluster must satisfy the proportional formulas — reversal near ratio times transfer, fee refund near ratio times fee — and violations are findings regardless of how cleanly the identity test passes. An internally consistent ledger happily records a policy executed badly.
The anomaly test proves nothing is hiding: any adjustment, reserve_transaction, or connect_collection_transfer without a filed reason gets a ticket before the close finishes. These types are legitimate; unexplained, they are how surprises enter the books quietly.
Teams running this manually today can compare their process against the month-end close pattern or the spreadsheet-based workflow as further reading — the assertions above are the part worth keeping no matter which tool executes them.
Corrections you will meet: failures, reinstatements, reserves
A clean month still contains entries that exist purely to correct other entries. Reading them fast is most of the accountant's edge.
Failed refunds come back to you. When a refund cannot reach the customer's instrument, the funds return to the balance that funded it — within up to roughly 30 days of the attempt (refunds) — and the Refund object carries status: failed with a failure_balance_transaction pointing at the return leg. In the ledger this appears as a fresh credit where you expected nothing; matching it against its failed refund prevents double-counting both the original refund and the return as activity.
Won disputes reinstate funds. A dispute resolved in your favor triggers charge.dispute.funds_reinstated, and the associated balance transaction restores what the freeze or debit had taken, including the partially-refunded-payment nuances Stripe documents. The reconciliation habit: every dispute.created debit should eventually pair with either a funds_reinstated credit or a permanent-loss entry, never neither.
Reserve machinery lives on the platform side. reserve_transaction entries appear when Stripe holds platform balance against a negative connected account and again when it releases that hold; connect_collection_transfer appears when a 180-day-old connected negative is zeroed out of your reserves (account balances). Neither is an error; both are movements of YOUR money triggered by someone else's account, which makes them exactly the lines worth ticketing on sight.
And then there is adjustment — the catch-all. Genuine uses exist, but an unexplained adjustment without a filed reason is how surprises enter books quietly. Treat any adjustment you cannot narrate in one sentence as an open item until closed.
Balances per currency
Multi-currency platforms read more than one column. The retrieve-balance call returns pending and available figures for each currency the account holds, so a USD-settled platform accumulating EUR application fees from cross-border destination charges sees separate EUR lines waiting there (retrieve balance). Reconcile per currency before converting anything; mixing them at the totals level hides exactly the conversion deltas that FX-focused reviews go hunting for.
curl https://api.stripe.com/v1/balance \
-u "sk_live_...:"
Each currency block answers independently: payouts draw from their own currency's available figure, refunds debit their own charge currency, and no automatic netting crosses the columns.
Frequently asked questions
Is net what lands in the bank?
Not by itself. Each transaction's net aggregates into the available balance, and the bank receives payouts — lumps of many nets. The bank line matches the payout amount, and the individual nets reconcile inside it. Treating any single transaction's net as a bank posting is the fastest way to break reconciliation.
Which date should I book a sale on?
Pick a policy and apply it uniformly. Booking on created aligns revenue with customer activity; available_on marks when the cash became usable and drives the bank-side picture. Problems come from mixing the two conventions mid-year, not from either choice itself.
Where do currency conversions show up?
Inside the same balance transactions: when money converts, the transaction carries an exchange_rate field explaining exactly how much landed (BalanceTransaction object). Refunds convert at the live rate on refund day regardless of any quote locked at purchase time, and the original conversion fee is not returned (FX quotes) — both facts show up as spread between paired entries.
Do connected accounts see the same vocabulary?
Yes — every account on Connect keeps its own ledger using the identical type enum, seen from its own side. The platform watches transfer rows leave; the seller watches them arrive. Reconciling the two perspectives against each other is precisely how transfer-reversal gaps get found.
Where do currency conversions show up?
Inside the balance transactions themselves: when a charge's presentment currency differs from settlement, the transaction records the amounts and fees that produced the settled figure, so the conversion cost is embedded in fee/net rather than appearing as its own type. Comparing linked transactions across currencies is how the spread becomes visible — and why reconciling per currency first, then converting totals, keeps arithmetic honest.
Check your own last 90 days
Reading a ledger well tells you what happened; pairing every refund against what should have happened tells you what is missing. FeeGuard exists because that pairing runs silently on every refund your platform issues. The free audit reads your last 90 days of Connect activity through a restricted, read-only API key and reports every unreversed transfer, unreturned application fee, and uncovered dispute loss with the amounts attached. You get the answer first; ongoing monitoring stays optional afterward.
FeeGuard is an independent product and is not affiliated with, endorsed by, or sponsored by Stripe, Inc.