Skip to content

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.

Read-only key Python and Node.js Tests included
A bunch of wires that are connected to a server
Photo by Lightsaber Collection on Unsplash
The short answer

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.

Risk pauses thesellerdisabled_reasonsetIn flightpayouts pendup to 10 daysPayoutscanceledfunds return tobalanceInvestigationclosesnobody unpausesSix months offundsfound at year end
Nothing external ever prompts the reversal. The decision lives in a ticket system that has no connection to Stripe.

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.

GET /v1/accountsdisabled_reason, plus canceledpayoutsNot paused, no residuenothing to doplatform_pausedDashboard only, no APICanceled, not paused nowre-issue the payoutsAnother disabled_reasona different note entirely
An onboarding link sent to a paused seller gets completed perfectly and changes nothing, because no field was ever missing.

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.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 114 Stripe fixes, free and open source.
stripe_platform_paused.py
"""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())
stripe-platform-paused.mjs
/**
 * 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.

test_stripe_platform_paused.py
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
stripe-platform-paused.test.mjs
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

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.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.