Diagnostic Stripe
a payout schedule left on manual strands the balance
A seller opens a ticket asking where four months of money went. Everything you check says the account is fine: payouts are enabled, no requirements are outstanding, a verified bank account is attached and set as the default. There are no failed payouts to investigate because there are no payouts at all, and the reason is a single string in a settings hash nobody has read since the platform was built.
Paginate GET /v1/accounts?limit=100 and read settings.payouts.schedule.interval. Where it is manual and payouts_enabled is true, Stripe will never initiate a payout for that account. It is not a fault state, which is exactly why nothing reports it.
Confirm that money is actually stuck rather than merely unscheduled: with Stripe-Account: acct_x, GET /v1/balance for a positive available[].amount, and GET /v1/payouts?limit=1 for the most recent payout. A positive balance and no payout in the last 30 days is stranded money; a positive balance with a payout last week means a job exists and is running.
While you are there, read settings.payouts.schedule.delay_days. An inflated value produces the same "no payouts" complaint from a schedule that is working exactly as configured.
The problem in plain words
Every health check on this account passes. That is the whole problem. Manual is a legitimate setting, deliberately available, and Stripe has no opinion about whether you meant it, so an account left on it looks identical to one where a nightly job is quietly doing its work. The absence of that job is not something the API can see.
It typically starts as a decision. Somebody set the platform default to manual during a hold-funds phase, or turned it on for one seller during a dispute, and the corresponding create the payouts ourselves work item was either never written or was written for a different cohort. New accounts inherit the platform default, so the population of affected sellers grows quietly with signups.
The damage compounds. Every week the stranded amount grows, and the eventual fix is not a small one: switching to an automatic schedule releases a large balance at once, which is exactly the kind of movement that draws a risk review.
Why it happens
Manual is a setting, not a failure. There is no disabled_reason, no requirement, no event, and nothing in the Dashboard turns red. The only signal is an absence — payouts that were never created — and absences do not raise alerts unless something is deliberately looking for them.
It is inherited, so it spreads without a decision being made twice. The platform-level default applies to accounts created after it was set. One choice made during a beta becomes the configuration of every seller who has signed up since.
The obvious monitor watches the wrong list. Checking GET /v1/payouts?status=failed finds nothing here, forever, because nothing was ever attempted. The same blind spot makes an account with no external account attached look healthy, and the two conditions produce an identical symptom from opposite causes: one has nowhere to send money, this one has no instruction to send it.
A large delay_days looks the same from outside. A schedule with an inflated delay is working correctly and still produces a seller who says they have not been paid. It is worth flagging next to manual precisely because the complaint is identical and the fix is not.
The fix, as a flow
The script reads the schedule off every account, then spends two extra GETs only where the interval is manual, because a balance and the age of the last payout are what separate a deliberate setting from stuck money.
How to fix it
Read the schedule on every connected account
settings.payouts.schedule.interval is one of manual, daily, weekly or monthly. Flag manual where payouts_enabled is true, because that combination means Stripe is willing to pay out and is simply never asked to.
Separate manual-and-empty from manual-and-stranded
A manual schedule on an account with a zero balance is a configuration question. A manual schedule on an account holding money is a support ticket. Read GET /v1/balance with the Stripe-Account header to tell them apart before anyone gets paged.
Check whether a payout job exists at all
GET /v1/payouts?limit=1 against the account answers the question the settings cannot: is something out there creating payouts? A recent one means your job is running and manual is intentional. None at all, or one from months ago, means the job was never written.
Flag an inflated delay_days alongside it
Anything well above the country minimum is worth listing. It is not broken, but it explains the same complaint, and knowing which of the two you are looking at saves an afternoon.
Decide deliberately, then release carefully
Either switch the interval to an automatic one or commit to writing the job. If you switch, expect the accumulated balance to go out in the first payout: tell the seller, and expect Stripe to look at a movement that size on an account with no payout history.
How to check it worked
Re-run the script. Accounts on a manual schedule should either hold no balance or show a recent payout, and no account should appear as stranded.
python3 stripe_manual_payout_schedule.py
# 412 account(s): 0 stranded, 6 manual, 2 slow
The full code
One paginated GET over the accounts, then two small GETs per manual account using the Stripe-Account header — a restricted key with read access to Connected accounts, Balance and Payouts covers all three. The classifier is pure and takes the account plus the two facts read per account, so the line between a deliberate manual schedule and stranded money is a rule you can read rather than a judgement made mid-loop.
"""Report connected accounts whose payout schedule leaves money stranded.
Read only. Paginated GETs and no writes: give this a RESTRICTED key with read
access to Connected accounts, Balance and Payouts. 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 time
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("stripe_manual_payout_schedule")
API = "https://api.stripe.com/v1"
STALE_DAYS = 30 # a manual account with money and no payout this recently
SLOW_DELAY_DAYS = 14 # well above any country minimum
def classify(account, available, last_payout_age_days):
"""Sort one connected account by its payout schedule. Pure, so the boundary
between a deliberate manual schedule and stranded money can be tested.
`available` is the summed available balance for that account in minor units,
or None when it was not read. `last_payout_age_days` is the age of the most
recent payout in days, or None when the account has never had one.
Returns (state, detail).
"""
schedule = (((account.get("settings") or {}).get("payouts") or {})
.get("schedule") or {})
interval = schedule.get("interval")
delay = schedule.get("delay_days")
held = available or 0
if not account.get("payouts_enabled"):
return ("disabled",
"payouts_enabled is false: the schedule is not what is stopping "
"the money, so fix the requirements first")
if interval == "manual":
if held <= 0:
return ("manual",
"manual schedule with nothing available: intentional or not, "
"no money is stuck right now")
if last_payout_age_days is None:
return ("stranded",
"manual schedule, %d available and no payout has ever been "
"created: nothing is going to move it" % held)
if last_payout_age_days >= STALE_DAYS:
return ("stranded",
"manual schedule, %d available and the last payout was %.0f "
"days ago: whatever creates them has stopped"
% (held, last_payout_age_days))
return ("manual",
"manual schedule, %d available and a payout %.0f days ago: a job "
"is running" % (held, last_payout_age_days))
if interval is None:
return ("unknown",
"no settings.payouts.schedule.interval on the account object")
if isinstance(delay, int) and delay > SLOW_DELAY_DAYS:
return ("slow",
"%s schedule with delay_days=%d: working as configured, and far "
"enough out to produce the same complaint" % (interval, delay))
return ("scheduled", "%s schedule, delay_days=%s" % (interval, delay))
def get(session, path, account=None, **params):
"""GET one path. `account` sets the Stripe-Account header for per-account reads."""
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 stranded_facts(session, account_id):
"""Read the two facts that separate a deliberate manual schedule from a stuck one."""
balance = get(session, "/balance", account=account_id)
available = sum(b.get("amount", 0) for b in (balance.get("available") or []))
payouts = get(session, "/payouts", account=account_id, limit=1)
data = payouts.get("data") or []
age = None
if data and data[0].get("created"):
age = (time.time() - data[0]["created"]) / 86400.0
return available, age
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("--stale-days", type=float, default=STALE_DAYS,
help="a manual account with no payout this recently is stranded")
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
schedule = (((acct.get("settings") or {}).get("payouts") or {})
.get("schedule") or {})
# Only spend the two extra GETs where the schedule says nobody is going
# to create a payout on its own.
available, age = (None, None)
if schedule.get("interval") == "manual" and acct.get("payouts_enabled"):
available, age = stranded_facts(s, acct.get("id", ""))
state, detail = classify(acct, available, age)
counts[state] = counts.get(state, 0) + 1
if state in ("scheduled",):
continue
log.warning("%s %-10s %s", acct.get("id", "acct_?"), state, detail)
stranded = counts.get("stranded", 0)
slow = counts.get("slow", 0)
log.info("%d account(s): %d stranded, %d manual, %d slow",
scanned, stranded, counts.get("manual", 0), slow)
if stranded:
log.warning(" repair, one of two, and pick deliberately:")
log.warning(" POST %s/accounts/{id} "
"settings[payouts][schedule][interval]=daily", API)
log.warning(" or keep manual and write the job that creates "
"POST %s/payouts against each account", API)
log.warning(" note: the first automatic payout releases the whole "
"accumulated balance at once. Warn the seller.")
if slow:
log.warning(" repair: lower settings[payouts][schedule][delay_days] to "
"the country minimum if it was inflated by accident")
return 1 if stranded else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Report connected accounts whose payout schedule leaves money stranded.
*
* Read only. Paginated GETs and no writes: give this a RESTRICTED key with read
* access to Connected accounts, Balance and Payouts. The repair is printed,
* never performed.
*/
const API = 'https://api.stripe.com/v1';
export const STALE_DAYS = 30; // manual, holding money, no payout this recently
const SLOW_DELAY_DAYS = 14; // well above any country minimum
/**
* Sort one connected account by its payout schedule. Pure, so the boundary
* between a deliberate manual schedule and stranded money can be tested.
* `available` is the summed available balance in minor units, or null.
* `lastPayoutAgeDays` is null when the account has never had a payout.
* Returns [state, detail].
*/
export function classify(account, available, lastPayoutAgeDays) {
const schedule = account.settings?.payouts?.schedule ?? {};
const interval = schedule.interval ?? null;
const delay = schedule.delay_days;
const held = available ?? 0;
if (!account.payouts_enabled) {
return ['disabled',
'payouts_enabled is false: the schedule is not what is stopping the ' +
'money, so fix the requirements first'];
}
if (interval === 'manual') {
if (held <= 0) {
return ['manual',
'manual schedule with nothing available: intentional or not, no money ' +
'is stuck right now'];
}
if (lastPayoutAgeDays === null || lastPayoutAgeDays === undefined) {
return ['stranded',
`manual schedule, ${held} available and no payout has ever been ` +
'created: nothing is going to move it'];
}
if (lastPayoutAgeDays >= STALE_DAYS) {
return ['stranded',
`manual schedule, ${held} available and the last payout was ` +
`${lastPayoutAgeDays.toFixed(0)} days ago: whatever creates them has stopped`];
}
return ['manual',
`manual schedule, ${held} available and a payout ` +
`${lastPayoutAgeDays.toFixed(0)} days ago: a job is running`];
}
if (interval === null) {
return ['unknown', 'no settings.payouts.schedule.interval on the account object'];
}
if (Number.isInteger(delay) && delay > SLOW_DELAY_DAYS) {
return ['slow',
`${interval} schedule with delay_days=${delay}: working as configured, ` +
'and far enough out to produce the same complaint'];
}
return ['scheduled', `${interval} schedule, delay_days=${delay}`];
}
async function get(key, path, { account = null, 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;
}
}
async function strandedFacts(key, accountId) {
const balance = await get(key, '/balance', { account: accountId });
const available = (balance.available ?? []).reduce((n, b) => n + (b.amount ?? 0), 0);
const payouts = await get(key, '/payouts', { account: accountId, params: { limit: 1 } });
const data = payouts.data ?? [];
const age = data.length && data[0].created
? (Date.now() / 1000 - data[0].created) / 86400 : null;
return { available, age };
}
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 schedule = acct.settings?.payouts?.schedule ?? {};
let available = null;
let age = null;
if (schedule.interval === 'manual' && acct.payouts_enabled) {
({ available, age } = await strandedFacts(key, acct.id));
}
const [state, detail] = classify(acct, available, age);
counts[state] = (counts[state] ?? 0) + 1;
if (state === 'scheduled') continue;
console.warn(`${acct.id ?? 'acct_?'} ${state.padEnd(10)} ${detail}`);
}
const stranded = counts.stranded ?? 0;
console.log(`${scanned} account(s): ${stranded} stranded, ` +
`${counts.manual ?? 0} manual, ${counts.slow ?? 0} slow`);
if (stranded) {
console.warn(' repair, one of two, and pick deliberately:');
console.warn(` POST ${API}/accounts/{id} settings[payouts][schedule][interval]=daily`);
console.warn(` or keep manual and write the job that creates POST ${API}/payouts`);
console.warn(' note: the first automatic payout releases the whole ' +
'accumulated balance at once. Warn the seller.');
}
if (counts.slow) {
console.warn(' repair: lower settings[payouts][schedule][delay_days] to the ' +
'country minimum if it was inflated by accident');
}
if (stranded) process.exitCode = 1;
}
// Only run when invoked directly, so importing this module in the test file does
// not run main(), fail on the missing key and fail the suite.
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
The tests are about not crying wolf. Manual is a legitimate setting, so the check is only useful if it can tell an account where somebody chose manual and wrote the job from one where somebody chose manual and did not. That distinction is two facts — a balance and the age of the last payout — and every boundary between them is here.
from stripe_manual_payout_schedule import classify
def manual(payouts_enabled=True, delay=2):
return {"payouts_enabled": payouts_enabled,
"settings": {"payouts": {"schedule": {"interval": "manual",
"delay_days": delay}}}}
def test_manual_with_money_and_no_payout_ever_is_stranded():
state, detail = classify(manual(), 480000, None)
assert state == "stranded"
assert "no payout has ever been created" in detail
def test_manual_with_money_and_a_recent_payout_is_a_running_job():
# Somebody chose manual and wrote the job. Do not page anyone.
state, _ = classify(manual(), 480000, 3.0)
assert state == "manual"
def test_thirty_days_is_the_boundary():
assert classify(manual(), 100, 29.9)[0] == "manual"
assert classify(manual(), 100, 30.0)[0] == "stranded"
def test_manual_with_an_empty_balance_is_not_an_incident():
assert classify(manual(), 0, None)[0] == "manual"
def test_payouts_disabled_is_a_different_problem():
state, detail = classify(manual(payouts_enabled=False), 90000, None)
assert state == "disabled"
assert "requirements first" in detail
def test_inflated_delay_days_is_flagged_separately():
acct = {"payouts_enabled": True,
"settings": {"payouts": {"schedule": {"interval": "weekly",
"delay_days": 30}}}}
state, detail = classify(acct, None, None)
assert state == "slow"
assert "delay_days=30" in detail
def test_an_ordinary_daily_schedule_is_quiet():
acct = {"payouts_enabled": True,
"settings": {"payouts": {"schedule": {"interval": "daily",
"delay_days": 2}}}}
assert classify(acct, None, None)[0] == "scheduled"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { classify } from './stripe-manual-payout-schedule.mjs';
const manual = (payoutsEnabled = true, delay = 2) => ({
payouts_enabled: payoutsEnabled,
settings: { payouts: { schedule: { interval: 'manual', delay_days: delay } } },
});
test('manual with money and no payout ever is stranded', () => {
const [state, detail] = classify(manual(), 480000, null);
assert.equal(state, 'stranded');
assert.match(detail, /no payout has ever been created/);
});
test('manual with money and a recent payout is a running job', () => {
assert.equal(classify(manual(), 480000, 3.0)[0], 'manual');
});
test('thirty days is the boundary', () => {
assert.equal(classify(manual(), 100, 29.9)[0], 'manual');
assert.equal(classify(manual(), 100, 30.0)[0], 'stranded');
});
test('manual with an empty balance is not an incident', () => {
assert.equal(classify(manual(), 0, null)[0], 'manual');
});
test('payouts disabled is a different problem', () => {
const [state, detail] = classify(manual(false), 90000, null);
assert.equal(state, 'disabled');
assert.match(detail, /requirements first/);
});
test('inflated delay_days is flagged separately', () => {
const acct = {
payouts_enabled: true,
settings: { payouts: { schedule: { interval: 'weekly', delay_days: 30 } } },
};
const [state, detail] = classify(acct, null, null);
assert.equal(state, 'slow');
assert.match(detail, /delay_days=30/);
});
test('an ordinary daily schedule is quiet', () => {
const acct = {
payouts_enabled: true,
settings: { payouts: { schedule: { interval: 'daily', delay_days: 2 } } },
};
assert.equal(classify(acct, null, null)[0], 'scheduled');
});
FAQ
What does a manual payout schedule actually do?
It stops Stripe from initiating payouts for that account. The balance still accrues and payouts are still permitted; they just have to be created by your code. If nothing creates them, nothing happens, and no error is raised because nothing was attempted.
Why did every new seller end up on manual?
It is inherited. A platform-level payout schedule applies to accounts created after it is set, so one decision made during a hold-funds phase quietly becomes the configuration of every account that signs up afterwards.
How is this different from an account with no bank account attached?
The symptom is identical and the cause is the opposite. With no external account there is nowhere to send the money; with a manual schedule there is a destination and no instruction. Reading settings.payouts.schedule.interval next to the external accounts list tells you which one you have.
What happens when I switch a stranded account to daily?
The accumulated balance goes out, subject to the delay_days on the schedule. On an account with months of history and no payouts that can be a large single movement, so warn the seller first and expect it to attract a look from Stripe.
Should I flag a large delay_days as a problem?
Flag it, do not alarm about it. A long delay is a working schedule and produces the same seller complaint, so it belongs in the same report. The fix is different: lower it toward the country minimum if it was inflated by accident, or explain it if it was not.
Related field notes
- A connected account has no external account to pay out to
- Payouts fail with account_closed and nobody is watching
- The transfers capability is inactive so every transfer 400s
Switching the schedule back fixes future payouts and nothing historical. For the deposits already out the door, the workable approach is the clearing-account model — period activity in, each withdrawal out — rather than trying to attribute a settlement window to an arbitrary withdrawal, which cannot be right by construction: take 500 out of a 1,000 balance and no window equals the payout. Payoutproof books manual payouts this way for WooCommerce.
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.
- Payouts — Stripe Docs
- Manage payout schedule — Stripe Docs
- The account object — Stripe API reference
- The balance 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.