Diagnostic Stripe
a second-currency balance bucket can never be paid out
The Stripe balance and the bank statements have been out by the same figure for months. It is not a rounding error and it is not a timing difference: it is a few hundred euros, on an account that pays out in dollars, sitting in a bucket that no payout has ever touched or ever will.
GET /v1/balance returns available and pending as arrays, one entry per currency. Flag any account where either array has more than one entry, then check each currency for an exit: GET /v1/accounts/{id}/external_accounts must have a destination whose currency matches, and GET /v1/payouts should show that currency actually being paid out.
Automatic payouts only clear a currency that has a matching external account marked default_for_currency. Without one, the bucket accumulates and never drains, and a reconciler reading available[0].amount never sees it.
The problem in plain words
Nothing about this looks like a fault. The Dashboard shows the balance, the payouts run on schedule, and the payments in the stranded currency succeeded exactly as intended. The only symptom is a reconciliation difference that never closes, which is the kind of thing that gets a note in a spreadsheet rather than a ticket.
It is usually created by a decision nobody remembers making. A payment method configuration was widened, or a Checkout page was localised, or one enterprise customer was invoiced in their own currency as a favour. A handful of charges settle in EUR on a USD-only account, and from that moment there is a permanent second bucket with no way out of it.
Why it happens
Payouts are per currency, and so are external accounts. Stripe will not convert a EUR balance to send it to a USD bank. The payout has to go to a destination that holds that currency, and if no external account has currency: "eur" there is nothing for the payout to target.
default_for_currency is the field that actually decides it. Adding a EUR bank account is not enough on its own: automatic payouts follow the destination flagged as the default for that currency. An external account added and never flagged leaves the balance exactly where it was, which is why this problem sometimes survives a first attempt at fixing it.
The array shape hides it from every reconciler written in a hurry. available looks like a single number in the Dashboard and is a list in the API. Code that reads available[0] gets whichever currency Stripe happened to list first, reconciles it perfectly, and is structurally incapable of noticing the second entry.
The pending array is the early warning. Funds land in pending first and move to available after the settlement delay. A currency whose pending grows while its available stays flat is a bucket being filled with the tap already known to be blocked, and it is visible days before the money is stuck.
The fix, as a flow
The script reads both balance arrays, the external accounts and the recent payouts, because a missing destination and a destination nobody uses look identical on the balance and need different repairs.
How to fix it
Read both arrays in full, on every account
GET /v1/balance for the platform, then the same call with a Stripe-Account header per connected account. More than one entry in available or pending is the flag; a single entry with a non-zero amount in a currency you do not bank in is the same problem with only one bucket.
Look for an exit for each currency
GET /v1/accounts/{id}/external_accounts?limit=100 and match on the currency field of each bank account or card. No match means the balance is stranded by construction, not delayed.
Check that payouts in that currency have actually happened
A destination can exist and still not be used, because default_for_currency was never set on it. GET /v1/payouts?limit=100 over the last 90 days, grouped by currency, is the evidence that the route works rather than the assumption that it does.
Separate stranded from merely accruing
Money already in available with no destination is stuck now. Money in pending with no destination will be stuck in a few days. They deserve the same fix and a different urgency, and reporting them as one number loses that.
Either add the destination or stop accepting the currency
Both are legitimate. If you want the currency, add an external account in it and set default_for_currency. If you do not, remove it from the payment method configuration so no new charges settle there, and then drain the residue once with a one-off payout to a destination you added for the purpose.
How to check it worked
Re-run the script. Every currency with a balance should report a destination and recent payouts, and the account total should reconcile against the bank without a residual.
python3 stripe_stranded_currency.py
# usd draining destination present, 14 payout(s) in the window
# 1 bucket(s): 0 stranded, 0 accruing, 0 stalled
The full code
Three GETs: the balance, the external accounts, and the recent payouts, all read-only. The classifier is pure and takes the four facts that decide the outcome for one currency — what is settled, what is on the way, whether a destination exists, and whether payouts in that currency have actually happened — because a missing destination and an unused destination look the same on the balance and need different repairs.
"""Report Stripe balance currencies that no payout can drain.
Read only. Three GETs and no writes: give this a RESTRICTED key with read access
to Balance, Connected accounts and Payouts. 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_stranded_currency")
API = "https://api.stripe.com/v1"
DAY = 86400
def classify(entry, pending, has_destination, payouts_seen):
"""Sort one currency of a Stripe balance. Pure, so the states can be tested
without a network.
`entry` is one element of balance.available. `pending` is the amount in the
matching balance.pending entry. `has_destination` is whether any external
account on this account holds that currency, and `payouts_seen` is how many
payouts in that currency happened in the window.
Returns (state, detail).
"""
amount = entry.get("amount")
if not isinstance(amount, int):
return ("unknown", "available entry has no numeric amount: %r" % (amount,))
if not has_destination:
if amount > 0:
return ("stranded",
"%d settled with no external account in this currency: no "
"automatic payout can target it, so it will sit here "
"indefinitely" % amount)
if pending > 0:
return ("accruing",
"%d still pending with no external account in this currency: "
"it becomes stranded when it settles" % pending)
return ("clear", "no destination for this currency, but nothing is in it")
if amount > 0 and not payouts_seen:
return ("stalled",
"%d settled and a destination exists, but no payout in this "
"currency in the window: the external account is probably not "
"default_for_currency" % amount)
if amount > 0 or pending > 0:
return ("draining",
"destination present, %d payout(s) in the window" % payouts_seen)
return ("clear", "empty bucket")
def get(session, path, headers=None, **params):
r = session.get(API + path, params=params, headers=headers, 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_currencies(session, headers, since):
"""Count payouts per currency over the window."""
counts = {}
params = {"limit": 100, "created[gte]": since}
while True:
page = get(session, "/payouts", headers=headers, **params)
data = page.get("data", [])
for p in data:
cur = p.get("currency", "?")
counts[cur] = counts.get(cur, 0) + 1
if not data or not page.get("has_more"):
return counts
params["starting_after"] = data[-1]["id"]
def destination_currencies(session, headers, account_id):
"""Currencies that have somewhere to be paid out to."""
out = set()
params = {"limit": 100}
while True:
page = get(session, "/accounts/%s/external_accounts" % account_id,
headers=headers, **params)
data = page.get("data", [])
for ext in data:
if ext.get("currency"):
out.add(ext["currency"])
if not data or not page.get("has_more"):
return out
params["starting_after"] = data[-1]["id"]
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--account", help="a connected account id to check instead of "
"the account the key belongs to")
ap.add_argument("--days", type=int, default=90,
help="how far back to look for payouts in each currency")
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})
headers = {"Stripe-Account": args.account} if args.account else None
since = int(time.time()) - args.days * DAY
account_id = args.account or get(s, "/account").get("id", "")
balance = get(s, "/balance", headers=headers)
destinations = destination_currencies(s, headers, account_id)
payouts = payout_currencies(s, headers, since)
pending = {e.get("currency", "?"): e.get("amount") or 0
for e in balance.get("pending", [])}
available = balance.get("available", [])
# Union both arrays: a currency can be entirely in pending, which is the
# state worth catching because it is the one you can still act on early.
currencies = {e.get("currency", "?") for e in available} | set(pending)
counts = {}
for cur in sorted(currencies):
entry = next((e for e in available if e.get("currency") == cur),
{"currency": cur, "amount": 0})
state, detail = classify(entry, pending.get(cur, 0),
cur in destinations, payouts.get(cur, 0))
counts[state] = counts.get(state, 0) + 1
line = "%-4s %-10s %s" % (cur, state, detail)
(log.info if state in ("clear", "draining") else log.warning)(line)
stranded = counts.get("stranded", 0)
accruing = counts.get("accruing", 0)
stalled = counts.get("stalled", 0)
log.info("%d bucket(s): %d stranded, %d accruing, %d stalled",
len(currencies), stranded, accruing, stalled)
if stranded or accruing:
log.warning(" repair: add a destination in that currency and make it the "
"default, or stop accepting the currency:")
log.warning(" POST %s/accounts/%s with external_account in the currency, "
"then default_for_currency=true", API, account_id or "{id}")
if stalled:
log.warning(" repair: a destination exists but is not being used. Check "
"default_for_currency on it before adding another one.")
return 1 if (stranded or accruing or stalled or counts.get("unknown")) else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Report Stripe balance currencies that no payout can drain.
*
* Read only. Three GETs and no writes: give this a RESTRICTED key with read
* access to Balance, Connected accounts and Payouts. The repair is printed,
* never performed.
*/
const API = 'https://api.stripe.com/v1';
const DAY = 86400;
/**
* Sort one currency of a Stripe balance. Pure, so the states can be tested
* without a network. Returns [state, detail].
*/
export function classify(entry, pending, hasDestination, payoutsSeen) {
const amount = entry.amount;
if (typeof amount !== 'number' || !Number.isFinite(amount)) {
return ['unknown',
`available entry has no numeric amount: ${JSON.stringify(amount)}`];
}
if (!hasDestination) {
if (amount > 0) {
return ['stranded',
`${amount} settled with no external account in this currency: no ` +
'automatic payout can target it, so it will sit here indefinitely'];
}
if (pending > 0) {
return ['accruing',
`${pending} still pending with no external account in this currency: ` +
'it becomes stranded when it settles'];
}
return ['clear', 'no destination for this currency, but nothing is in it'];
}
if (amount > 0 && !payoutsSeen) {
return ['stalled',
`${amount} settled and a destination exists, but no payout in this ` +
'currency in the window: the external account is probably not ' +
'default_for_currency'];
}
if (amount > 0 || pending > 0) {
return ['draining', `destination present, ${payoutsSeen} payout(s) in the window`];
}
return ['clear', 'empty bucket'];
}
async function get(key, path, params = {}, headers = {}) {
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}`, ...headers },
});
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 payoutCurrencies(key, headers, since) {
const counts = new Map();
const params = { limit: 100, 'created[gte]': since };
for (;;) {
const page = await get(key, '/payouts', params, headers);
const data = page.data ?? [];
for (const p of data) {
const cur = p.currency ?? '?';
counts.set(cur, (counts.get(cur) ?? 0) + 1);
}
if (data.length === 0 || !page.has_more) return counts;
params.starting_after = data[data.length - 1].id;
}
}
export async function destinationCurrencies(key, headers, accountId) {
const out = new Set();
const params = { limit: 100 };
for (;;) {
const page = await get(key, `/accounts/${accountId}/external_accounts`,
params, headers);
const data = page.data ?? [];
for (const ext of data) if (ext.currency) out.add(ext.currency);
if (data.length === 0 || !page.has_more) return out;
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 account = process.env.STRIPE_ACCOUNT ?? null;
const headers = account ? { 'Stripe-Account': account } : {};
const since = Math.floor(Date.now() / 1000) - 90 * DAY;
const accountId = account ?? (await get(key, '/account')).id;
const balance = await get(key, '/balance', {}, headers);
const destinations = await destinationCurrencies(key, headers, accountId);
const payouts = await payoutCurrencies(key, headers, since);
const pending = new Map((balance.pending ?? [])
.map((e) => [e.currency ?? '?', e.amount ?? 0]));
const available = balance.available ?? [];
const currencies = new Set([
...available.map((e) => e.currency ?? '?'), ...pending.keys(),
]);
const counts = {};
for (const cur of [...currencies].sort()) {
const entry = available.find((e) => e.currency === cur)
?? { currency: cur, amount: 0 };
const [state, detail] = classify(entry, pending.get(cur) ?? 0,
destinations.has(cur), payouts.get(cur) ?? 0);
counts[state] = (counts[state] ?? 0) + 1;
const line = `${cur.padEnd(4)} ${state.padEnd(10)} ${detail}`;
if (state === 'clear' || state === 'draining') console.log(line);
else console.warn(line);
}
const stranded = counts.stranded ?? 0;
const accruing = counts.accruing ?? 0;
const stalled = counts.stalled ?? 0;
console.log(`${currencies.size} bucket(s): ${stranded} stranded, ` +
`${accruing} accruing, ${stalled} stalled`);
if (stranded || accruing) {
console.warn(' repair: add a destination in that currency and make it the ' +
'default, or stop accepting the currency:');
console.warn(` POST ${API}/accounts/${accountId} with external_account in ` +
'the currency, then default_for_currency=true');
}
if (stalled) {
console.warn(' repair: a destination exists but is not being used. Check ' +
'default_for_currency on it before adding another one.');
}
if (stranded || accruing || stalled || 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
The interesting boundary is between stranded and stalled: both are money that is not moving, but one has no destination at all and the other has one that automatic payouts are ignoring. The accruing test covers the case that is still cheap to fix, where the funds are in pending and the exit does not exist yet.
from stripe_stranded_currency import classify
def test_settled_funds_with_no_destination_are_stranded():
state, detail = classify({"currency": "eur", "amount": 41200}, 0, False, 0)
assert state == "stranded"
assert "41200" in detail
def test_pending_funds_with_no_destination_are_caught_early():
# Nothing has settled yet, so this is the version of the problem you can
# still fix before anybody has to reconcile around it.
state, detail = classify({"currency": "eur", "amount": 0}, 8000, False, 0)
assert state == "accruing"
assert "stranded when it settles" in detail
def test_a_destination_that_never_pays_out_is_its_own_state():
state, detail = classify({"currency": "gbp", "amount": 9500}, 0, True, 0)
assert state == "stalled"
assert "default_for_currency" in detail
def test_destination_and_payouts_is_healthy():
state, _ = classify({"currency": "usd", "amount": 250000}, 40000, True, 14)
assert state == "draining"
def test_empty_bucket_with_no_destination_is_not_a_problem():
state, _ = classify({"currency": "eur", "amount": 0}, 0, False, 0)
assert state == "clear"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { classify } from './stripe-stranded-currency.mjs';
test('settled funds with no destination are stranded', () => {
const [state, detail] = classify({ currency: 'eur', amount: 41200 }, 0, false, 0);
assert.equal(state, 'stranded');
assert.match(detail, /41200/);
});
test('pending funds with no destination are caught early', () => {
const [state, detail] = classify({ currency: 'eur', amount: 0 }, 8000, false, 0);
assert.equal(state, 'accruing');
assert.match(detail, /stranded when it settles/);
});
test('a destination that never pays out is its own state', () => {
const [state, detail] = classify({ currency: 'gbp', amount: 9500 }, 0, true, 0);
assert.equal(state, 'stalled');
assert.match(detail, /default_for_currency/);
});
test('destination and payouts is healthy', () => {
assert.equal(
classify({ currency: 'usd', amount: 250000 }, 40000, true, 14)[0], 'draining');
});
test('empty bucket with no destination is not a problem', () => {
assert.equal(classify({ currency: 'eur', amount: 0 }, 0, false, 0)[0], 'clear');
});
FAQ
Why will Stripe not just convert the balance and pay it out?
Payouts settle in the currency of the funds. Stripe pays a EUR balance to a destination that holds EUR, and will not convert it into your USD bank account on the way. Without a matching external account there is nothing for the payout to target.
I added a bank account in that currency and nothing happened. Why?
Adding the destination is only half of it. Automatic payouts follow the external account flagged default_for_currency for that currency, so an account added without the flag sits there unused and the balance does not move.
How did I end up with a currency I do not sell in?
Usually a widened payment method configuration, a localised Checkout page, or one invoice raised in a customer's own currency. A handful of charges is enough: once any amount settles in a currency, the bucket exists permanently.
Is a growing pending amount in that currency worse?
It is the same problem earlier. Funds land in pending and move to available after the settlement delay, so a currency whose pending grows while its available stays flat tells you money is on its way into a bucket with no exit, days before it is stuck.
Does this need a live secret key?
No. Balance, Connected accounts and Payouts with read access covers all three requests, including the per-account version through the Stripe-Account header.
Related field notes
- No external account is attached so payouts cannot land
- A payout schedule left on manual strands the balance
- connect_reserved grows as connected accounts go negative
A stranded bucket is worth reconciling on its own terms rather than converting it mentally to your home currency and hoping. Each balance settles into its own bank line and should carry its own clearing account. Payoutproof reconciles each payment account separately for WooCommerce, and keeps currency movements as an explicit journal line rather than a rounding difference.
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 balance object — Stripe API reference
- Payouts — Stripe Docs
- Supported currencies — Stripe Docs
- Payouts to connected accounts — Stripe Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.