Diagnostic Stripe
payout.failed is unsubscribed so failures go unseen for days
Money stopped arriving in the bank account. Nothing alerted, nothing errored, and the balance in Stripe kept climbing. On a Connect platform it is worse: the sellers notice before the platform does, and they notice by not being paid.
Union the enabled_events arrays across every endpoint from GET /v1/webhook_endpoints and check whether payout.failed is in it. Treat a "*" subscription as covering everything.
Then establish whether it matters yet: GET /v1/payouts?limit=100 for any status of "failed", or GET /v1/events?types[]=payout.failed for a non-empty result. An unsubscribed event that has already fired is not a gap in coverage — it is an incident you have not been told about.
The problem in plain words
Payout failure is one of the few Stripe states that is genuinely invisible from the inside of your application. There is no failed request, no rejected charge, no customer complaint. Payments keep succeeding and the Stripe balance keeps growing, which reads as a good week rather than a stuck one. The signal is entirely negative: an amount that should have shown up in a bank account and did not.
The knock-on is what makes it urgent rather than annoying. When a payout fails, the external account it was going to is disabled, and no further payouts — automatic or manual — can be processed until it is updated. So one failure does not delay one transfer; it stops all of them, quietly, until someone goes and fixes the bank details.
Why it happens
People subscribe to the success and not the failure. payout.paid is the event you reach for when you are building reconciliation, because it is the one that carries the money you want to match against. payout.failed arrives separately and later, and it is easy to leave off a list that was written while thinking about the happy path.
Failure is rare enough to never have been exercised. Bank details are entered once and work for years. The subscription gap has no symptom until the day the account is closed, the sort code changes, or a bank rejects the transfer — which is precisely the day you need the alert you did not configure.
On Connect it fails on the wrong side of the boundary. Connected accounts' payout events reach a Connect-scoped destination, not the account-scoped one, so a platform can have payout.failed subscribed and still see nothing for its sellers. The companion signal there is account.external_account.updated, which tells you a seller has repaired their details.
The failure reason is in the event and nowhere convenient. failure_code distinguishes an account_closed that needs the seller to act from a could_not_process that may simply need a retry. Without the event you get neither the alert nor the reason.
The fix, as a flow
The script unions the subscribed event types across every endpoint, then asks whether any payout has already failed, because those two facts together separate a coverage gap from a live incident.
How to fix it
Union enabled_events across every endpoint
Coverage is a property of the account, not of any one endpoint. It is entirely normal for the payout events to live on a different endpoint from the payment ones. Treat a "*" subscription as covering everything — it does, though it brings its own problems.
Check whether payouts have already failed
GET /v1/payouts?limit=100 and look for status of "failed". This is the difference between a gap to close this quarter and an incident that is live right now with an external account disabled behind it.
Read the failure code before assuming it is the bank
failure_code and failure_balance_transaction are on the payout object. account_closed, invalid_account_number and debit_not_authorized need different people to do different things, and only one of them is fixed by trying again.
Subscribe to the failure alongside the success
POST /v1/webhook_endpoints/{id} with enabled_events[]=payout.failed and enabled_events[]=payout.paid. Keeping them together is the point: a reconciliation process that only ever hears about successes cannot tell a quiet week from a broken one.
On Connect, add the connected-account destination too
A Connect-scoped endpoint with payout.failed and account.external_account.updated. Without it the platform is blind to exactly the failures its sellers will call about.
How to check it worked
Re-run the script. The union should contain payout.failed and the state should be covered.
python3 stripe_payout_events.py
# covered payout.failed is subscribed on at least one endpoint
The full code
Three GETs and no writes — endpoints, payouts, and optionally the events themselves. A restricted key with read access to Webhook Endpoints, Payouts and Events covers it. The classifier takes the subscription union and the count of failures already seen, because those two facts together are what separate a gap in coverage from an outage in progress.
"""Report whether payout.failed is subscribed, and whether payouts already failed.
Read only. GETs only, no writes: give this a RESTRICTED key with read access to
Webhook Endpoints, Payouts and Events. 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 requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("stripe_payout_events")
API = "https://api.stripe.com/v1"
TARGET = "payout.failed"
COMPANION = "payout.paid"
def verdict(subscribed, failed_payouts):
"""Classify payout-failure coverage. Pure, so the rules can be tested.
`subscribed` is the union of enabled_events across every endpoint;
`failed_payouts` is how many payouts are already in status failed.
Returns (state, detail).
"""
events = set(subscribed or [])
if "*" in events:
return ("wildcard",
"a wildcard subscription covers %s, but it also delivers every "
"other event type to the same handler." % TARGET)
if TARGET in events:
if COMPANION not in events:
return ("partial",
"%s is subscribed but %s is not. Reconciliation cannot tell a "
"quiet week from a broken one." % (TARGET, COMPANION))
return ("covered", "%s is subscribed on at least one endpoint" % TARGET)
if failed_payouts:
return ("blind",
"%d payout(s) already failed and nothing subscribes to %s. The "
"external account is disabled until the details are updated."
% (failed_payouts, TARGET))
return ("unsubscribed",
"nothing subscribes to %s. No failures in the window yet, so this is "
"a gap rather than an incident." % TARGET)
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")
if r.status_code == 403:
raise SystemExit("403 from Stripe: the restricted key lacks read access to "
+ path)
r.raise_for_status()
return r.json()
def subscribed_events(endpoints):
"""Union of enabled_events across endpoints. Pure, given the endpoint list."""
union = set()
for ep in endpoints:
union.update(ep.get("enabled_events") or [])
return union
def failed_payouts(session, limit):
"""Count payouts currently in status failed, and collect their failure codes."""
codes = {}
count = 0
params = {"limit": 100, "status": "failed"}
while True:
page = get(session, "/payouts", **params)
data = page.get("data", [])
for p in data:
count += 1
code = p.get("failure_code") or "unknown"
codes[code] = codes.get(code, 0) + 1
if not data or not page.get("has_more") or count >= limit:
break
params["starting_after"] = data[-1]["id"]
return count, codes
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--max-payouts", type=int, default=500,
help="stop counting failed payouts after this many")
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})
endpoints = get(s, "/webhook_endpoints", limit=100).get("data", [])
union = subscribed_events(endpoints)
count, codes = failed_payouts(s, args.max_payouts)
state, detail = verdict(union, count)
line = "%-13s %s" % (state, detail)
if state == "covered":
log.info(line)
return 0
log.warning(line)
if codes:
log.warning(" failure codes seen: %s",
", ".join("%s x%d" % (c, n) for c, n in sorted(codes.items())))
if state in ("blind", "unsubscribed", "partial"):
target = endpoints[0]["id"] if endpoints else "<we_id>"
log.warning(" repair: POST %s/webhook_endpoints/%s "
"-d enabled_events[]=%s -d enabled_events[]=%s",
API, target, TARGET, COMPANION)
log.warning(" on Connect, add a connected-accounts destination carrying "
"%s and account.external_account.updated", TARGET)
return 1
if __name__ == "__main__":
sys.exit(main())
/**
* Report whether payout.failed is subscribed, and whether payouts already failed.
*
* Read only. GETs only, no writes: give this a RESTRICTED key with read access to
* Webhook Endpoints, Payouts and Events. The repair is printed, never performed.
*/
const API = 'https://api.stripe.com/v1';
const TARGET = 'payout.failed';
const COMPANION = 'payout.paid';
/**
* Classify payout-failure coverage. Pure, so the rules can be tested.
* `subscribed` is the union of enabled_events across every endpoint.
*/
export function verdict(subscribed, failedPayouts) {
const events = new Set(subscribed ?? []);
if (events.has('*')) {
return ['wildcard',
`a wildcard subscription covers ${TARGET}, but it also delivers every ` +
'other event type to the same handler.'];
}
if (events.has(TARGET)) {
if (!events.has(COMPANION)) {
return ['partial',
`${TARGET} is subscribed but ${COMPANION} is not. Reconciliation cannot ` +
'tell a quiet week from a broken one.'];
}
return ['covered', `${TARGET} is subscribed on at least one endpoint`];
}
if (failedPayouts) {
return ['blind',
`${failedPayouts} payout(s) already failed and nothing subscribes to ` +
`${TARGET}. The external account is disabled until the details are updated.`];
}
return ['unsubscribed',
`nothing subscribes to ${TARGET}. No failures in the window yet, so this is ` +
'a gap rather than an incident.'];
}
export function subscribedEvents(endpoints) {
const union = new Set();
for (const ep of endpoints ?? []) {
for (const e of ep.enabled_events ?? []) union.add(e);
}
return union;
}
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.status === 403) {
throw new Error(`403 from Stripe: the restricted key lacks read access to ${path}`);
}
if (!res.ok) throw new Error(`${res.status} from ${url.pathname}`);
return res.json();
}
export async function failedPayouts(key, limit = 500) {
const codes = new Map();
let count = 0;
const params = { limit: 100, status: 'failed' };
for (;;) {
const page = await get(key, '/payouts', params);
const data = page.data ?? [];
for (const p of data) {
count += 1;
const code = p.failure_code ?? 'unknown';
codes.set(code, (codes.get(code) ?? 0) + 1);
}
if (data.length === 0 || !page.has_more || count >= limit) break;
params.starting_after = data[data.length - 1].id;
}
return { count, codes };
}
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 { data: endpoints = [] } = await get(key, '/webhook_endpoints', { limit: 100 });
const union = subscribedEvents(endpoints);
const { count, codes } = await failedPayouts(key);
const [state, detail] = verdict(union, count);
const line = `${state.padEnd(13)} ${detail}`;
if (state === 'covered') { console.log(line); return; }
console.warn(line);
if (codes.size > 0) {
const seen = [...codes.entries()].sort().map(([c, n]) => `${c} x${n}`).join(', ');
console.warn(` failure codes seen: ${seen}`);
}
const target = endpoints.length > 0 ? endpoints[0].id : '<we_id>';
console.warn(` repair: POST ${API}/webhook_endpoints/${target} ` +
`-d enabled_events[]=${TARGET} -d enabled_events[]=${COMPANION}`);
console.warn(' on Connect, add a connected-accounts destination carrying ' +
`${TARGET} and account.external_account.updated`);
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
Two distinctions are worth pinning down. A missing subscription with failures already recorded is not the same finding as a missing subscription on an account that has never had one — the first is an incident, the second is a task. And a wildcard technically covers payout.failed, so the check must not report it as broken while still saying what it is.
from stripe_payout_events import verdict
BOTH = ["payout.paid", "payout.failed", "payment_intent.succeeded"]
def test_both_payout_events_subscribed_is_covered():
state, _ = verdict(BOTH, 0)
assert state == "covered"
def test_missing_subscription_with_failures_is_an_incident():
state, detail = verdict(["payout.paid"], 3)
assert state == "blind"
assert "3 payout(s)" in detail
def test_missing_subscription_with_no_failures_is_only_a_gap():
# Same configuration, different urgency. Collapsing these two loses the
# distinction between a ticket and a page.
state, _ = verdict(["payout.paid"], 0)
assert state == "unsubscribed"
def test_failure_without_the_success_is_flagged_as_partial():
state, _ = verdict(["payout.failed"], 0)
assert state == "partial"
def test_a_wildcard_covers_it_but_is_named_as_such():
state, detail = verdict(["*"], 0)
assert state == "wildcard"
assert "every" in detail
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict } from './stripe-payout-events.mjs';
const BOTH = ['payout.paid', 'payout.failed', 'payment_intent.succeeded'];
test('both payout events subscribed is covered', () => {
assert.equal(verdict(BOTH, 0)[0], 'covered');
});
test('missing subscription with failures is an incident', () => {
const [state, detail] = verdict(['payout.paid'], 3);
assert.equal(state, 'blind');
assert.match(detail, /3 payout\(s\)/);
});
test('missing subscription with no failures is only a gap', () => {
// Same configuration, different urgency.
assert.equal(verdict(['payout.paid'], 0)[0], 'unsubscribed');
});
test('failure without the success is flagged as partial', () => {
assert.equal(verdict(['payout.failed'], 0)[0], 'partial');
});
test('a wildcard covers it but is named as such', () => {
const [state, detail] = verdict(['*'], 0);
assert.equal(state, 'wildcard');
assert.match(detail, /every/);
});
FAQ
What happens when a Stripe payout fails?
The payout moves to status failed and the external account it was going to is disabled. No further payouts, automatic or manual, can be processed to that account until the details are updated. One failure therefore stops the whole payout schedule rather than delaying a single transfer.
Why is payout.paid usually subscribed and payout.failed not?
Because payout.paid is the event people reach for when building reconciliation: it carries the money to match against. The failure event arrives separately and later, and gets left off a list written while thinking about the happy path.
I subscribe to payout.failed but see nothing for my connected accounts.
Connected accounts' events only reach a Connect-scoped destination. An account-scoped endpoint never sees them whatever its enabled_events says. Add a connected-accounts destination carrying payout.failed and account.external_account.updated.
Which failure codes need action from me rather than a retry?
account_closed, invalid_account_number and debit_not_authorized all need someone to change the bank details or the authorisation; retrying is pointless. Codes like could_not_process may clear on their own. Read failure_code on the payout object rather than assuming.
Can I check this with a read-only key?
Yes. Read access to Webhook Endpoints gives you the subscription union and read access to Payouts confirms whether anything has already failed. Neither can move money.
Related field notes
- An endpoint subscribes to every event and floods the handler
- A webhook endpoint sits disabled after days of retries
- Match payouts to orders
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.
- Connect webhooks — Stripe Docs
- The payout object — Stripe API reference
- Create a webhook endpoint — Stripe API reference
- Receive Stripe events in your webhook endpoint — Stripe Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.