Diagnostic Stripe
a bank account sits at status errored and payouts stop
A seller's payout failed in July. Somebody looked, saw one failure, assumed a temporary bank problem and moved on. It is now September, the seller's balance has grown to five figures, and there have been no further failed payouts at all — which is exactly what everyone took as evidence that the problem had resolved itself.
Read status on the external account itself: GET /v1/accounts/{id}/external_accounts?limit=100. When a payout fails, Stripe marks the destination errored and stops sending scheduled payouts to it until new details are attached. Two other statuses do the same thing for different reasons: verification_failed and tokenized_account_number_deactivated.
Corroborate with a balance that is going nowhere — GET /v1/balance and GET /v1/payouts?limit=1 with the Stripe-Account header — and note the repair: for errored, editing the account and routing numbers on the existing object does not clear the status. A new external account, made default_for_currency, is the reliable fix.
The problem in plain words
Everything about this failure argues that it is over. There was one bad event, it was weeks ago, and nothing bad has happened since. Any monitor built around GET /v1/payouts?status=failed agrees: the count went up by one and then stayed flat. Flat is what recovery looks like.
It is not recovery. It is the absence of attempts. Stripe froze the destination on the first failure, so there is nothing left to fail, and the metric everyone is watching goes quiet precisely because the problem became permanent. Meanwhile the money accumulates in a balance nobody is looking at, and the seller — who receives no notification when their account is Custom — finds out when they go to reconcile the quarter.
Why it happens
The state lives on the destination, not on the payout. Payout objects record what happened to individual attempts. The external account records whether attempts will happen at all. Those are different objects, and only one of them is in most people's dashboards.
errored is sticky in a way that surprises people. It is not cleared by fixing the numbers in place. Updating the routing or account number on the existing bank account leaves the status where it is; Stripe wants a fresh external account attached and set as the default for that currency. Teams that patch the existing object see no change and conclude the API is broken.
Three different statuses stop payouts for three different reasons. errored follows a failed payout. verification_failed means the ownership details did not check out. tokenized_account_number_deactivated means the token behind the destination was deactivated and it has to be re-linked. Same symptom, three repairs, and a check that only greps for errored finds one of them.
Nothing on the account object says any of this. payouts_enabled can stay true with a frozen destination, because the capability is fine — it is the bank details that are not. An account-level health check passes cleanly the whole time.
The fix, as a flow
The script reads the destination rather than the payouts, then spends two extra calls only where the status already says payouts stopped, so the corroboration is cheap enough to run across every account.
How to fix it
List the destinations, not the payouts
GET /v1/accounts/{id}/external_accounts?limit=100 for each connected account, and for the platform's own account too. Read status on every one. This is the only field that distinguishes "no payouts because nothing was owed" from "no payouts because Stripe stopped trying".
Corroborate with a balance that is not moving
A frozen destination on an account with a zero balance is untidy. A frozen destination on an account with a growing available balance and no payout for six weeks is money that belongs to somebody else sitting on your platform. Fetch GET /v1/balance and the most recent payout with the Stripe-Account header, and only for the accounts that already look halted, so the check stays cheap.
Read which status it is before writing the repair ticket
The three halting statuses need three different conversations with the seller: fresh bank details, identity details that match the account holder, or a re-link through Financial Connections. Printing the status without the corresponding repair produces a ticket that bounces once before anyone can act on it.
Check whether the frozen one is even the default
default_for_currency tells you whether this destination is the one payouts actually use. An errored account that is not the default is a cleanup task; the default being errored is why the money stopped. The distinction decides which list the account goes on.
Attach a new external account rather than editing the old one
New bank details, tokenized, attached to the account, then set default_for_currency on the new object. After that, re-check status: the new destination should read new or validated, and the accumulated balance pays out on the next scheduled run.
How to check it worked
Re-run the script. Every destination should read a healthy status, and no account should be holding a balance behind a frozen one.
python3 stripe_external_account_errored.py
# 412 account(s), 418 destination(s): 0 halted, 0 stranded
The full code
One paginated GET for the accounts, one per account for its destinations, and the balance and last-payout lookups only for the accounts that already look halted — corroboration is worth two extra calls on a handful of accounts and not worth six hundred on a healthy platform. The classifier is pure and takes the evidence as arguments, including the case where it was not gathered.
"""Report connected accounts whose payout destination Stripe has stopped using.
Read only. GETs only, no writes: give this a RESTRICTED key with read access to
Connected accounts, Bank accounts, Payouts and Balance. 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_external_account_errored")
API = "https://api.stripe.com/v1"
DAY = 86400
# Statuses where Stripe has stopped sending scheduled payouts to this
# destination. Same symptom, three different repairs, which is why the table is
# the check rather than a comparison against the one status everybody knows.
HALTED = {
"errored":
"a payout to this destination failed. Editing the account or routing "
"number on the existing object does not clear this: attach a NEW external "
"account and set default_for_currency on it.",
"verification_failed":
"the ownership details behind this destination could not be verified. "
"Attach a new external account whose holder details match the account.",
"tokenized_account_number_deactivated":
"the tokenized account number behind this destination was deactivated. "
"Re-link the bank through Financial Connections to mint a new one.",
}
# Statuses where payouts can be sent. `new` simply means Stripe has not yet had
# reason to validate it, which is not a problem.
HEALTHY = ("new", "validated", "verified")
def verdict(external, last_payout_created, available_amount, now):
"""Classify one external account. Pure. Returns (state, detail).
`last_payout_created` and `available_amount` are the corroborating evidence
and may be None, meaning it was not looked up. That is deliberate: the
evidence is only fetched for destinations that already look halted, so the
classifier has to be honest about the difference between "no money stranded"
and "nobody checked".
"""
if external is None:
return ("no-destination",
"no external account attached at all: there is nothing for a payout "
"to be sent to")
status = (external.get("status") or "").lower()
if status in HALTED:
bits = ["status %s" % status, HALTED[status]]
if available_amount:
bits.append("%d (minor units) sitting in the available balance"
% available_amount)
if last_payout_created is not None:
bits.append("last payout %d day(s) ago"
% ((now - last_payout_created) // DAY))
elif available_amount is not None:
bits.append("no payout has ever been attempted")
if not external.get("default_for_currency"):
bits.append("not the default destination for %s, so cleanup rather than "
"the cause" % (external.get("currency") or "its currency"))
state = "stranded" if available_amount else "halted"
return (state, " | ".join(bits))
if status in HEALTHY:
return ("healthy", "status %s: payouts can be sent here" % status)
return ("unknown", "unrecognised status %r: read it before assuming it is fine"
% (external.get("status"),))
def get(session, path, account=None, **params):
headers = {"Stripe-Account": account} if account else None
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 accounts(session, cap):
"""Yield connected accounts, paginating until Stripe stops or the cap is hit."""
seen = 0
params = {"limit": 100}
while True:
page = get(session, "/accounts", **params)
data = page.get("data", [])
for acct in data:
yield acct
seen += 1
if seen >= cap:
return
if not data or not page.get("has_more"):
return
params["starting_after"] = data[-1]["id"]
def evidence(session, account_id):
"""Balance and last payout for one account. Only called when it may matter."""
balance = get(session, "/balance", account=account_id)
available = sum(b.get("amount", 0) for b in balance.get("available", []) or [])
payouts = get(session, "/payouts", account=account_id, limit=1)
data = payouts.get("data", [])
return (data[0].get("created") if data else None), available
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--account", help="check one connected account instead of all")
ap.add_argument("--max-accounts", type=int, default=5000,
help="stop paginating after this many accounts")
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})
now = int(time.time())
if args.account:
targets = [{"id": args.account}]
else:
targets = list(accounts(s, args.max_accounts))
counts = {}
destinations = 0
for acct in targets:
acct_id = acct.get("id")
banks = get(s, "/accounts/%s/external_accounts" % acct_id,
object="bank_account", limit=100).get("data", [])
if not banks:
state, detail = verdict(None, None, None, now)
counts[state] = counts.get(state, 0) + 1
log.warning("%s %-14s %s", acct_id, state, detail)
continue
for bank in banks:
destinations += 1
# The evidence costs two extra calls, so only spend them where the
# status already says payouts have stopped.
if (bank.get("status") or "").lower() in HALTED:
last_payout, available = evidence(s, acct_id)
else:
last_payout, available = None, None
state, detail = verdict(bank, last_payout, available, now)
counts[state] = counts.get(state, 0) + 1
if state == "healthy":
continue
log.warning("%s %s %-14s %s", acct_id, bank.get("id", "ba_?"),
state, detail)
halted = counts.get("halted", 0)
stranded = counts.get("stranded", 0)
log.info("%d account(s), %d destination(s): %d halted, %d stranded",
len(targets), destinations, halted, stranded)
if halted or stranded:
log.warning(" repair: attach fresh details rather than editing the frozen "
"object, then make the new one default:")
log.warning(" POST %s/accounts/{id} with external_account={{BANK_TOKEN}}", API)
log.warning(" POST %s/accounts/{id}/external_accounts/{ba_id} with "
"default_for_currency=true", API)
log.warning(" check: a flat count of failed payouts is not recovery when the "
"destination is frozen, because nothing is being attempted")
if counts.get("no-destination"):
log.warning(" %d account(s) have no bank account attached at all",
counts["no-destination"])
return 1 if (halted or stranded or counts.get("no-destination")
or counts.get("unknown")) else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Report connected accounts whose payout destination Stripe has stopped using.
*
* Read only. GETs only, no writes: give this a RESTRICTED key with read access
* to Connected accounts, Bank accounts, Payouts and Balance. The repair is
* printed, never performed.
*/
const API = 'https://api.stripe.com/v1';
const DAY = 86400;
// Statuses where Stripe has stopped sending scheduled payouts to this
// destination. Same symptom, three different repairs.
const HALTED = {
errored:
'a payout to this destination failed. Editing the account or routing number ' +
'on the existing object does not clear this: attach a NEW external account ' +
'and set default_for_currency on it.',
verification_failed:
'the ownership details behind this destination could not be verified. Attach ' +
'a new external account whose holder details match the account.',
tokenized_account_number_deactivated:
'the tokenized account number behind this destination was deactivated. ' +
'Re-link the bank through Financial Connections to mint a new one.',
};
// Statuses where payouts can be sent. `new` means Stripe has had no reason to
// validate it yet, which is not a problem.
const HEALTHY = ['new', 'validated', 'verified'];
/**
* Classify one external account. Pure. Returns [state, detail].
* The evidence arguments may be null, meaning nobody looked: it is only fetched
* for destinations that already look halted, and the classifier has to keep
* "no money stranded" distinct from "not checked".
*/
export function verdict(external, lastPayoutCreated, availableAmount, now) {
if (external === null || external === undefined) {
return ['no-destination',
'no external account attached at all: there is nothing for a payout to be ' +
'sent to'];
}
const status = (external.status ?? '').toLowerCase();
if (Object.prototype.hasOwnProperty.call(HALTED, status)) {
const bits = [`status ${status}`, HALTED[status]];
if (availableAmount) {
bits.push(`${availableAmount} (minor units) sitting in the available balance`);
}
if (lastPayoutCreated !== null && lastPayoutCreated !== undefined) {
bits.push(`last payout ${Math.floor((now - lastPayoutCreated) / DAY)} day(s) ago`);
} else if (availableAmount !== null && availableAmount !== undefined) {
bits.push('no payout has ever been attempted');
}
if (!external.default_for_currency) {
bits.push(`not the default destination for ${external.currency ?? 'its currency'}, ` +
'so cleanup rather than the cause');
}
return [availableAmount ? 'stranded' : 'halted', bits.join(' | ')];
}
if (HEALTHY.includes(status)) {
return ['healthy', `status ${status}: payouts can be sent here`];
}
return ['unknown',
`unrecognised status ${JSON.stringify(external.status)}: read it before ` +
'assuming it is fine'];
}
async function get(key, path, { account, ...params } = {}) {
const url = new URL(API + path);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
const headers = { Authorization: `Bearer ${key}` };
if (account) headers['Stripe-Account'] = account;
const res = await fetch(url, { 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* accounts(key, cap = 5000) {
let seen = 0;
const params = { limit: 100 };
for (;;) {
const page = await get(key, '/accounts', params);
const data = page.data ?? [];
for (const acct of data) {
yield acct;
seen += 1;
if (seen >= cap) return;
}
if (data.length === 0 || !page.has_more) return;
params.starting_after = data[data.length - 1].id;
}
}
async function evidence(key, accountId) {
const balance = await get(key, '/balance', { account: accountId });
const available = (balance.available ?? []).reduce((t, b) => t + (b.amount ?? 0), 0);
const payouts = await get(key, '/payouts', { account: accountId, limit: 1 });
const data = payouts.data ?? [];
return [data.length ? data[0].created : null, available];
}
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 now = Math.floor(Date.now() / 1000);
const targets = [];
for await (const acct of accounts(key)) targets.push(acct);
const counts = new Map();
let destinations = 0;
for (const acct of targets) {
const banks = (await get(key, `/accounts/${acct.id}/external_accounts`,
{ object: 'bank_account', limit: 100 })).data ?? [];
if (banks.length === 0) {
const [state, detail] = verdict(null, null, null, now);
counts.set(state, (counts.get(state) ?? 0) + 1);
console.warn(`${acct.id} ${state.padEnd(14)} ${detail}`);
continue;
}
for (const bank of banks) {
destinations += 1;
// The evidence costs two extra calls, so only spend them where the status
// already says payouts have stopped.
const halted = Object.prototype.hasOwnProperty.call(
HALTED, (bank.status ?? '').toLowerCase());
const [lastPayout, available] = halted
? await evidence(key, acct.id) : [null, null];
const [state, detail] = verdict(bank, lastPayout, available, now);
counts.set(state, (counts.get(state) ?? 0) + 1);
if (state === 'healthy') continue;
console.warn(`${acct.id} ${bank.id ?? 'ba_?'} ${state.padEnd(14)} ${detail}`);
}
}
const halted = counts.get('halted') ?? 0;
const stranded = counts.get('stranded') ?? 0;
console.log(`${targets.length} account(s), ${destinations} destination(s): ` +
`${halted} halted, ${stranded} stranded`);
if (halted || stranded) {
console.warn(' repair: attach fresh details rather than editing the frozen ' +
'object, then make the new one default:');
console.warn(` POST ${API}/accounts/{id} with external_account={{BANK_TOKEN}}`);
console.warn(` POST ${API}/accounts/{id}/external_accounts/{ba_id} with ` +
'default_for_currency=true');
console.warn(' check: a flat count of failed payouts is not recovery when the ' +
'destination is frozen, because nothing is being attempted');
}
if (counts.get('no-destination')) {
console.warn(` ${counts.get('no-destination')} account(s) have no bank account ` +
'attached at all');
}
if (halted || stranded || counts.get('no-destination') || counts.get('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 tests cover all three halting statuses, because the repair text is the deliverable and getting it wrong sends a seller to fix the wrong thing. They also pin the difference between evidence that came back empty and evidence that was never gathered, which is the one place a classifier like this quietly lies.
from stripe_external_account_errored import HALTED, verdict
NOW = 1767225600 # 2026-01-01T00:00:00Z
def bank(status, default=True, currency="usd"):
return {"id": "ba_1", "status": status, "currency": currency,
"default_for_currency": default}
def test_a_validated_account_is_healthy():
state, detail = verdict(bank("validated"), None, None, NOW)
assert state == "healthy"
assert "payouts can be sent" in detail
def test_new_is_not_an_error():
# `new` only means Stripe has had no reason to validate it yet.
assert verdict(bank("new"), None, None, NOW)[0] == "healthy"
def test_every_halting_status_is_caught_and_carries_its_own_repair():
for status in HALTED:
state, detail = verdict(bank(status), None, None, NOW)
assert state == "halted", status
assert status in detail
def test_errored_says_not_to_edit_the_existing_object():
_, detail = verdict(bank("errored"), None, None, NOW)
assert "does not clear this" in detail
assert "NEW external account" in detail
def test_a_balance_behind_a_frozen_destination_is_stranded():
state, detail = verdict(bank("errored"), NOW - 45 * 86400, 812340, NOW)
assert state == "stranded"
assert "812340" in detail
assert "45 day(s) ago" in detail
def test_evidence_that_was_never_gathered_is_not_reported_as_no_money():
# available_amount None means nobody looked. Saying "no payout has ever been
# attempted" in that case would be an invention.
_, detail = verdict(bank("errored"), None, None, NOW)
assert "no payout has ever been attempted" not in detail
_, detail = verdict(bank("errored"), None, 0, NOW)
assert "no payout has ever been attempted" in detail
def test_a_frozen_non_default_destination_is_flagged_as_cleanup():
_, detail = verdict(bank("verification_failed", default=False), None, None, NOW)
assert "not the default destination for usd" in detail
def test_no_bank_account_at_all_is_its_own_answer():
state, _ = verdict(None, None, None, NOW)
assert state == "no-destination"
def test_an_unrecognised_status_is_not_assumed_healthy():
assert verdict(bank("some_new_status"), None, None, NOW)[0] == "unknown"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict } from './stripe-external-account-errored.mjs';
const NOW = 1767225600; // 2026-01-01T00:00:00Z
const bank = (status, defaultForCurrency = true, currency = 'usd') => ({
id: 'ba_1', status, currency, default_for_currency: defaultForCurrency,
});
test('a validated account is healthy', () => {
const [state, detail] = verdict(bank('validated'), null, null, NOW);
assert.equal(state, 'healthy');
assert.match(detail, /payouts can be sent/);
});
test('new is not an error', () => {
assert.equal(verdict(bank('new'), null, null, NOW)[0], 'healthy');
});
test('every halting status is caught and carries its own repair', () => {
for (const status of ['errored', 'verification_failed',
'tokenized_account_number_deactivated']) {
const [state, detail] = verdict(bank(status), null, null, NOW);
assert.equal(state, 'halted', status);
assert.ok(detail.includes(status));
}
});
test('errored says not to edit the existing object', () => {
const [, detail] = verdict(bank('errored'), null, null, NOW);
assert.match(detail, /does not clear this/);
assert.match(detail, /NEW external account/);
});
test('a balance behind a frozen destination is stranded', () => {
const [state, detail] = verdict(bank('errored'), NOW - 45 * 86400, 812340, NOW);
assert.equal(state, 'stranded');
assert.match(detail, /812340/);
assert.match(detail, /45 day\(s\) ago/);
});
test('evidence that was never gathered is not reported as no money', () => {
// A null available amount means nobody looked, which is not the same as zero.
assert.ok(!verdict(bank('errored'), null, null, NOW)[1]
.includes('no payout has ever been attempted'));
assert.ok(verdict(bank('errored'), null, 0, NOW)[1]
.includes('no payout has ever been attempted'));
});
test('a frozen non default destination is flagged as cleanup', () => {
const [, detail] = verdict(bank('verification_failed', false), null, null, NOW);
assert.match(detail, /not the default destination for usd/);
});
test('no bank account at all is its own answer', () => {
assert.equal(verdict(null, null, null, NOW)[0], 'no-destination');
});
test('an unrecognised status is not assumed healthy', () => {
assert.equal(verdict(bank('some_new_status'), null, null, NOW)[0], 'unknown');
});
FAQ
Why did the failed payouts stop if nothing was fixed?
Because Stripe stopped attempting them. The first failure sets the destination to errored and scheduled payouts to it are held until new details are attached, so the count of failed payouts goes flat. That flatness is the symptom, not the recovery, and it is why a monitor built only on failed payouts is blind to this state.
Can I clear errored by updating the account and routing numbers?
No. Editing the numbers on the existing bank account object leaves the status where it is. Attach a new external account with the correct details and set default_for_currency on it; the old object can then be detached. Teams that patch in place see no change and usually conclude something is broken on Stripe's side.
Does payouts_enabled go false when this happens?
Not necessarily. payouts_enabled describes the account's capability, and the capability is fine: it is the bank details that are not. An account-level health check can report a completely healthy account whose money has not moved in six weeks, which is exactly why the destination has to be read directly.
What is tokenized_account_number_deactivated?
It means the tokenized account number behind the destination was deactivated, so the destination can no longer be used even though the details look right. The fix is to re-link the bank so a fresh token is minted, rather than to re-type the same numbers. It halts payouts the same way errored does, which is why it belongs in the same check.
Does this need a live secret key?
No. A restricted key with read access to Connected accounts, Bank accounts, Payouts and Balance is enough. It lists destinations and reads a balance; attaching new bank details is printed as a call for a human to run, and the script has no permission to run it.
Related field notes
- Payouts fail with account_closed and nobody is watching
- A connected account has no external account to pay out to
- Payout schedule was left on manual and funds pile up
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 bank account object — Stripe API reference
- The Payout object — Stripe API reference
- Payouts to connected accounts — Stripe Docs
- The Account 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.