Diagnostic Stripe
payouts cannot be tied back to their balance transactions
A single deposit arrives in the bank and finance asks the only reasonable question: what is in it? The obvious answer, GET /v1/balance_transactions?payout=po_x, comes back with an empty list. Not an error, not a permissions problem — an empty list, on a payout that definitely happened.
Read reconciliation_status on the payout. Stripe only lets you list balance transactions by payout when that field is "completed", and it only reaches completed for standard automatic payouts. Every payout you create yourself comes back not_applicable, and no balance transaction will ever list against it.
So GET /v1/payouts?limit=100&created[gte]= and flag anything where reconciliation_status != "completed" or automatic == false. For the ones that are completed, check the arithmetic: paginate GET /v1/balance_transactions?payout=po_x and confirm the sum of net equals the payout amount.
The problem in plain words
This is a structural loss, not an outage. A platform that runs its payouts manually — because somebody wanted control over the timing, or because the schedule was set to manual during testing and never changed — has permanently given up the ability to ask Stripe what a given deposit contained. The payouts work perfectly. It is the audit trail that does not exist.
It surfaces at the worst time, which is during a reconciliation somebody else is running: an accountant closing a quarter, an auditor sampling deposits, a finance hire trying to explain a variance. The answer is not in the API and cannot be added retroactively by changing a setting today, because the linkage was never recorded for the payouts that already went out.
Why it happens
reconciliation_status is not a progress indicator, it is a capability flag. completed means Stripe has assembled the list of balance transactions for that payout. in_progress means it is doing so. not_applicable means it never will, and that is the value manual payouts get from the moment they are created.
Manual payouts opt out of it by definition. When you decide what leaves and when, Stripe is no longer the party assembling the payout out of a known set of transactions, so it cannot report which ones went into it. This is the trade for the control, and it is rarely made deliberately — the schedule usually got left on manual by accident.
The sum is worth checking even when the status is right. A payout whose linked balance transactions do not sum to its amount is telling you something specific: transactions in a currency you did not expect, a reversal that landed in the window, or a page of results you stopped paginating too early. A reconciliation that never verifies the total is only checking that the endpoint responds.
transfer_group is the part you control, and it is usually empty. Charges and transfers that share a transfer_group can be joined back together whatever the payout says. Left null, as it is by default, each side of a marketplace transaction is an unrelated object and the reconstruction is guesswork even where Stripe does give you the payout breakdown.
The fix, as a flow
The script reads two fields per payout and then checks the arithmetic on the ones that have a breakdown, because an endpoint that responds is not the same as a total that adds up.
How to fix it
Read reconciliation_status and automatic on every recent payout
GET /v1/payouts?limit=100&created[gte]= over 90 days. Two fields decide everything: reconciliation_status says whether a breakdown exists, and automatic says why. A not_applicable on a manual payout is a policy problem; the same value on an automatic one means the payout is of a kind Stripe does not itemise.
Verify the arithmetic on the ones that are reconcilable
Paginate GET /v1/balance_transactions?payout=po_x&limit=100 and sum net. It should equal the payout's amount. Anything else is a real discrepancy and worth a look before the quarter closes rather than after.
Move the schedule to automatic so future payouts are itemised
An automatic daily or weekly schedule restores reconciliation_status: "completed" from the next payout onward. It does nothing for history, which is the argument for doing it today rather than at the next audit.
Use the Reporting API for the payouts you cannot re-run
payout_reconciliation.by_id.itemized.1 takes a single payout id; payout_reconciliation.itemized.7 covers an interval and joins on the automatic_payout_id column. This is the only route to a breakdown of payouts that have already left.
Set transfer_group on everything from now on
It costs one parameter at charge and transfer creation and it is the only linkage that survives regardless of payout mechanics. Also worth listing reversed transfers — amount_reversed > 0 — because they move money without a matching charge and are the usual explanation for a sum that is close but not equal.
How to check it worked
Re-run the script over the days since the schedule changed. Every payout should be completed, and every checked sum should match the payout amount exactly.
python3 stripe_payout_reconciliation.py --days 14
# po_1Abc reconciled 87 balance transaction(s) sum to the payout
# 12 payout(s): 0 manual, 0 mismatched, 0 unsupported
The full code
One paginated GET over the payouts, then one paginated GET per reconcilable payout to check the sum — read access to Payouts and Balance transactions is all the key needs. The classifier is pure and takes the payout plus the summed net of its balance transactions, with None meaning the sum was not fetched, so the difference between not checked, checked and correct and checked and wrong stays visible instead of collapsing into a boolean.
"""Report payouts that cannot be tied back to their balance transactions.
Read only. Paginated GETs and no writes: give this a RESTRICTED key with read
access to Payouts and Balance transactions. The repair is printed, never
performed, because this script holds a credential to a live payments account.
"""
import argparse
import logging
import os
import sys
import time
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("stripe_payout_reconciliation")
API = "https://api.stripe.com/v1"
DAY = 86400
def classify(payout, txn_sum, txn_count):
"""Sort one payout by whether its contents can be recovered. Pure, so the
states can be tested without a network.
`txn_sum` is the sum of `net` over the balance transactions listed against
this payout, or None when they were not fetched. `txn_count` is how many
there were. Only payouts with reconciliation_status "completed" can be
listed against at all, so for the rest both arguments are meaningless.
Returns (state, detail).
"""
status = payout.get("reconciliation_status")
automatic = payout.get("automatic")
amount = payout.get("amount")
if status == "completed":
if txn_sum is None:
return ("reconcilable",
"reconciliation_status completed: the breakdown exists, this "
"run did not fetch it")
if not isinstance(amount, int):
return ("unknown", "payout has no numeric amount: %r" % (amount,))
if txn_sum != amount:
return ("mismatch",
"%d balance transaction(s) sum to %d against a payout amount "
"of %d, %d apart: look for another currency, a reversal in the "
"window, or a page you stopped paginating"
% (txn_count, txn_sum, amount, abs(amount - txn_sum)))
return ("reconciled",
"%d balance transaction(s) sum to the payout" % txn_count)
if status == "in_progress":
return ("pending",
"reconciliation_status in_progress: Stripe is still assembling the "
"breakdown, which fills in after the payout settles")
if status == "not_applicable":
if automatic is False:
return ("manual",
"manual payout: reconciliation_status not_applicable, so no "
"balance transaction will ever list against it. The itemized "
"report is the only route to its contents")
return ("unsupported",
"reconciliation_status not_applicable on an automatic payout: "
"Stripe itemises standard automatic payouts only")
return ("unknown", "unrecognised reconciliation_status: %r" % (status,))
def get(session, path, **params):
r = session.get(API + path, params=params, timeout=30)
if r.status_code == 401:
raise SystemExit("401 from Stripe: the key is wrong, or is for the other mode")
r.raise_for_status()
return r.json()
def payout_transactions(session, payout_id, cap):
"""Sum `net` over the balance transactions Stripe attributes to a payout."""
total = 0
count = 0
params = {"payout": payout_id, "limit": 100}
while True:
page = get(session, "/balance_transactions", **params)
data = page.get("data", [])
for bt in data:
total += bt.get("net") or 0
count += 1
if not data or not page.get("has_more") or count >= cap:
return total, count
params["starting_after"] = data[-1]["id"]
def orphan_charges(session, since):
"""Count charges created without a transfer_group, one page deep.
Not the finding itself, but the reason a reconstruction is guesswork even
where Stripe does hand you the payout breakdown.
"""
page = get(session, "/charges", limit=100, **{"created[gte]": since})
data = page.get("data", [])
return sum(1 for c in data if not c.get("transfer_group")), len(data)
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--days", type=int, default=90, help="window to look at")
ap.add_argument("--no-sums", action="store_true",
help="skip the per-payout balance transaction sums")
ap.add_argument("--transfer-groups", action="store_true",
help="also count recent charges with no transfer_group")
args = ap.parse_args()
key = os.environ.get("STRIPE_API_KEY")
if not key:
log.error("set STRIPE_API_KEY (use a restricted, read-only key)")
return 2
s = requests.Session()
s.headers.update({"Authorization": "Bearer " + key})
since = int(time.time()) - args.days * DAY
counts = {}
scanned = 0
params = {"limit": 100, "created[gte]": since}
while True:
page = get(s, "/payouts", **params)
data = page.get("data", [])
for payout in data:
scanned += 1
txn_sum = txn_count = None
if payout.get("reconciliation_status") == "completed" and not args.no_sums:
txn_sum, txn_count = payout_transactions(s, payout["id"], 10000)
state, detail = classify(payout, txn_sum, txn_count)
counts[state] = counts.get(state, 0) + 1
line = "%s %-13s %s" % (payout.get("id", "po_?"), state, detail)
(log.info if state in ("reconciled", "reconcilable", "pending")
else log.warning)(line)
if not data or not page.get("has_more"):
break
params["starting_after"] = data[-1]["id"]
manual = counts.get("manual", 0)
mismatched = counts.get("mismatch", 0)
unsupported = counts.get("unsupported", 0)
log.info("%d payout(s): %d manual, %d mismatched, %d unsupported",
scanned, manual, mismatched, unsupported)
if args.transfer_groups:
orphans, sampled = orphan_charges(s, since)
log.info("%d of %d recent charge(s) have no transfer_group", orphans, sampled)
if manual or unsupported:
log.warning(" repair: move the account to an automatic schedule so future "
"payouts are itemised:")
log.warning(" POST %s/accounts/{id} with "
"settings[payouts][schedule][interval]=daily", API)
log.warning(" for history, run the itemized report: POST "
"%s/reporting/report_runs with "
"report_type=payout_reconciliation.by_id.itemized.1", API)
if mismatched:
log.warning(" repair: the breakdown exists but does not add up. Check for "
"a second currency and for transfers with amount_reversed > 0 "
"in the same window.")
return 1 if (manual or mismatched or unsupported or counts.get("unknown")) else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Report payouts that cannot be tied back to their balance transactions.
*
* Read only. Paginated GETs and no writes: give this a RESTRICTED key with read
* access to Payouts and Balance transactions. The repair is printed, never
* performed.
*/
const API = 'https://api.stripe.com/v1';
const DAY = 86400;
/**
* Sort one payout by whether its contents can be recovered. Pure, so the states
* can be tested without a network. `txnSum` is null when the balance
* transactions were not fetched. Returns [state, detail].
*/
export function classify(payout, txnSum, txnCount) {
const status = payout.reconciliation_status;
const automatic = payout.automatic;
const amount = payout.amount;
if (status === 'completed') {
if (txnSum === null || txnSum === undefined) {
return ['reconcilable',
'reconciliation_status completed: the breakdown exists, this run did ' +
'not fetch it'];
}
if (!Number.isInteger(amount)) {
return ['unknown', `payout has no numeric amount: ${JSON.stringify(amount)}`];
}
if (txnSum !== amount) {
return ['mismatch',
`${txnCount} balance transaction(s) sum to ${txnSum} against a payout ` +
`amount of ${amount}, ${Math.abs(amount - txnSum)} apart: look for ` +
'another currency, a reversal in the window, or a page you stopped ' +
'paginating'];
}
return ['reconciled', `${txnCount} balance transaction(s) sum to the payout`];
}
if (status === 'in_progress') {
return ['pending',
'reconciliation_status in_progress: Stripe is still assembling the ' +
'breakdown, which fills in after the payout settles'];
}
if (status === 'not_applicable') {
if (automatic === false) {
return ['manual',
'manual payout: reconciliation_status not_applicable, so no balance ' +
'transaction will ever list against it. The itemized report is the only ' +
'route to its contents'];
}
return ['unsupported',
'reconciliation_status not_applicable on an automatic payout: Stripe ' +
'itemises standard automatic payouts only'];
}
return ['unknown',
`unrecognised reconciliation_status: ${JSON.stringify(status)}`];
}
async function get(key, path, params = {}) {
const url = new URL(API + path);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
const res = await fetch(url, { headers: { Authorization: `Bearer ${key}` } });
if (res.status === 401) {
throw new Error('401 from Stripe: the key is wrong, or is for the other mode');
}
if (!res.ok) throw new Error(`${res.status} from ${url.pathname}`);
return res.json();
}
export async function payoutTransactions(key, payoutId, cap = 10000) {
let total = 0;
let count = 0;
const params = { payout: payoutId, limit: 100 };
for (;;) {
const page = await get(key, '/balance_transactions', params);
const data = page.data ?? [];
for (const bt of data) {
total += bt.net ?? 0;
count += 1;
}
if (data.length === 0 || !page.has_more || count >= cap) {
return { total, count };
}
params.starting_after = data[data.length - 1].id;
}
}
async function main() {
const key = process.env.STRIPE_API_KEY;
if (!key) {
console.error('set STRIPE_API_KEY (use a restricted, read-only key)');
process.exitCode = 2;
return;
}
const days = Number(process.env.WINDOW_DAYS ?? 90);
const since = Math.floor(Date.now() / 1000) - days * DAY;
const counts = {};
let scanned = 0;
const params = { limit: 100, 'created[gte]': since };
for (;;) {
const page = await get(key, '/payouts', params);
const data = page.data ?? [];
for (const payout of data) {
scanned += 1;
let txnSum = null;
let txnCount = null;
if (payout.reconciliation_status === 'completed') {
const { total, count } = await payoutTransactions(key, payout.id);
txnSum = total;
txnCount = count;
}
const [state, detail] = classify(payout, txnSum, txnCount);
counts[state] = (counts[state] ?? 0) + 1;
const line = `${payout.id ?? 'po_?'} ${state.padEnd(13)} ${detail}`;
if (['reconciled', 'reconcilable', 'pending'].includes(state)) console.log(line);
else console.warn(line);
}
if (data.length === 0 || !page.has_more) break;
params.starting_after = data[data.length - 1].id;
}
const manual = counts.manual ?? 0;
const mismatched = counts.mismatch ?? 0;
const unsupported = counts.unsupported ?? 0;
console.log(`${scanned} payout(s): ${manual} manual, ${mismatched} mismatched, ` +
`${unsupported} unsupported`);
if (manual || unsupported) {
console.warn(' repair: move the account to an automatic schedule so future ' +
'payouts are itemised:');
console.warn(` POST ${API}/accounts/{id} with ` +
'settings[payouts][schedule][interval]=daily');
console.warn(` for history, run the itemized report: POST ` +
`${API}/reporting/report_runs with ` +
'report_type=payout_reconciliation.by_id.itemized.1');
}
if (mismatched) {
console.warn(' repair: the breakdown exists but does not add up. Check for a ' +
'second currency and for transfers with amount_reversed > 0 in ' +
'the same window.');
}
if (manual || mismatched || unsupported || counts.unknown) process.exitCode = 1;
}
// Only run when invoked directly. The test file imports this module, and without
// the guard main() would run there too, fail on the missing key, and set a
// non-zero exit code that fails the whole test file even as every test passes.
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
Three of these tests exist to keep not_applicable from being one thing. On a manual payout it is a decision somebody made and can unmake; on an automatic one it means Stripe does not itemise that kind of payout at all. The fourth pins the arithmetic, because a breakdown that responds and does not add up is the failure most reconciliations are not looking for.
from stripe_payout_reconciliation import classify
def test_manual_payout_can_never_be_listed_against():
state, detail = classify(
{"id": "po_1", "amount": 500000, "automatic": False,
"reconciliation_status": "not_applicable"}, None, None)
assert state == "manual"
assert "itemized report" in detail
def test_not_applicable_on_an_automatic_payout_is_different():
# Same field value, different cause: nothing about the schedule will change
# this one, because Stripe only itemises standard automatic payouts.
state, _ = classify(
{"id": "po_2", "amount": 500000, "automatic": True,
"reconciliation_status": "not_applicable"}, None, None)
assert state == "unsupported"
def test_completed_payout_whose_transactions_do_not_add_up():
state, detail = classify(
{"id": "po_3", "amount": 500000, "automatic": True,
"reconciliation_status": "completed"}, 497500, 84)
assert state == "mismatch"
assert "2500 apart" in detail
def test_completed_and_balanced_is_the_healthy_case():
state, _ = classify(
{"id": "po_4", "amount": 500000, "automatic": True,
"reconciliation_status": "completed"}, 500000, 84)
assert state == "reconciled"
def test_in_progress_is_not_reported_as_broken():
state, _ = classify(
{"id": "po_5", "amount": 500000, "automatic": True,
"reconciliation_status": "in_progress"}, None, None)
assert state == "pending"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { classify } from './stripe-payout-reconciliation.mjs';
test('manual payout can never be listed against', () => {
const [state, detail] = classify(
{ id: 'po_1', amount: 500000, automatic: false,
reconciliation_status: 'not_applicable' }, null, null);
assert.equal(state, 'manual');
assert.match(detail, /itemized report/);
});
test('not_applicable on an automatic payout is different', () => {
const [state] = classify(
{ id: 'po_2', amount: 500000, automatic: true,
reconciliation_status: 'not_applicable' }, null, null);
assert.equal(state, 'unsupported');
});
test('completed payout whose transactions do not add up', () => {
const [state, detail] = classify(
{ id: 'po_3', amount: 500000, automatic: true,
reconciliation_status: 'completed' }, 497500, 84);
assert.equal(state, 'mismatch');
assert.match(detail, /2500 apart/);
});
test('completed and balanced is the healthy case', () => {
const [state] = classify(
{ id: 'po_4', amount: 500000, automatic: true,
reconciliation_status: 'completed' }, 500000, 84);
assert.equal(state, 'reconciled');
});
test('in_progress is not reported as broken', () => {
const [state] = classify(
{ id: 'po_5', amount: 500000, automatic: true,
reconciliation_status: 'in_progress' }, null, null);
assert.equal(state, 'pending');
});
FAQ
Why does balance_transactions?payout=po_x return an empty list?
Because that payout's reconciliation_status is not completed. Stripe only assembles the list of balance transactions for standard automatic payouts, so a payout you created yourself has no breakdown to return and the filter matches nothing.
Does switching to an automatic schedule fix the payouts I already made?
No. It changes the status of payouts made from that point on. History is only recoverable through the Reporting API, using payout_reconciliation.by_id.itemized.1 for a single payout or payout_reconciliation.itemized.7 over an interval.
What does in_progress mean, and how long does it last?
Stripe is still assembling the breakdown. It resolves on its own after the payout settles, so an in_progress payout from this morning is not a finding. One that has been in_progress for days is worth a support ticket.
The transactions sum close to the payout but not exactly. What is missing?
Usually one of three things: transactions in a second currency, a transfer reversal that landed inside the window, or pagination that stopped at the first page. Check amount_reversed on transfers in the same period before assuming the API is wrong.
What does transfer_group actually buy me?
A join key you own. Charges and transfers sharing a transfer_group can be reassembled from your own data regardless of what the payout object reports, which is the only reconciliation route that does not depend on Stripe's payout mechanics.
Related field notes
- A payout schedule left on manual strands the balance
- Checkout sessions that cannot be reconciled to orders
- A second-currency balance bucket can never be paid out
When reconciliation_status never reaches completed, no amount of API work recovers the breakdown — but the books do not actually need it. The standard treatment is to journal the period's activity against a clearing account and book each payout as a bank transfer out of that clearing balance; what is left in clearing is the money still sitting at Stripe, which you can tie to the balance endpoint. Payoutproof does exactly that for WooCommerce stores, automatically, for the payouts this note says cannot be itemised.
Sources
Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.
- The payout object — Stripe API reference
- Payout reconciliation report — Stripe Docs
- Reporting API — Stripe Docs
- The transfer object — Stripe API reference
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.