Diagnostic Stripe
the transfers capability is inactive so every transfer 400s
One seller's payouts never arrive. Their checkout works, the charges land on the platform, the account object says charges_enabled: true, and the seller's own balance stays at zero. Every attempt to move the money returns a 400 that names a capability nobody on the team has ever read a value from.
Paginate GET /v1/accounts?limit=100 and read capabilities.transfers. It has four possible states and only one of them lets funds move: active. pending means Stripe is verifying, inactive means a requirement is unmet, and a missing key means the capability was never requested at all, which is the case no boolean on the account will ever show you.
The list response gives you the status and nothing else. For the reason, read the capability itself: GET /v1/accounts/{id}/capabilities/transfers returns requirements.currently_due, requirements.pending_verification and requirements.disabled_reason scoped to that one capability rather than to the account.
The problem in plain words
The confusing part is that the account looks healthy. charges_enabled is true, so a monitor built around it stays green. Payments succeed, because taking a payment on the platform and moving funds to a connected account are two separate permissions and only the first one is working. The seller sees orders arrive and no money follow.
The error itself is not much help either. It comes back on the call that moves funds, at the moment somebody tries, which is usually a background job rather than a request anybody is watching. So the failure surfaces as a stuck queue or a retry loop somewhere in your own infrastructure, several steps removed from the account that caused it.
Why it happens
A capability is not the same permission as the account boolean. charges_enabled tells you the account can accept payments. Moving funds to that account requires the transfers capability to be active, verified against its own requirement set. The two can and do disagree, and a check that reads only the account object cannot see the disagreement.
The unrequested case has no field of its own. When a capability was never requested, Stripe does not report it as inactive — the key is simply absent from the capabilities hash. Code written as if account.capabilities.transfers != 'active' catches it; code written as if status == 'inactive' does not, and this is the state that lasts for years because nothing anywhere ever complains about it.
Capabilities can go back down. An account that has been transferring for months drops to inactive when new requirements go unmet past their deadline. It is not a one-time onboarding gate you clear and forget; it is a live status that needs the same daily read as any other.
The requirements you need are not the ones on the account. Stripe documents an explicit coupling between card_payments and transfers: where an account has both, one of them sitting at inactive disables the pair. That is why a team can satisfy every field the account object lists and watch transfers stay stubbornly inactive. Read GET /v1/accounts/{id}/capabilities and take the union.
The fix, as a flow
The script reads the capability status off the account list, then fetches the capability itself for anything that is not active, because the status says what is wrong and only the capability object says why.
How to fix it
Read capabilities.transfers, not charges_enabled
Compare against the string active rather than testing for inactive. The four outcomes are active, pending, inactive and absent, and absent is a real state with a different repair from all the others.
Fetch the capability object for anything that is not active
GET /v1/accounts/{id}/capabilities/transfers is one extra GET per unhealthy account and it is the only place the reason lives. requirements.currently_due lists the fields for this capability; requirements.disabled_reason says whether a field is even the problem.
Split pending from inactive before telling anyone to do something
pending means Stripe is verifying what it already has. There is nothing to collect and no link to send, and sending one anyway produces a completed form with no status change, which reads to the seller as a broken product.
Take the union across every capability, not just this one
Because card_payments and transfers disable each other, the field blocking transfers may be listed under a capability you never use. GET /v1/accounts/{id}/capabilities returns all of them; collect the union of their currently_due in one pass rather than fixing one capability at a time.
Request the capability for accounts that never had it
An absent transfers key is not a verification problem, so no amount of onboarding will fix it. The capability has to be requested for the account first, and only then does Stripe start asking for whatever it needs to make it active.
How to check it worked
Re-run the script. Every account you expect to pay out should report active, and the summary line should show no blocked or unrequested accounts left.
python3 stripe_transfers_capability.py
# 412 account(s): 412 active, 0 blocked, 0 unrequested, 0 held
The full code
One paginated GET over the accounts, then one small GET per account that is not already active — a restricted key with read access to Connected accounts covers both. The classifier is pure and takes the capability object, so the difference between never requested, being verified and waiting on a field is decided by rules you can read rather than inside a request loop.
"""Report connected accounts whose transfers capability is not active.
Read only. Paginated GETs 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_transfers_capability")
API = "https://api.stripe.com/v1"
# Reasons no amount of field collection will clear. An onboarding link sent to an
# account in one of these states produces a completed form and no status change.
DASHBOARD_ONLY = ("listed", "under_review", "rejected")
def classify(capability):
"""Sort the transfers capability of one account. Pure, so the states can be
tested without a network.
`capability` is the object from GET /v1/accounts/{id}/capabilities/transfers,
or None when the account's `capabilities` hash has no `transfers` key at all.
That absence is its own state: the capability was never requested, so no
requirement is outstanding and none ever will be.
Returns (state, detail).
"""
if capability is None:
return ("unrequested",
"no transfers capability on the account: it was never requested, "
"so Stripe is not asking for anything and funds will never move")
status = capability.get("status")
reqs = capability.get("requirements") or {}
due = [f for f in (reqs.get("currently_due") or []) if f]
verifying = [f for f in (reqs.get("pending_verification") or []) if f]
reason = reqs.get("disabled_reason")
if status == "active":
return ("active", "transfers are active")
if status == "unrequested":
return ("unrequested",
"status unrequested: request the capability before collecting "
"anything, because nothing is outstanding yet")
if status == "pending":
return ("verifying",
"status pending: Stripe is checking what it already has%s. "
"Collecting more fields does not speed it up"
% (", %d field(s) in pending_verification" % len(verifying)
if verifying else ""))
if status == "inactive":
if due:
return ("blocked",
"status inactive, %d field(s) currently due on this "
"capability: %s" % (len(due), ", ".join(due[:4])))
if verifying:
return ("verifying",
"status inactive with %d field(s) in pending_verification: "
"submitted and being checked, nothing to collect"
% len(verifying))
if reason and (reason in DASHBOARD_ONLY
or reason.split(".", 1)[0] == "rejected"):
return ("held",
"status inactive, disabled_reason %s: no API call clears "
"this one" % reason)
if reason:
return ("blocked",
"status inactive, disabled_reason %s with nothing currently "
"due: read every capability before collecting" % reason)
return ("unknown",
"status inactive with no currently_due and no disabled_reason")
return ("unknown", "unrecognised capability status: %s" % 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 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-unrequested", action="store_true",
help="do not list accounts that never requested the capability")
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
caps = acct.get("capabilities") or {}
# The list response carries the status and nothing else. Only fetch the
# capability object where the status is not already active, because that
# object is the only place the reason lives.
capability = None
if caps.get("transfers") == "active":
counts["active"] = counts.get("active", 0) + 1
continue
if "transfers" in caps:
capability = get(s, "/accounts/%s/capabilities/transfers"
% acct.get("id", ""))
state, detail = classify(capability)
counts[state] = counts.get(state, 0) + 1
if state == "unrequested" and args.quiet_unrequested:
continue
log.warning("%s %-12s %s", acct.get("id", "acct_?"), state, detail)
blocked = counts.get("blocked", 0)
held = counts.get("held", 0)
unrequested = counts.get("unrequested", 0)
unknown = counts.get("unknown", 0)
log.info("%d account(s): %d active, %d blocked, %d unrequested, %d held",
scanned, counts.get("active", 0), blocked, unrequested, held)
if unrequested:
log.warning(" repair: request the capability first, then onboard for "
"whatever it asks for once Stripe starts asking:")
log.warning(" POST %s/accounts/{id}/capabilities/transfers requested=true",
API)
if blocked:
log.warning(" repair: read every capability and collect the union of "
"currently_due, since card_payments and transfers disable "
"each other:")
log.warning(" GET %s/accounts/{id}/capabilities", API)
log.warning(" then POST %s/accounts/{id} with those fields, or an "
"account link with type=account_onboarding", API)
if held:
log.warning(" repair: Dashboard, Connected accounts. No field collection "
"clears a rejected.* or under_review reason.")
return 1 if (blocked or held or unrequested or unknown) else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Report connected accounts whose transfers capability is not active.
*
* Read only. Paginated GETs 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 no amount of field collection will clear.
const DASHBOARD_ONLY = ['listed', 'under_review', 'rejected'];
/**
* Sort the transfers capability of one account. Pure, so the states can be
* tested without a network. `capability` is null when the account's capabilities
* hash has no transfers key at all, which means it was never requested.
* Returns [state, detail].
*/
export function classify(capability) {
if (capability === null || capability === undefined) {
return ['unrequested',
'no transfers capability on the account: it was never requested, so ' +
'Stripe is not asking for anything and funds will never move'];
}
const status = capability.status;
const reqs = capability.requirements ?? {};
const due = (reqs.currently_due ?? []).filter(Boolean);
const verifying = (reqs.pending_verification ?? []).filter(Boolean);
const reason = reqs.disabled_reason ?? null;
if (status === 'active') return ['active', 'transfers are active'];
if (status === 'unrequested') {
return ['unrequested',
'status unrequested: request the capability before collecting anything, ' +
'because nothing is outstanding yet'];
}
if (status === 'pending') {
const extra = verifying.length
? `, ${verifying.length} field(s) in pending_verification` : '';
return ['verifying',
`status pending: Stripe is checking what it already has${extra}. ` +
'Collecting more fields does not speed it up'];
}
if (status === 'inactive') {
if (due.length) {
return ['blocked',
`status inactive, ${due.length} field(s) currently due on this ` +
`capability: ${due.slice(0, 4).join(', ')}`];
}
if (verifying.length) {
return ['verifying',
`status inactive with ${verifying.length} field(s) in ` +
'pending_verification: submitted and being checked, nothing to collect'];
}
if (reason && (DASHBOARD_ONLY.includes(reason) || reason.split('.')[0] === 'rejected')) {
return ['held',
`status inactive, disabled_reason ${reason}: no API call clears this one`];
}
if (reason) {
return ['blocked',
`status inactive, disabled_reason ${reason} with nothing currently due: ` +
'read every capability before collecting'];
}
return ['unknown', 'status inactive with no currently_due and no disabled_reason'];
}
return ['unknown', `unrecognised capability status: ${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* 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 = {};
let scanned = 0;
for await (const acct of accounts(key)) {
scanned += 1;
const caps = acct.capabilities ?? {};
if (caps.transfers === 'active') {
counts.active = (counts.active ?? 0) + 1;
continue;
}
// Only fetch the capability object where the status is not already active:
// it is the only place the reason lives.
let capability = null;
if ('transfers' in caps) {
capability = await get(key, `/accounts/${acct.id}/capabilities/transfers`);
}
const [state, detail] = classify(capability);
counts[state] = (counts[state] ?? 0) + 1;
console.warn(`${acct.id ?? 'acct_?'} ${state.padEnd(12)} ${detail}`);
}
const blocked = counts.blocked ?? 0;
const held = counts.held ?? 0;
const unrequested = counts.unrequested ?? 0;
const unknown = counts.unknown ?? 0;
console.log(`${scanned} account(s): ${counts.active ?? 0} active, ${blocked} ` +
`blocked, ${unrequested} unrequested, ${held} held`);
if (unrequested) {
console.warn(' repair: request the capability first, then onboard for ' +
'whatever it asks for once Stripe starts asking:');
console.warn(` POST ${API}/accounts/{id}/capabilities/transfers requested=true`);
}
if (blocked) {
console.warn(' repair: read every capability and collect the union of ' +
'currently_due, since card_payments and transfers disable each other:');
console.warn(` GET ${API}/accounts/{id}/capabilities`);
}
if (held) {
console.warn(' repair: Dashboard, Connected accounts. No field collection ' +
'clears a rejected.* or under_review reason.');
}
if (blocked || held || unrequested || 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 are mostly about the four statuses being kept apart, because every one of them has a different repair and three of them are easy to collapse into a single broken bucket. The absent capability is the case worth writing a test for: it is the one that produces no error anywhere and lasts longest.
from stripe_transfers_capability import classify
def test_absent_capability_is_unrequested_not_inactive():
# No transfers key on the account at all. Nothing is outstanding, so no
# onboarding link will ever help; the capability has to be requested.
state, detail = classify(None)
assert state == "unrequested"
assert "never requested" in detail
def test_active_is_the_only_healthy_status():
assert classify({"status": "active"})[0] == "active"
def test_pending_is_not_something_to_chase():
state, detail = classify({
"status": "pending",
"requirements": {"pending_verification": ["individual.verification.document"]},
})
assert state == "verifying"
assert "does not speed it up" in detail
def test_inactive_with_fields_names_them():
state, detail = classify({
"status": "inactive",
"requirements": {"currently_due": ["company.tax_id", "business_profile.url"]},
})
assert state == "blocked"
assert "company.tax_id" in detail
def test_rejected_reason_is_not_a_field_to_collect():
state, detail = classify({
"status": "inactive",
"requirements": {"currently_due": [], "disabled_reason": "rejected.fraud"},
})
assert state == "held"
assert "rejected.fraud" in detail
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { classify } from './stripe-transfers-capability.mjs';
test('absent capability is unrequested, not inactive', () => {
const [state, detail] = classify(null);
assert.equal(state, 'unrequested');
assert.match(detail, /never requested/);
});
test('active is the only healthy status', () => {
assert.equal(classify({ status: 'active' })[0], 'active');
});
test('pending is not something to chase', () => {
const [state, detail] = classify({
status: 'pending',
requirements: { pending_verification: ['individual.verification.document'] },
});
assert.equal(state, 'verifying');
assert.match(detail, /does not speed it up/);
});
test('inactive with fields names them', () => {
const [state, detail] = classify({
status: 'inactive',
requirements: { currently_due: ['company.tax_id', 'business_profile.url'] },
});
assert.equal(state, 'blocked');
assert.match(detail, /company\.tax_id/);
});
test('a rejected reason is not a field to collect', () => {
const [state, detail] = classify({
status: 'inactive',
requirements: { currently_due: [], disabled_reason: 'rejected.fraud' },
});
assert.equal(state, 'held');
assert.match(detail, /rejected\.fraud/);
});
FAQ
Why does the account say charges_enabled true if transfers fail?
They are different permissions. charges_enabled says the account can accept payments; the transfers capability says funds can be moved to it. Each has its own requirement set and its own verification, so an account can take payments all day while nothing can be paid out to it.
What does an absent transfers key mean?
It was never requested. Stripe omits unrequested capabilities from the capabilities hash rather than listing them as inactive, so there is no status to read and no requirement outstanding. Request the capability, and only then does Stripe start asking for what it needs.
I fixed everything in currently_due and transfers is still inactive. Why?
Read every capability, not just this one. Where an account has both card_payments and transfers, either one sitting at inactive disables the pair, so the field blocking your transfers can be listed under a capability you never use. GET /v1/accounts/{id}/capabilities and satisfy the union.
Is pending the same as inactive?
No, and it is worth keeping apart. Pending means Stripe is verifying what it already holds: there is nothing to collect and no link to send. Sending one anyway gives the seller a form that changes nothing, which reads as a broken product rather than as patience.
Does this need a live secret key?
No. A restricted key with read access to Connected accounts is enough for both the account list and the capability objects, and it is what this script should be given. It cannot move money if it leaks.
Related field notes
- A connected account sits with charges_enabled false
- requirements.past_due has already disabled the payouts
- A payout schedule left on manual strands the balance
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.
- Account capabilities — Stripe Docs
- The capability object — Stripe API reference
- The account object — Stripe API reference
- Handling verification with the API — Stripe Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.