Diagnostic Stripe
a connected account sits with charges_enabled false
A seller emails support to say their checkout has been broken for two weeks. Nobody on the platform side saw anything: no alert, no failed job, no error in the logs. The platform's own Stripe account is healthy, payments are flowing, the graphs are flat and normal. The account that stopped working is one of four hundred, and the only field that would have told you is one nobody was reading.
Paginate GET /v1/accounts?limit=100 and flag every account where charges_enabled is false. Then read requirements.disabled_reason on the same object, because that single string decides whether this is your problem or Stripe's.
requirements.past_due and requirements.pending_verification mean fields are outstanding and an onboarding link fixes it. Anything in the rejected.* family, plus listed and under_review, cannot be cleared through the API at all — those are resolved from the Dashboard's Connected accounts page or not at all.
The problem in plain words
The platform never sees this. That is the whole difficulty. Your application talks to your own Stripe account, your own account is fine, and a connected account that has had its card payments switched off produces no signal on your side unless you go and ask about it. There is no failing request in your logs because your code is not making requests for that seller; the seller's customers are, and they are seeing an error page on a checkout you do not own.
By the time it reaches you it arrives as a support ticket with two weeks of lost revenue attached, and the seller reasonably believes the platform broke their store. You then have to work out which of several hundred accounts are in the same state, which is when it becomes clear that nobody has ever run that query.
Why it happens
Nothing pushes the state change to you. charges_enabled flips the moment a capability the account depends on goes inactive, and the only notification is the account.updated event. If the platform has no endpoint scoped to connected accounts — which is the default, since a normal endpoint only receives the platform's own events — that transition happens in silence.
The account looks finished. details_submitted is true. The seller completed onboarding months ago and has been taking payments ever since. Nothing about the object says "unfinished", so a check written around onboarding completion passes cleanly while the account is dead.
The reason matters more than the flag. Two accounts can both read charges_enabled: false and need completely different work: one needs an email with an onboarding link, the other needs a human to open the Dashboard because Stripe has rejected it and the API has no way to argue. A monitor that reports only the boolean generates a list that cannot be acted on without opening every account by hand.
Capabilities and the top-level flag are not the same thing. charges_enabled is a summary. capabilities.card_payments is the specific thing that broke, and Stripe couples card_payments and transfers so that either one being inactive disables both. Chasing the summary flag without reading the capability leaves you fixing requirements that belong to a capability you were not looking at.
The fix, as a flow
The script reads every connected account and sorts it by who can fix it, because an account waiting on a form and an account Stripe has rejected look identical through the charges_enabled flag alone.
How to fix it
List every connected account and read three fields, not one
charges_enabled, details_submitted and requirements.disabled_reason. The first says something is wrong, the second says whether onboarding ever finished, the third says who can fix it. Paginate with starting_after; a platform with four hundred sellers does not fit in one page and the broken ones are not usefully clustered.
Separate never-started from stopped-working
details_submitted: false with charges_enabled: false is an account that never opened for business. It is a sales problem, not an incident. Mixing those into the same alert as accounts that were live yesterday is how the alert gets ignored, because the never-started ones are always the majority.
Split the disabled reasons by who can act
requirements.past_due, requirements.pending_verification and action_required.requested_capabilities are yours: collect fields, send a link, wait. rejected.fraud, rejected.listed, rejected.terms_of_service, rejected.other, listed and under_review are not. Sending an onboarding link to a rejected account produces a completed form and no change in status.
Read the specific capability before collecting anything
GET /v1/accounts/{id}/capabilities gives the per-capability requirement sets. Union requirements.currently_due across all of them rather than the one you happen to use, because of the card_payments/transfers coupling: satisfying one capability's list while the other stays inactive leaves both disabled and looks like the fix did not work.
Print the repair, then subscribe so it never gets this far again
The repair is an account link for the fixable cases and a Dashboard visit for the rest. The permanent fix is an endpoint with connect: true subscribed to account.updated, so the next flip arrives as an event on the day it happens rather than as a ticket a fortnight later.
How to check it worked
Re-run the script. Every live seller should classify as live, and anything left should be an account that has genuinely never onboarded.
python3 stripe_connect_charges_disabled.py
# 412 account(s): 0 blocked, 0 rejected, 6 never onboarded
The full code
One paginated GET against /v1/accounts and nothing else — a restricted key with read access to Connected accounts is enough, and is what you should give it. The classification is a pure function of one account object, because the whole value of this check is the distinction between an account you can fix with an email and one that needs a human in the Dashboard, and that distinction is a list of strings that is easy to get subtly wrong.
"""Report connected accounts that cannot take payments, and say who can fix each.
Read only. One paginated GET and no writes: give this a RESTRICTED key with read
access to Connected accounts. 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_connect_charges_disabled")
API = "https://api.stripe.com/v1"
# Reasons the API cannot clear. An onboarding link sent to one of these produces a
# completed form and no change in status, which reads as a broken link to the
# seller and as a fixed account to whoever sent it.
DASHBOARD_ONLY = ("listed", "under_review", "rejected")
# Stripe is holding the account while it checks something. There is no field to
# collect and nothing for anyone to do.
WAITING = ("requirements.pending_verification",)
def classify(account):
"""Sort one connected account. Pure, so the reason table can be tested.
Takes an /v1/accounts object. Returns (state, detail). The states exist to
split the work by who can do it: `blocked` is an email, `rejected` is a human
in the Dashboard, `waiting` is nobody.
"""
reqs = account.get("requirements") or {}
reason = reqs.get("disabled_reason")
due = [f for f in (reqs.get("currently_due") or []) if f]
if account.get("charges_enabled"):
return ("live", "charges_enabled, nothing to chase")
if not account.get("details_submitted"):
return ("never-onboarded",
"details_submitted is false: this account never opened, so it has "
"not broken. Do not page anyone about it.")
if reason and (reason in DASHBOARD_ONLY or reason.split(".", 1)[0] == "rejected"):
return ("rejected",
"disabled_reason %s: the API cannot clear this. It is resolved from "
"the Dashboard Connected accounts page, or not at all." % reason)
if reason in WAITING:
return ("waiting",
"disabled_reason %s: Stripe is verifying what it already has. "
"Collecting more fields does not speed it up." % reason)
if due:
return ("blocked",
"%s, %d field(s) currently due: %s"
% (reason or "no disabled_reason", len(due), ", ".join(due[:4])))
if reason:
return ("blocked",
"%s with nothing in currently_due: read the per-capability "
"requirements before collecting anything." % reason)
return ("unknown",
"charges_enabled is false with no disabled_reason and no currently_due")
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 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 main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--max-accounts", type=int, default=5000,
help="stop paginating after this many accounts")
ap.add_argument("--quiet-never-onboarded", action="store_true",
help="do not list accounts that never finished onboarding")
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})
counts = {}
scanned = 0
for acct in accounts(s, args.max_accounts):
scanned += 1
state, detail = classify(acct)
counts[state] = counts.get(state, 0) + 1
if state == "live":
continue
if state == "never-onboarded" and args.quiet_never_onboarded:
continue
log.warning("%s %-16s %s", acct.get("id", "acct_?"), state, detail)
blocked = counts.get("blocked", 0)
rejected = counts.get("rejected", 0)
unknown = counts.get("unknown", 0)
log.info("%d account(s): %d blocked, %d rejected, %d never onboarded",
scanned, blocked, rejected, counts.get("never-onboarded", 0))
if blocked:
log.warning(" repair: read the union of currently_due across every "
"capability first:")
log.warning(" GET %s/accounts/{id}/capabilities", API)
log.warning(" repair: create an account link for the seller, "
"type=account_onboarding, collection_options[fields]=currently_due")
if rejected:
log.warning(" repair: Dashboard, Connected accounts, open the account. "
"No API call clears a rejected.* or under_review reason.")
if blocked or rejected or unknown:
log.warning(" check: an endpoint with connect=true subscribed to "
"account.updated turns this into an event instead of a ticket")
return 1 if (blocked or rejected or unknown) else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Report connected accounts that cannot take payments, and say who can fix each.
*
* Read only. One paginated GET and no writes: give this a RESTRICTED key with
* read access to Connected accounts. The repair is printed, never performed.
*/
const API = 'https://api.stripe.com/v1';
// Reasons the API cannot clear. An onboarding link sent to one of these produces
// a completed form and no change in status.
const DASHBOARD_ONLY = ['listed', 'under_review', 'rejected'];
// Stripe is holding the account while it checks something. Nothing to collect.
const WAITING = ['requirements.pending_verification'];
/**
* Sort one connected account. Pure, so the reason table can be tested.
* Returns [state, detail]. The states split the work by who can do it.
*/
export function classify(account) {
const reqs = account.requirements ?? {};
const reason = reqs.disabled_reason ?? null;
const due = (reqs.currently_due ?? []).filter(Boolean);
if (account.charges_enabled) return ['live', 'charges_enabled, nothing to chase'];
if (!account.details_submitted) {
return ['never-onboarded',
'details_submitted is false: this account never opened, so it has not ' +
'broken. Do not page anyone about it.'];
}
if (reason && (DASHBOARD_ONLY.includes(reason) || reason.split('.')[0] === 'rejected')) {
return ['rejected',
`disabled_reason ${reason}: the API cannot clear this. It is resolved from ` +
'the Dashboard Connected accounts page, or not at all.'];
}
if (reason && WAITING.includes(reason)) {
return ['waiting',
`disabled_reason ${reason}: Stripe is verifying what it already has. ` +
'Collecting more fields does not speed it up.'];
}
if (due.length) {
return ['blocked',
`${reason ?? 'no disabled_reason'}, ${due.length} field(s) currently due: ` +
due.slice(0, 4).join(', ')];
}
if (reason) {
return ['blocked',
`${reason} with nothing in currently_due: read the per-capability ` +
'requirements before collecting anything.'];
}
return ['unknown',
'charges_enabled is false with no disabled_reason and no currently_due'];
}
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* 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 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 counts = new Map();
let scanned = 0;
for await (const acct of accounts(key)) {
scanned += 1;
const [state, detail] = classify(acct);
counts.set(state, (counts.get(state) ?? 0) + 1);
if (state === 'live') continue;
console.warn(`${acct.id ?? 'acct_?'} ${state.padEnd(16)} ${detail}`);
}
const blocked = counts.get('blocked') ?? 0;
const rejected = counts.get('rejected') ?? 0;
const unknown = counts.get('unknown') ?? 0;
console.log(`${scanned} account(s): ${blocked} blocked, ${rejected} rejected, ` +
`${counts.get('never-onboarded') ?? 0} never onboarded`);
if (blocked) {
console.warn(' repair: read the union of currently_due across every capability first:');
console.warn(` GET ${API}/accounts/{id}/capabilities`);
console.warn(' repair: create an account link for the seller, ' +
'type=account_onboarding, collection_options[fields]=currently_due');
}
if (rejected) {
console.warn(' repair: Dashboard, Connected accounts, open the account. ' +
'No API call clears a rejected.* or under_review reason.');
}
if (blocked || rejected || unknown) {
console.warn(' check: an endpoint with connect=true subscribed to ' +
'account.updated turns this into an event instead of a ticket');
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 are about the reason table, because that is the part with real consequences. Classifying a rejected account as fixable sends a seller an onboarding link that cannot work, and classifying an account that never onboarded as an incident is how a monitor with four hundred accounts behind it gets muted in its first week.
from stripe_connect_charges_disabled import classify
def test_enabled_account_is_live():
state, _ = classify({"charges_enabled": True, "details_submitted": True})
assert state == "live"
def test_never_onboarded_is_not_an_incident():
state, detail = classify({"charges_enabled": False, "details_submitted": False})
assert state == "never-onboarded"
assert "never opened" in detail
def test_every_rejected_reason_is_dashboard_only():
# rejected.* is an open family; matching the prefix rather than a fixed list
# is the difference between a correct answer and one that ages badly.
for reason in ("rejected.fraud", "rejected.listed", "rejected.terms_of_service",
"rejected.other", "listed", "under_review"):
state, detail = classify({
"charges_enabled": False, "details_submitted": True,
"requirements": {"disabled_reason": reason,
"currently_due": ["company.tax_id"]},
})
assert state == "rejected", reason
assert "cannot clear" in detail
def test_past_due_is_blocked_and_names_the_fields():
state, detail = classify({
"charges_enabled": False, "details_submitted": True,
"requirements": {"disabled_reason": "requirements.past_due",
"currently_due": ["company.tax_id", "business_profile.url"]},
})
assert state == "blocked"
assert "company.tax_id" in detail
def test_pending_verification_asks_nobody_for_anything():
state, detail = classify({
"charges_enabled": False, "details_submitted": True,
"requirements": {"disabled_reason": "requirements.pending_verification",
"currently_due": []},
})
assert state == "waiting"
assert "does not speed it up" in detail
def test_disabled_with_no_explanation_is_not_reported_as_healthy():
state, _ = classify({"charges_enabled": False, "details_submitted": True,
"requirements": {}})
assert state == "unknown"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { classify } from './stripe-connect-charges-disabled.mjs';
test('enabled account is live', () => {
assert.equal(classify({ charges_enabled: true, details_submitted: true })[0], 'live');
});
test('never onboarded is not an incident', () => {
const [state, detail] = classify({ charges_enabled: false, details_submitted: false });
assert.equal(state, 'never-onboarded');
assert.match(detail, /never opened/);
});
test('every rejected reason is dashboard only', () => {
// rejected.* is an open family; matching the prefix rather than a fixed list is
// the difference between a correct answer and one that ages badly.
for (const reason of ['rejected.fraud', 'rejected.listed', 'rejected.terms_of_service',
'rejected.other', 'listed', 'under_review']) {
const [state, detail] = classify({
charges_enabled: false,
details_submitted: true,
requirements: { disabled_reason: reason, currently_due: ['company.tax_id'] },
});
assert.equal(state, 'rejected', reason);
assert.match(detail, /cannot clear/);
}
});
test('past due is blocked and names the fields', () => {
const [state, detail] = classify({
charges_enabled: false,
details_submitted: true,
requirements: {
disabled_reason: 'requirements.past_due',
currently_due: ['company.tax_id', 'business_profile.url'],
},
});
assert.equal(state, 'blocked');
assert.match(detail, /company\.tax_id/);
});
test('pending verification asks nobody for anything', () => {
const [state, detail] = classify({
charges_enabled: false,
details_submitted: true,
requirements: {
disabled_reason: 'requirements.pending_verification',
currently_due: [],
},
});
assert.equal(state, 'waiting');
assert.match(detail, /does not speed it up/);
});
test('disabled with no explanation is not reported as healthy', () => {
assert.equal(
classify({ charges_enabled: false, details_submitted: true, requirements: {} })[0],
'unknown');
});
FAQ
What actually sets charges_enabled to false?
A capability the account depends on going inactive. That happens when verification fields go unmet past their deadline, when Stripe opens a risk review, when Stripe rejects the account, or when the platform itself pauses it. The flag is a summary of capability state, not an independent switch, which is why the specific capability and its requirements are where the repair lives.
Why did nobody get an alert?
Because a plain webhook endpoint only receives events for your own account. Events about connected accounts need an endpoint created with connect set to true, subscribed to account.updated. Platforms that never created one see nothing when a seller's account changes state, and there is no error to indicate the events were missed.
Can I fix a rejected account through the API?
No. Every reason in the rejected family, plus listed and under_review, is resolved from the Dashboard's Connected accounts page or through Stripe support. Updating the account object or sending a fresh onboarding link changes nothing, and the seller will complete the form and come back asking why it did not work.
Should I check capabilities or charges_enabled?
Both, in that order of authority. charges_enabled tells you an account is broken; capabilities.card_payments and its requirements tell you what to collect. Stripe couples card_payments and transfers so that either being inactive disables both, so union the currently_due lists across all capabilities rather than trusting the one you use.
Does this need a live secret key?
No. A restricted key with read access to Connected accounts is enough, and it is what this script should be given. It reads a list of accounts and prints a classification; it cannot onboard, update, or reject anything.
Related field notes
- requirements.past_due has already disabled the payouts
- A connected account has no external account to pay out to
- payout.failed is unsubscribed so broken bank details go unseen
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 Account object — Stripe API reference
- Handling verification with the API — Stripe Docs
- Account capabilities — Stripe Docs
- List all connected accounts — 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.