Diagnostic Stripe
card_payments inactive disables transfers as well
You read capabilities.transfers, saw inactive, fetched the capability, collected the two fields in its currently_due, and submitted them. Stripe accepted the update. The capability is still inactive, its currently_due is now empty, and transfers still fail. Nothing you can see on that capability explains it, because the requirement holding it down is filed under a different one.
Stripe documents a coupling: if an account has both card_payments and transfers, and the status of either is inactive, then both are disabled. So a platform that only cares about transfers can satisfy every requirement transfers lists and stay blocked on a card_payments field it never reads.
Check the pair together from GET /v1/accounts?limit=100, then take the union of requirements.currently_due across every entry from GET /v1/accounts/{id}/capabilities rather than the one capability you use. That union is the actual list of fields to collect, and it can be satisfied in a single account update.
The problem in plain words
This is the failure that survives being fixed. Somebody works the capability's own requirement list to empty, watches nothing change, and concludes there is a delay on Stripe's side. A week later they work it again. The loop is stable because the evidence available on the object you are looking at is genuinely consistent with everything being fine.
Where it hurts most is on platforms that never take card payments through the connected account at all. Destination charges settle on the platform and are transferred onward, so card_payments on the seller's account is a capability nobody has thought about since it was requested by whatever onboarding preset created the account. It is unmonitored, it is unmentioned in any runbook, and it is holding the money.
Why it happens
The coupling is symmetric, so the direction of the fault is not obvious. An inactive card_payments disables transfers and an inactive transfers disables card_payments. Reading either status alone tells you something is wrong; it does not tell you which of the two is the cause, and the repair depends entirely on that.
Requirements are filed per capability, and only per capability. The account-level currently_due is a flattened view that does not say which capability each field belongs to. The per-capability view says exactly that, and it is the only way to see a field that exists solely because of a capability you do not use.
Fixing one capability at a time cannot converge. Clearing the transfers list leaves the card_payments list untouched, and the pair stays down. The union collected in one pass is not just faster, it is the only approach that ends. This is the mechanism; the status values themselves and the case where a capability was never requested at all are covered in transfers capability is inactive so every transfer 400s.
Dropping the capability is a real option and is sometimes the right one. If the connected account genuinely never needs to take card payments, requesting card_payments=false removes it and with it the coupling. It fails for capabilities that are permanent on that account type, which is itself a useful thing to learn early rather than during an incident.
The fix, as a flow
The script reads both halves of the coupled pair, then unions the outstanding fields across every capability on the account, keeping the name of whichever capability asked for each one.
How to fix it
Read the pair, not one capability
From the accounts list, take capabilities.card_payments and capabilities.transfers together. Both present and either one inactive is the flag. If only one of the two exists on the account there is no coupling to worry about, and that is a different diagnosis.
Pull every capability, not the one you use
GET /v1/accounts/{id}/capabilities returns them all, each with its own requirements hash. This is one extra GET per unhealthy account and it is where the field you are missing actually lives.
Union the currently_due lists and keep the owner of each field
The union is what to collect. Keeping the capability name alongside each field is what makes the report legible: seeing business_profile.mcc attributed to card_payments is the moment the whole thing makes sense to whoever is reading.
Submit the union in one account update
One POST /v1/accounts/{id} carrying every field in the union. Splitting it across two updates re-creates the loop you are trying to escape, because the pair only comes back up when neither capability is inactive.
Or drop the capability you do not need
Requesting card_payments as false removes it from the account and the coupling with it. Only do this where the account really will never take a card payment directly, and expect it to fail on account types where the capability is permanent.
How to check it worked
Re-run the script. Every account with both capabilities should report both active, and the union of outstanding fields should be empty.
python3 stripe_capability_coupling.py
# 412 account(s): 410 healthy, 0 coupled down, 2 pending, 0 field(s) outstanding
The full code
One paginated GET over the accounts, then one GET of the capabilities list for each account that is not healthy — a restricted key with read access to Connected accounts covers both. Two pure functions: one classifies the coupled pair from the statuses alone, and one unions the outstanding fields across every capability while remembering which capability asked for each. The second is the part that changes what you do next, so it is worth having outside the request loop where it can be read.
"""Report connected accounts where the card_payments/transfers pair is down.
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_capability_coupling")
API = "https://api.stripe.com/v1"
PAIR = ("card_payments", "transfers")
def verdict(capabilities):
"""Classify the coupled pair on one account. Pure and offline testable.
Stripe couples card_payments and transfers: where an account has both, either
one sitting at inactive disables the pair. Returns (state, detail). An account
that has only one of the two is reported as uncoupled rather than as healthy,
because the coupling is not what is wrong with it.
"""
caps = capabilities or {}
present = [name for name in PAIR if name in caps]
if len(present) < len(PAIR):
return ("uncoupled",
"only %s on this account, so the pair cannot disable itself"
% (", ".join(present) or "neither capability",))
inactive = [name for name in PAIR if caps[name] == "inactive"]
if len(inactive) == len(PAIR):
return ("coupled-down",
"both card_payments and transfers are inactive; collect the union "
"of their requirements, not one list at a time")
if inactive:
blocked = [name for name in PAIR if name not in inactive]
return ("coupled-down",
"%s is inactive, which disables %s as well; the field you need may "
"be filed under %s" % (inactive[0], blocked[0], inactive[0]))
pending = [name for name in PAIR if caps[name] == "pending"]
if pending:
return ("coupled-pending",
"%s is pending verification; nothing to collect until Stripe "
"finishes with what it already has" % (", ".join(pending),))
other = [name for name in PAIR if caps[name] != "active"]
if other:
return ("unknown",
"unrecognised status for %s" % (", ".join(
"%s=%r" % (name, caps[name]) for name in other),))
return ("healthy", "both capabilities active")
def union_due(capability_objects):
"""Union currently_due across every capability, keeping who asked for each.
Returns [(field, [capability, ...]), ...] sorted by field. This is the list to
submit in one account update: collecting one capability's list at a time
cannot converge, because the pair stays disabled while either half is.
"""
owed = {}
for cap in capability_objects or []:
name = cap.get("id") or "?"
req = cap.get("requirements") or {}
for field in (req.get("past_due") or []) + (req.get("currently_due") or []):
owed.setdefault(field, set()).add(name)
return [(field, sorted(owners)) for field, owners in sorted(owed.items())]
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 paginate(session, path, limit):
"""Walk a list endpoint, stopping once `limit` objects have been yielded."""
seen = 0
params = {"limit": 100}
while True:
page = get(session, path, **params)
data = page.get("data", [])
for obj in data:
yield obj
seen += 1
if seen >= limit:
return
if not page.get("has_more") or not data:
return
params["starting_after"] = data[-1]["id"]
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--max-accounts", type=int, default=500,
help="stop after this many connected 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})
total = healthy = down = pending = fields = 0
for acct in paginate(s, "/accounts", args.max_accounts):
total += 1
state, detail = verdict(acct.get("capabilities"))
if state == "healthy":
healthy += 1
continue
if state == "uncoupled":
continue
if state == "coupled-pending":
pending += 1
log.info("%-16s %s %s", state, acct["id"], detail)
continue
down += 1
log.warning("%-16s %s %s", state, acct["id"], detail)
caps = get(s, "/accounts/%s/capabilities" % acct["id"]).get("data", [])
outstanding = union_due(caps)
fields += len(outstanding)
for field, owners in outstanding:
log.warning(" %-42s required by %s", field, ", ".join(owners))
if outstanding:
log.warning(" repair: one POST %s/accounts/%s carrying every field above",
API, acct["id"])
else:
log.warning(" no fields outstanding: check requirements.disabled_reason "
"and requirements.errors on each capability")
log.info("%d account(s): %d healthy, %d coupled down, %d pending, %d field(s) outstanding",
total, healthy, down, pending, fields)
return 1 if down else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Report connected accounts where the card_payments/transfers pair is down.
*
* 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';
const PAIR = ['card_payments', 'transfers'];
/**
* Classify the coupled pair on one account. Pure and offline testable.
* Stripe couples card_payments and transfers: where an account has both, either
* one sitting at inactive disables the pair.
*/
export function verdict(capabilities) {
const caps = capabilities ?? {};
const present = PAIR.filter((name) => name in caps);
if (present.length < PAIR.length) {
return ['uncoupled',
`only ${present.join(', ') || 'neither capability'} on this account, so the ` +
'pair cannot disable itself'];
}
const inactive = PAIR.filter((name) => caps[name] === 'inactive');
if (inactive.length === PAIR.length) {
return ['coupled-down',
'both card_payments and transfers are inactive; collect the union of their ' +
'requirements, not one list at a time'];
}
if (inactive.length) {
const blocked = PAIR.filter((name) => !inactive.includes(name));
return ['coupled-down',
`${inactive[0]} is inactive, which disables ${blocked[0]} as well; the field ` +
`you need may be filed under ${inactive[0]}`];
}
const pending = PAIR.filter((name) => caps[name] === 'pending');
if (pending.length) {
return ['coupled-pending',
`${pending.join(', ')} is pending verification; nothing to collect until ` +
'Stripe finishes with what it already has'];
}
const other = PAIR.filter((name) => caps[name] !== 'active');
if (other.length) {
return ['unknown', `unrecognised status for ${other.map(
(name) => `${name}=${JSON.stringify(caps[name])}`).join(', ')}`];
}
return ['healthy', 'both capabilities active'];
}
/**
* Union currently_due across every capability, keeping who asked for each.
* Returns [[field, [capability, ...]], ...] sorted by field.
*/
export function unionDue(capabilityObjects) {
const owed = new Map();
for (const cap of capabilityObjects ?? []) {
const name = cap.id ?? '?';
const req = cap.requirements ?? {};
for (const field of [...(req.past_due ?? []), ...(req.currently_due ?? [])]) {
if (!owed.has(field)) owed.set(field, new Set());
owed.get(field).add(name);
}
}
return [...owed.entries()]
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
.map(([field, owners]) => [field, [...owners].sort()]);
}
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();
}
async function* paginate(key, path, limit) {
let seen = 0;
const params = { limit: 100 };
for (;;) {
const page = await get(key, path, params);
const data = page.data ?? [];
for (const obj of data) {
yield obj;
if (++seen >= limit) return;
}
if (!page.has_more || data.length === 0) 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;
}
let total = 0, healthy = 0, down = 0, pending = 0, fields = 0;
for await (const acct of paginate(key, '/accounts', 500)) {
total += 1;
const [state, detail] = verdict(acct.capabilities);
if (state === 'healthy') { healthy += 1; continue; }
if (state === 'uncoupled') continue;
if (state === 'coupled-pending') {
pending += 1;
console.log(`${state.padEnd(16)} ${acct.id} ${detail}`);
continue;
}
down += 1;
console.warn(`${state.padEnd(16)} ${acct.id} ${detail}`);
const { data: caps = [] } = await get(key, `/accounts/${acct.id}/capabilities`);
const outstanding = unionDue(caps);
fields += outstanding.length;
for (const [field, owners] of outstanding) {
console.warn(` ${field.padEnd(42)} required by ${owners.join(', ')}`);
}
if (outstanding.length) {
console.warn(` repair: one POST ${API}/accounts/${acct.id} carrying every ` +
'field above');
} else {
console.warn(' no fields outstanding: check requirements.disabled_reason and ' +
'requirements.errors on each capability');
}
}
console.log(`${total} account(s): ${healthy} healthy, ${down} coupled down, ` +
`${pending} pending, ${fields} field(s) outstanding`);
process.exitCode = down ? 1 : 0;
}
// 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 test that matters is the asymmetric one: transfers is active, card_payments is inactive, and the account is still down. A check written around the capability you actually use calls that healthy. The other is the union: a field owed only by card_payments has to appear in the list even though you were looking at transfers, which is the whole reason for collecting across every capability rather than one.
from stripe_capability_coupling import union_due, verdict
def test_both_active_is_healthy():
assert verdict({"card_payments": "active", "transfers": "active"})[0] == "healthy"
def test_an_active_transfers_is_still_down_when_card_payments_is_inactive():
# The whole point of the note: reading only the capability you use says fine.
state, detail = verdict({"card_payments": "inactive", "transfers": "active"})
assert state == "coupled-down"
assert "card_payments" in detail
assert "transfers" in detail
def test_the_coupling_runs_the_other_way_too():
state, detail = verdict({"card_payments": "active", "transfers": "inactive"})
assert state == "coupled-down"
assert "transfers is inactive" in detail
def test_one_capability_alone_is_not_a_coupling_problem():
assert verdict({"transfers": "inactive"})[0] == "uncoupled"
assert verdict({})[0] == "uncoupled"
def test_pending_is_separated_from_inactive():
state, _ = verdict({"card_payments": "pending", "transfers": "active"})
assert state == "coupled-pending"
def test_an_unrecognised_status_is_not_silently_healthy():
assert verdict({"card_payments": "revoked", "transfers": "active"})[0] == "unknown"
def test_the_union_keeps_fields_owed_by_a_capability_you_do_not_use():
caps = [
{"id": "transfers", "requirements": {"currently_due": []}},
{"id": "card_payments",
"requirements": {"currently_due": ["business_profile.mcc"],
"past_due": ["business_profile.url"]}},
]
assert union_due(caps) == [
("business_profile.mcc", ["card_payments"]),
("business_profile.url", ["card_payments"]),
]
def test_a_field_owed_by_both_names_both():
caps = [
{"id": "transfers", "requirements": {"currently_due": ["tos_acceptance.date"]}},
{"id": "card_payments", "requirements": {"currently_due": ["tos_acceptance.date"]}},
]
assert union_due(caps) == [("tos_acceptance.date", ["card_payments", "transfers"])]
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { unionDue, verdict } from './stripe-capability-coupling.mjs';
test('both active is healthy', () => {
assert.equal(verdict({ card_payments: 'active', transfers: 'active' })[0], 'healthy');
});
test('an active transfers is still down when card_payments is inactive', () => {
const [state, detail] = verdict({ card_payments: 'inactive', transfers: 'active' });
assert.equal(state, 'coupled-down');
assert.match(detail, /card_payments/);
assert.match(detail, /transfers/);
});
test('the coupling runs the other way too', () => {
const [state, detail] = verdict({ card_payments: 'active', transfers: 'inactive' });
assert.equal(state, 'coupled-down');
assert.match(detail, /transfers is inactive/);
});
test('one capability alone is not a coupling problem', () => {
assert.equal(verdict({ transfers: 'inactive' })[0], 'uncoupled');
assert.equal(verdict({})[0], 'uncoupled');
});
test('pending is separated from inactive', () => {
assert.equal(verdict({ card_payments: 'pending', transfers: 'active' })[0],
'coupled-pending');
});
test('an unrecognised status is not silently healthy', () => {
assert.equal(verdict({ card_payments: 'revoked', transfers: 'active' })[0], 'unknown');
});
test('the union keeps fields owed by a capability you do not use', () => {
const caps = [
{ id: 'transfers', requirements: { currently_due: [] } },
{ id: 'card_payments',
requirements: { currently_due: ['business_profile.mcc'],
past_due: ['business_profile.url'] } },
];
assert.deepEqual(unionDue(caps), [
['business_profile.mcc', ['card_payments']],
['business_profile.url', ['card_payments']],
]);
});
test('a field owed by both names both', () => {
const caps = [
{ id: 'transfers', requirements: { currently_due: ['tos_acceptance.date'] } },
{ id: 'card_payments', requirements: { currently_due: ['tos_acceptance.date'] } },
];
assert.deepEqual(unionDue(caps),
[['tos_acceptance.date', ['card_payments', 'transfers']]]);
});
FAQ
Does an inactive card_payments really stop transfers?
Yes, where the account has both. Stripe documents the coupling explicitly: if an account has card_payments and transfers and either one is inactive, both are disabled. It is symmetric, so an inactive transfers takes card_payments down in the same way.
Why is the capability's own currently_due empty while it stays inactive?
Because the field holding it down is filed under the other half of the pair. Requirements are attached per capability, so clearing the list on the capability you use tells you nothing about the one that is actually blocking it.
Why not just fix the capabilities one at a time?
Because that cannot converge. The pair only comes back up when neither half is inactive, so clearing one list and leaving the other still leaves both disabled. Collect the union in one pass and submit it in one account update.
Can I remove card_payments if the seller never takes cards directly?
Often, yes. Requesting card_payments as false drops it from the account and removes the coupling. It fails on account types where the capability is permanent, so check the result rather than assuming it worked.
What if the union is empty and the pair is still down?
Then the block is not a missing field. Read requirements.disabled_reason and requirements.errors on each capability: a rejected document or a platform-level pause produces an inactive status with nothing outstanding to collect.
Related field notes
- transfers capability is inactive so every transfer 400s
- A connected account sits with charges_enabled false
- future_requirements will revoke a capability on a date
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
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.