Diagnostic Stripe
platform-paused payouts were never unpaused
In March, risk paused a seller while a chargeback pattern was investigated. In April the investigation closed with nothing found, the ticket was marked resolved, and everyone moved on. It is now September. The seller has been taking payments the whole time, the money has been accumulating, and the pause is still on, because unpausing was a manual step in a dashboard and nothing anywhere remembers that it was owed.
Paginate GET /v1/accounts?limit=100 and flag requirements.disabled_reason == "platform_paused". That string means the platform did this, deliberately, at some point — not Stripe, not the seller, and not a missing verification field.
Corroborate with GET /v1/payouts?status=canceled using the Stripe-Account header. Pausing leaves in-flight payouts pending for up to ten days and then cancels them, returning the funds to the connected balance, so the trail this leaves is a cluster of canceled payouts rather than failed ones. Unpausing is Dashboard-only: there is no v1 endpoint for it, and the canceled payouts are not re-issued for you.
The problem in plain words
This is the one Connect failure that is entirely self-inflicted, and that is what makes it survive. Every other disabled account has an external cause and therefore an external prompt: a deadline, a verification failure, a rejection. A platform pause has none. It is a decision your own team made, recorded in a ticket system that has no connection to Stripe, and reversed only when a person remembers.
The seller often cannot tell you either. A Custom account receives no notification from Stripe when it is paused, so from their side money simply stopped arriving. If they are still processing payments — a payouts-only pause leaves charges working — their revenue reports look normal and only their bank statement disagrees. The gap between "we paused this in March" and "we noticed in September" is usually a seller doing their quarterly accounts.
Why it happens
Generic disabled-account checks classify it wrongly. A monitor that reads disabled_reason and sorts into "collect some fields" or "Stripe rejected it" has no bucket for this one, so platform_paused lands in the collectable pile. The seller then receives an onboarding link, completes it perfectly, and nothing changes — because there was never a field missing. The broader charges_enabled check is worth running, but it needs this reason carved out of it.
There is no API to undo it. Unpausing lives in the Dashboard under Connect, Connected accounts, on the account itself. No POST reverses it, so it cannot be put in a runbook, a script, or a scheduled job that expires holds automatically. It is a manual action with no deadline attached, which is a category of task organisations are reliably bad at.
The evidence is canceled, not failed. Payouts already in flight when the pause lands stay pending for up to ten days and are then canceled, with the money returned to the connected balance. Anyone searching for failures finds nothing, and canceled is a status most payout monitors ignore entirely because it usually means somebody intended it.
Unpausing does not pay out the backlog. The canceled payouts stay canceled. Once the account is live again the accumulated balance goes out on the next scheduled run, or waits for a manual payout if the schedule is manual, so an account can be unpaused and still be sitting on the money.
The fix, as a flow
The script looks for one string, and refuses to claim accounts disabled for any other reason, because the failure being described here started with somebody treating a deliberate pause as a missing field.
How to fix it
Search on the reason string, not on the boolean
payouts_enabled: false is shared by half a dozen unrelated problems. requirements.disabled_reason == "platform_paused" is unambiguous and identifies exactly the accounts your own team switched off. Pausing can apply to charges, to payouts, or to both, so read both flags rather than assuming which one was used.
Pull the canceled payouts for each paused account
GET /v1/payouts?status=canceled&limit=100 with the Stripe-Account header. The oldest canceled payout is a good estimate of when the pause landed, which is the number that turns "this account is paused" into "this account has been paused for 174 days" — and only the second version gets acted on.
Reconcile the list against the tickets that caused it
Every paused account should map to an open investigation. The ones that map to a closed investigation, or to no ticket at all, are the finding. This is the only step that cannot be automated from Stripe's side, because the reason for the pause was never written down anywhere Stripe can see.
Unpause in the Dashboard, then confirm through the API
Connect, Connected accounts, open the account, unpause payments or payouts. Then re-read the account: payouts_enabled should be true and disabled_reason should be gone. Believe the API rather than the dashboard's confirmation banner.
Re-issue what was canceled
Nothing replays automatically. The balance is sitting on the connected account, and either the next scheduled payout collects it or somebody has to create one. An account left on a manual payout schedule will hold that money indefinitely even after the pause is lifted.
How to check it worked
Re-run the script. No account should be paused without a matching open investigation, and none should be carrying canceled payouts nobody re-issued.
python3 stripe_platform_paused.py
# 412 account(s): 0 paused by the platform, 0 with canceled payouts to re-issue
The full code
One paginated GET for the accounts, and the canceled-payout lookup only for accounts that are actually paused — with a flag to widen it, since payouts canceled by a pause that has since been lifted are their own finding. The classifier is pure and deliberately refuses to claim accounts disabled for other reasons: this check is about one string, and a check that quietly absorbs its neighbours becomes impossible to act on.
"""Report connected accounts the platform paused and never unpaused.
Read only. GETs only, no writes: give this a RESTRICTED key with read access to
Connected accounts and Payouts. The repair is printed, never performed, because
this script holds a credential to a live payments account. It is also printed as
a Dashboard path rather than an API call, because Stripe has no v1 endpoint that
unpauses an 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_platform_paused")
API = "https://api.stripe.com/v1"
DAY = 86400
PAUSED = "platform_paused"
def verdict(account, canceled_count, oldest_canceled_created, now):
"""Classify one connected account against a platform pause. Pure.
Returns (state, detail). Accounts disabled for any other reason are returned
as `other-reason` rather than being folded in: a pause is a decision your own
team made and needs a different response from a missing verification field,
and a check that blurs the two sends onboarding links to sellers who have
nothing to submit.
"""
reqs = account.get("requirements") or {}
reason = reqs.get("disabled_reason")
if reason == PAUSED:
off = []
if account.get("charges_enabled") is False:
off.append("charges")
if account.get("payouts_enabled") is False:
off.append("payouts")
bits = ["paused by the platform: %s off" % (" and ".join(off) or "nothing")]
if canceled_count:
bits.append("%d canceled payout(s)" % canceled_count)
if oldest_canceled_created is not None:
bits.append("paused for at least %d day(s), from the oldest cancellation"
% ((now - oldest_canceled_created) // DAY))
bits.append("no API call reverses this: Dashboard, Connect, Connected "
"accounts, open the account")
return ("paused", " | ".join(bits))
if reason:
return ("other-reason",
"disabled for %s, which is not a platform pause and is not this "
"check's problem" % reason)
if canceled_count:
return ("residue",
"%d canceled payout(s) on an account that is not paused now: a pause "
"was lifted and the canceled payouts were never re-issued" % canceled_count)
return ("healthy", "not paused")
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 canceled_payouts(session, account_id):
"""Count canceled payouts for one account and find the oldest.
A pause holds in-flight payouts as pending for up to ten days and then
cancels them, so this is the paper trail. Failed payouts are a different
problem entirely and are not counted here.
"""
count = 0
oldest = None
params = {"status": "canceled", "limit": 100}
while True:
page = get(session, "/payouts", account=account_id, **params)
data = page.get("data", [])
for payout in data:
count += 1
created = payout.get("created")
if created is not None and (oldest is None or created < oldest):
oldest = created
if not data or not page.get("has_more"):
return count, oldest
params["starting_after"] = data[-1]["id"]
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--check-canceled-everywhere", action="store_true",
help="look for canceled payouts on every account, not only the "
"paused ones. Two extra calls per account, and the only way "
"to find payouts canceled by a pause that was later lifted")
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())
counts = {}
scanned = 0
for acct in accounts(s, args.max_accounts):
scanned += 1
reqs = acct.get("requirements") or {}
paused = reqs.get("disabled_reason") == PAUSED
if paused or args.check_canceled_everywhere:
count, oldest = canceled_payouts(s, acct.get("id"))
else:
count, oldest = 0, None
state, detail = verdict(acct, count, oldest, now)
counts[state] = counts.get(state, 0) + 1
if state in ("healthy", "other-reason"):
continue
log.warning("%s %-8s %s", acct.get("id", "acct_?"), state, detail)
paused = counts.get("paused", 0)
residue = counts.get("residue", 0)
log.info("%d account(s): %d paused by the platform, %d with canceled payouts "
"to re-issue", scanned, paused, residue)
if paused:
log.warning(" repair: Dashboard, Connect, Connected accounts, open the "
"account, unpause payments or payouts. There is no v1 API for it.")
log.warning(" then: re-read the account and confirm payouts_enabled is true "
"and disabled_reason is gone")
log.warning(" reconcile: every paused account should map to an OPEN "
"investigation. The ones that do not are the finding.")
if paused or residue:
log.warning(" note: unpausing does not replay canceled payouts. The balance "
"waits for the next scheduled payout, or forever on a manual "
"schedule.")
if not args.check_canceled_everywhere:
log.info(" canceled payouts were only checked on paused accounts; "
"--check-canceled-everywhere widens it")
return 1 if (paused or residue) else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Report connected accounts the platform paused and never unpaused.
*
* Read only. GETs only, no writes: give this a RESTRICTED key with read access
* to Connected accounts and Payouts. The repair is printed, never performed, and
* it is printed as a Dashboard path because Stripe has no v1 endpoint that
* unpauses an account.
*/
const API = 'https://api.stripe.com/v1';
const DAY = 86400;
const PAUSED = 'platform_paused';
/**
* Classify one connected account against a platform pause. Pure.
* Returns [state, detail]. Accounts disabled for any other reason come back as
* `other-reason` rather than being folded in: blurring the two sends onboarding
* links to sellers who have nothing to submit.
*/
export function verdict(account, canceledCount, oldestCanceledCreated, now) {
const reqs = account.requirements ?? {};
const reason = reqs.disabled_reason ?? null;
if (reason === PAUSED) {
const off = [];
if (account.charges_enabled === false) off.push('charges');
if (account.payouts_enabled === false) off.push('payouts');
const bits = [`paused by the platform: ${off.join(' and ') || 'nothing'} off`];
if (canceledCount) bits.push(`${canceledCount} canceled payout(s)`);
if (oldestCanceledCreated !== null && oldestCanceledCreated !== undefined) {
bits.push('paused for at least ' +
`${Math.floor((now - oldestCanceledCreated) / DAY)} day(s), from the ` +
'oldest cancellation');
}
bits.push('no API call reverses this: Dashboard, Connect, Connected accounts, ' +
'open the account');
return ['paused', bits.join(' | ')];
}
if (reason) {
return ['other-reason',
`disabled for ${reason}, which is not a platform pause and is not this ` +
"check's problem"];
}
if (canceledCount) {
return ['residue',
`${canceledCount} canceled payout(s) on an account that is not paused now: a ` +
'pause was lifted and the canceled payouts were never re-issued'];
}
return ['healthy', 'not paused'];
}
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;
}
}
/**
* Count canceled payouts for one account and find the oldest. A pause holds
* in-flight payouts as pending for up to ten days and then cancels them, so this
* is the paper trail. Failed payouts are a different problem and are not counted.
*/
async function canceledPayouts(key, accountId) {
let count = 0;
let oldest = null;
const params = { status: 'canceled', limit: 100, account: accountId };
for (;;) {
const page = await get(key, '/payouts', params);
const data = page.data ?? [];
for (const payout of data) {
count += 1;
if (payout.created !== undefined && (oldest === null || payout.created < oldest)) {
oldest = payout.created;
}
}
if (data.length === 0 || !page.has_more) return [count, oldest];
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 everywhere = process.argv.includes('--check-canceled-everywhere');
const now = Math.floor(Date.now() / 1000);
const counts = new Map();
let scanned = 0;
for await (const acct of accounts(key)) {
scanned += 1;
const paused = (acct.requirements ?? {}).disabled_reason === PAUSED;
const [count, oldest] = (paused || everywhere)
? await canceledPayouts(key, acct.id) : [0, null];
const [state, detail] = verdict(acct, count, oldest, now);
counts.set(state, (counts.get(state) ?? 0) + 1);
if (state === 'healthy' || state === 'other-reason') continue;
console.warn(`${acct.id ?? 'acct_?'} ${state.padEnd(8)} ${detail}`);
}
const paused = counts.get('paused') ?? 0;
const residue = counts.get('residue') ?? 0;
console.log(`${scanned} account(s): ${paused} paused by the platform, ` +
`${residue} with canceled payouts to re-issue`);
if (paused) {
console.warn(' repair: Dashboard, Connect, Connected accounts, open the account, ' +
'unpause payments or payouts. There is no v1 API for it.');
console.warn(' then: re-read the account and confirm payouts_enabled is true and ' +
'disabled_reason is gone');
console.warn(' reconcile: every paused account should map to an OPEN ' +
'investigation. The ones that do not are the finding.');
}
if (paused || residue) {
console.warn(' note: unpausing does not replay canceled payouts. The balance ' +
'waits for the next scheduled payout, or forever on a manual schedule.');
process.exitCode = 1;
}
if (!everywhere) {
console.log(' canceled payouts were only checked on paused accounts; ' +
'--check-canceled-everywhere widens it');
}
}
// 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 earns its place is the one asserting a rejected or past-due account does not come back as paused. Everything else in this check is a string comparison; the value is in refusing to claim accounts that belong to a different repair, since the whole failure mode being described here started with somebody treating a pause as a missing field.
from stripe_platform_paused import verdict
NOW = 1767225600 # 2026-01-01T00:00:00Z
def account(reason=None, charges=True, payouts=True):
return {"id": "acct_1", "charges_enabled": charges, "payouts_enabled": payouts,
"requirements": {"disabled_reason": reason}}
def test_a_normal_account_is_healthy():
assert verdict(account(), 0, None, NOW)[0] == "healthy"
def test_platform_paused_is_named_and_says_which_side_is_off():
state, detail = verdict(account("platform_paused", charges=True, payouts=False),
0, None, NOW)
assert state == "paused"
assert "payouts off" in detail
assert "no API call reverses this" in detail
def test_a_pause_on_both_sides_says_both():
_, detail = verdict(account("platform_paused", charges=False, payouts=False),
0, None, NOW)
assert "charges and payouts off" in detail
def test_canceled_payouts_date_the_pause():
_, detail = verdict(account("platform_paused", payouts=False),
4, NOW - 174 * 86400, NOW)
assert "4 canceled payout(s)" in detail
assert "at least 174 day(s)" in detail
def test_other_disabled_reasons_are_not_claimed_by_this_check():
# The failure this note describes starts with somebody treating a pause as a
# missing field. Doing the reverse is just as wrong.
for reason in ("requirements.past_due", "rejected.fraud", "under_review",
"requirements.pending_verification"):
state, detail = verdict(account(reason, charges=False, payouts=False),
0, None, NOW)
assert state == "other-reason", reason
assert reason in detail
def test_canceled_payouts_without_a_pause_are_residue():
state, detail = verdict(account(), 3, NOW - 200 * 86400, NOW)
assert state == "residue"
assert "never re-issued" in detail
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict } from './stripe-platform-paused.mjs';
const NOW = 1767225600; // 2026-01-01T00:00:00Z
const account = (reason = null, charges = true, payouts = true) => ({
id: 'acct_1',
charges_enabled: charges,
payouts_enabled: payouts,
requirements: { disabled_reason: reason },
});
test('a normal account is healthy', () => {
assert.equal(verdict(account(), 0, null, NOW)[0], 'healthy');
});
test('platform paused is named and says which side is off', () => {
const [state, detail] = verdict(account('platform_paused', true, false), 0, null, NOW);
assert.equal(state, 'paused');
assert.match(detail, /payouts off/);
assert.match(detail, /no API call reverses this/);
});
test('a pause on both sides says both', () => {
const [, detail] = verdict(account('platform_paused', false, false), 0, null, NOW);
assert.match(detail, /charges and payouts off/);
});
test('canceled payouts date the pause', () => {
const [, detail] = verdict(account('platform_paused', true, false),
4, NOW - 174 * 86400, NOW);
assert.match(detail, /4 canceled payout\(s\)/);
assert.match(detail, /at least 174 day\(s\)/);
});
test('other disabled reasons are not claimed by this check', () => {
// The failure this note describes starts with somebody treating a pause as a
// missing field. Doing the reverse is just as wrong.
for (const reason of ['requirements.past_due', 'rejected.fraud', 'under_review',
'requirements.pending_verification']) {
const [state, detail] = verdict(account(reason, false, false), 0, null, NOW);
assert.equal(state, 'other-reason', reason);
assert.ok(detail.includes(reason));
}
});
test('canceled payouts without a pause are residue', () => {
const [state, detail] = verdict(account(), 3, NOW - 200 * 86400, NOW);
assert.equal(state, 'residue');
assert.match(detail, /never re-issued/);
});
FAQ
Is there really no API to unpause an account?
Not in v1. Pausing and unpausing payments or payouts on a connected account is a Dashboard control, found on the account under Connect, Connected accounts, and it is not supported on Accounts v2 at all. That is why this script prints a Dashboard path rather than a call: there is no call to print.
Why are the payouts canceled rather than failed?
Because they never left. A payout already in flight when the pause lands sits at pending for up to ten days, and if the pause is still on when that runs out Stripe cancels it and returns the funds to the connected balance. Nothing was rejected by a bank, so nothing appears in a failed-payout report.
Does the seller know they have been paused?
Not reliably. A Custom account gets no notification from Stripe, so the platform is the only party that can tell them. If only payouts were paused, the seller keeps taking payments and their revenue dashboards look completely normal, which is why these pauses can outlive the investigations that caused them by months.
Will unpausing send the money that piled up?
Eventually, and not automatically. The canceled payouts stay canceled. Once the account is live again the accumulated balance goes out on the next scheduled payout, and on a manual payout schedule it goes out only when somebody creates one, so an unpaused account can still be sitting on months of funds.
Does this need a live secret key?
No. A restricted key with read access to Connected accounts and Payouts is enough. The script reads account state and counts canceled payouts; the pause itself can only be lifted by a person in the Dashboard, which is the strongest possible guarantee that a monitoring script will not do it by accident.
Related field notes
- A connected account sits with charges_enabled false
- A connected account's reserved balance keeps growing
- 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.
- Pausing payments or payouts on connected accounts — Stripe Docs
- The Account object — Stripe API reference
- The Payout object — Stripe API reference
- Payouts to connected accounts — Stripe Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.