Skip to content
All posts

Month-end reconciliation for a Stripe Connect platform: a complete walkthrough

Reconcile by tying balance transactions to payouts and to expected refund recovery; one complete worked pass, from payout reports to a closed month.

FeeGuard15 min read

Month-end reconciliation for a Stripe Connect platform: a complete walkthrough

This walkthrough answers one question for controllers and finance ops on Connect platforms: how do you close a month so the numbers provably hold? You will pull the ledger three ways, translate transaction types into journal lines, run one complete copyable pass, check refund recovery math, and tie every payout to a bank deposit.

What a closed month has to prove

A closed month proves one identity, per currency:

opening available balance + net activity − payouts = closing available balance

Net activity means the sum of net across every balance transaction in the window except payouts. Stripe computes net as amount − fee, both in integer cents, and never mutates a balance transaction after creation. That immutability is why balance transactions, not charges or refunds, are the source of truth for a close: the ledger records every cent that moved, whether or not a friendlier object exists for it.

Two properties of the ledger matter before you start. First, balances come in two stages: pending and available. A transaction carries available_on, the date its net funds become available (account balances). Your identity runs on the available balance, so decide up front whether an event belongs to August by its created date or its available_on date, and apply that rule every month. Second, a Connect platform really has many ledgers: one per account, per currency, per balance type, plus connect_reserved holdings the platform can carry. Close each one separately.

So a defensible close demonstrates three things:

  1. The ledger ties: opening plus activity minus payouts reproduces the closing balance.
  2. Every payout matches a real bank deposit.
  3. Every refund that should have recovered seller funds or application fees did.

In practice you recompute the identity twice each cycle: once mid-period as a health check on partial data, and once at close with the interval frozen. Mid-month, expect it to fail benignly because pending items have not crossed to available; at close, comparing like with like, any residual difference must resolve to a specific transaction ID before the books shut. No plug figures, no rounding residue, differences named to the object. That discipline is what separates a reconciliation from a nicely formatted summary.

Everything below serves those three proofs.

Pull the inputs three ways

You need the itemized ledger for the interval. Three routes get it.

Route 1: Dashboard exports and the Reports API. Stripe ships report families built for exactly this: the balance reports reconcile the balance like a bank account, and the payout reconciliation reports break down which transactions composed each payout. Run them programmatically through /v1/reporting/report_runs. The report-type IDs I verified while writing this are balance.summary.1 and balance_change_from_activity.itemized.3 on the balance side (reports API), and on the payout side payout_reconciliation.itemized.7, payout_reconciliation.by_id.itemized.3, ending_balance_reconciliation.itemized.4, and failed_payouts.itemized.2 (payout reconciliation report types). The by_id variants take a single payout ID, which is the fastest way to explain one deposit. Confirm the current IDs on those pages before scripting; report schemas gain columns over time. To automate this route, subscribe to the reporting.report_type.updated webhook, which fires twice daily as new data lands, create the run for your interval, then collect the CSV from the finished run's file URL when reporting.report_run.succeeded arrives (reports API).

Route 2: Sigma. Sigma exposes the same ledger as SQL tables. The columns used below (created, type, amount, fee, net, currency) match Stripe's published query examples (querying transactions); check the schema browser for anything beyond them:

select
  type,
  count(*) as txn_count,
  sum(amount) as gross_cents,
  sum(fee) as fees_cents,
  sum(net) as net_cents
from balance_transactions
where created >= date '2026-08-01'
  and created <  date '2026-09-01'
  and currency = 'usd'
group by type
order by net_cents

That one result set is the skeleton of your journal entry summary. When the warehouse team wants the ledger outside the Dashboard, Stripe's BigQuery data sync moves the same tables into your own environment on a schedule.

Route 3: paginated API. The list balance transactions endpoint accepts created[gte] and created[lt] bounds and returns at most 100 objects per call. August 2026 starts at Unix time 1785542400 and ends at 1788220800:

curl -G https://api.stripe.com/v1/balance_transactions \
  -u "$STRIPE_SECRET_KEY:" \
  --data-urlencode "created[gte]=1785542400" \
  --data-urlencode "created[lt]=1788220800" \
  --data-urlencode "limit=100"

Page forward with starting_after=<last id of previous page> until has_more is false. This route is the one to automate; it is deterministic, and the objects are small.

If your month currently lives in exported spreadsheets, the mechanics below work identically on CSV input, and the recovery workflow for Excel-based closes picks up where the export stops.

Translate transaction types into journal lines

Every balance transaction carries a type. The table below maps the types a Connect platform sees most often to what they mean and how they usually land in the general ledger. The authoritative enumeration lives on the balance transaction object and grows as features are adopted; card payments produce charge, while local payment methods produce payment and payment_refund variants (balance transaction types).

TypeMeaningTypical GL treatment
chargeCard payment succeeded; net is amount minus processing feeDebit Stripe clearing asset, credit revenue
refundCard refund issuedDebit contra-revenue, credit Stripe clearing
adjustmentDisputes, dispute reversals, failed refunds, correctionsRead description; debit dispute expense or suspense, reverse on recovery
transferFunds moved from your balance to a connected accountCredit Stripe clearing, debit funds held for sellers
Transfer reversal (no reversal value exists; recorded as transfer_refund)A transfer you reversed; adds back to platform balanceReverse the original transfer entry
application_feePlatform earnings collected on direct and destination chargesCredit platform fee revenue
application_fee_refundFee amounts returned to connected accountsDebit contra fee revenue
payoutMoney sent to your bank accountDebit bank, credit Stripe clearing (settlement leg, not new activity)
payout_failurePayout bounced; funds returned to balanceReverse the payout entry
stripe_feeFees for Stripe software such as Radar, Connect, BillingDebit software expense
topupYour own funds pushed in from a bankDebit Stripe clearing, credit bank
reserve_transactionFunds reserved because a connected account went negativeReclassify to a reserve asset
connect_collection_transferStripe zeroed a 180-day negative connected account from your reservesBook against the reserve; pursue the seller

Two practical notes. Stripe itself recommends classifying for accounting with reporting_category rather than type, because it splits adjustments into disputes versus failed refunds and renames several groups (reporting categories). Expect near-neighbors too, and map them deliberately: local payment methods produce payment and payment_refund instead of charge and refund, canceled or failed transfers produce transfer_cancel and transfer_failure, and canceled payouts have their own payout_cancel entry. And adjustment is the row that rewards patience: each one hides a different story behind the same label, so reconcile them from description and source one by one.

A worked reconciliation pass, line by line

Everything in this section is example data, computed as shown so you can copy the method.

Inputs and assumptions

  • US platform, USD only, destination charges with transfer_data.destination.
  • Standard US card pricing of 2.9% + $0.30 per Stripe's published pricing (pricing).
  • Opening available balance on day 1: $500.00 = 50,000¢ (assumption).
  • Application fee is $10.00 = 1,000¢ on the $100.00 sale and $20.00 = 2,000¢ on the $200.00 sale.
  • Every transaction below settles from pending to available inside the window (assumption, so the pass fits one ledger; section 7 relaxes this).

Event math

Monday, charge $100.00 = 10,000¢, fee passed to seller via transfer:

  • Processing fee: 0.029 × 10,000¢ = 290¢; 290¢ + 30¢ = 320¢ = $3.20
  • Charge net: 10,000¢ − 320¢ = 9,680¢ = +$96.80
  • Transfer: 10,000¢ − 1,000¢ fee = 9,000¢ = −$90.00

Wednesday, charge $200.00 = 20,000¢:

  • Processing fee: 0.029 × 20,000¢ = 580¢; 580¢ + 30¢ = 610¢ = $6.10
  • Charge net: 20,000¢ − 610¢ = 19,390¢ = +$193.90
  • Transfer: 20,000¢ − 2,000¢ = 18,000¢ = −$180.00

Thursday, full refund of Monday's charge with both reverse_transfer and refund_application_fee true:

  • Refund: −10,000¢ = −$100.00
  • Transfer reversed: +9,000¢ = +$90.00
  • Fee returned to the seller: −1,000¢ = −$10.00

Friday, payout to the bank: −5,000¢ = −$50.00

Running ledger

#DayEventTypeNetAvailable after
——Opening balance——$500.00
1MonCharge $100.00charge+$96.80$596.80
2MonPay seller $90.00transfer−$90.00$506.80
3WedCharge $200.00charge+$193.90$700.70
4WedPay seller $180.00transfer−$180.00$520.70
5ThuRefund Monday's salerefund−$100.00$420.70
6ThuReversal of transfertransfer_refund+$90.00$510.70
7ThuFee returned to sellerapplication_fee_refund−$10.00$500.70
8FriPayout to bankpayout−$50.00$450.70

Identity check

  • Net activity excluding payouts: 9,680 − 9,000 + 19,390 − 18,000 − 10,000 + 9,000 − 1,000 = 70¢
  • Closing: 50,000 + 70 − 5,000 = 45,070¢ = $450.70, equal to the last running row. The month ties.

Position check on Monday's order. Platform margin at sale: 9,680 − 9,000 = 680¢. Refund cycle: −10,000 + 9,000 − 1,000 = −2,000¢. Platform lands at 680 − 2,000 = −1,320¢ = −$13.20, the seller ends at +$10.00, and Stripe keeps $3.20: −1,320 + 1,000 + 320 = 0. Notice what both-flags did: the seller was over-compensated by the fee amount on a fully refunded order. Flags are policy choices, not defaults to inherit; the next section shows the check that catches the expensive ones.

The same week reads differently from the seller's chair. Monday's order credited the connected account $90.00; Thursday's both-flags refund pulled that $90.00 back out and then paid the seller a $10.00 returned application fee, leaving the seller up $10.00 on a fully refunded order. Wednesday's untouched order still nets the seller $180.00. Sellers who reconcile their own balances will ask about that ten dollars; it is the over-compensation above wearing a receipt.

Reconcile refunds inside the close

The identity proves money moved consistently. It does not prove recovery happened. For each refunded destination charge, compute what the reversal should have been and compare:

expected reversal (cents) = round((amount_refunded / charge.amount) × transfer.amount)
missing (cents)           = expected − sum(existing reversals)

Thursday's refund passes: expected = round((10,000 / 10,000) × 9,000) = 9,000¢, actual reversal = 9,000¢, missing = 0. Run the parallel expectation for fees on direct charges the same way: expected fee refund = round((amount_refunded / charge.amount) × application_fee_amount). For Thursday's pass that is round((10,000 / 10,000) × 1,000) = 1,000¢, matching the ledger's −$10.00 exactly.

Partial refunds exercise the proportionality. Stripe reverses a proportional amount of the transfer when reverse_transfer=true on a partial refund (destination charges). Refund $40.00 = 4,000¢ of the $100.00 sale: expected = round((4,000 / 10,000) × 9,000) = 3,600¢ = $36.00. If the ledger shows no reversal, missing = 3,600¢, and that is real money your platform donated to the seller. The full partial refund reversal math follows the same formula at any ratio.

Close the loop in the books: sum missing across all refunded charges for the month and post it as a recovery receivable with the charge and transfer IDs attached. Two caveats keep the receivable honest. On separate charges and transfers, refunds never touch transfers at all, so every expected reversal there is a deliberate action, not an omission to flag blindly (separate charges and transfers). And a reversal only succeeds if the connected account's available balance covers it, so a missing reversal sometimes means "seller could not fund it," which routes to netting against future payouts rather than to an angry email.

Direct charges live somewhere else

If your fleet uses direct charges, the charges and refunds sit on the connected accounts' balances. A platform-level pull like the one above shows your application_fee entries and little else; the sale itself never touches your ledger. Scope the close per account by sending the Stripe-Account header:

curl https://api.stripe.com/v1/balance \
  -u "$STRIPE_SECRET_KEY:" \
  -H "Stripe-Account: acct_SELLER_EXAMPLE"

The same header works on GET /v1/balance_transactions, so the per-account pass is the identical identity applied once per account. Across a large fleet, that means iterating /v1/accounts, keeping a per-account watermark of the last closed created timestamp, and accepting that runtime grows linearly with the fleet.

Direct charges add one more expected-value check: application fees are not refunded automatically. Unless the refund call sets refund_application_fee=true or you refund the fee separately afterward, the connected account loses that amount (direct charges). Compute expected fee refunds per refund the same way you computed reversals, and treat gaps as findings. Doing this by hand across hundreds of sellers every month is mechanical work, which is the gap FeeGuard's ongoing monitoring of Stripe Connect platforms addresses; the manual method below still stands on its own.

Tie payouts to the bank

Payouts are where the ledger meets reality. For each payout entry, match amount and arrival date to the bank statement; the payout reconciliation reports even carry a bank trace_id column for deposits your bank cannot place (payout reconciliation). When a payout bounces, the money comes back: a payout_failure balance transaction restores the funds to your balance, and payout.failed fires (payouts). Handle it in order: reverse the original payout journal entry against the payout_failure transaction ID, confirm the returned amount reappears in available, fix or replace the bank account on file, and only then treat the retried payout as the settlement event. Until the failure clears, your bank reconciliation simply shows no deposit for that entry, which is expected rather than missing. Do not double-count either leg.

The subtle trap is timing. Charges land in pending first and become available on a rolling schedule, typically around two business days though it varies by country and account. A refund issued late in the month can debit available before the charge that funded it ever arrives, and a payout drawn at month end can include transactions whose created dates fall in the prior month. Those are phantom gaps: the identity holds as soon as you compare like with like. Watch the balance.available event during the year to see settlements as they land, and at close use ending_balance_reconciliation with its interval_end parameter to pin the balance to a moment instead of to "now."

Close checklist

Print this. Each step produces an artifact you can hand to an auditor.

  1. Freeze the period and record opening available balance per currency, carried from last month's close.
  2. Pull the itemized ledger for the interval (report run, Sigma export, or API pagination).
  3. Split the ledger by status and confirm nothing straddles the boundary unexpectedly.
  4. Map every row to a journal line by reporting_category; read every adjustment description individually.
  5. Run the expected-reversal check for all refunded destination and separate charges; total the missing amounts.
  6. Run the expected-fee-refund check for all refunded direct charges; total those gaps separately.
  7. Match every payout to a bank deposit; investigate every payout_failure and payout_cancel row.
  8. Recompute the identity per currency: opening + net activity − payouts = closing.
  9. Tie the computed closing balance to the balance endpoint or the ending-balance report for the same instant.
  10. File the evidence pack: report run IDs, queries, scripts, and the exception log with object IDs.

Where the manual close strains is not the arithmetic; it is steps 5 and 6 repeating forever across a growing fleet. A comparison of the manual approach and automated monitoring lays out that trade-off, and the controller-specific view collects the close duties in one place.

Frequently asked questions

Do Dashboard exports replace pulling the API?

No, they complement it. Exports and API responses draw on the same immutable balance transactions, so either input reconciles. Exports are faster for a human reviewing categories; the API is better for automation and for proving completeness, because you control the pagination loop end to end. Use whichever you can reproduce on demand, and verify totals tie to the identity regardless of source.

Why does my computed closing balance not match the balance endpoint on closing day?

Because the endpoint reports now, not the end of last month. Any activity since the period closed, plus anything still pending, moves the live number away from your computed closing figure. Compare the computed close against the ending-balance report for that interval_end, or replay the ledger forward to the current instant before comparing.

What about months with more than one currency?

Run the identity once per currency and never net across them. Currency conversions appear as their own balance transactions with exchange_rate populated, so each currency's ledger stays internally consistent even though conversion legs move money between ledgers. Consolidate only at the reporting layer, after each currency ties.

Can I rebuild a month from two years ago?

Usually yes. Balance transactions are immutable and remain retrievable through the API well beyond any single close. Prebuilt reports have explicit availability windows you can check per report type before relying on them. Rebuild the same way you closed originally: same bounds, same rules, same formulas, and expect the rebuilt close to reproduce the original to the cent.

Run the 90-day audit

FeeGuard exists because this arithmetic 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 unreclaimed application fee, unreversed transfer, and uncovered dispute loss with the amounts attached. You get the answer first; monitoring is optional afterward.

Run the free 90-day audit

FeeGuard is an independent product and is not affiliated with, endorsed by, or sponsored by Stripe, Inc.