Diagnostic Stripe
accounts stall at details_submitted false after link expiry
Your database has four hundred acct_ rows and two hundred and ninety of them have never done anything at all. Support has a handful of tickets that all say some version of the same thing: the Stripe page said something went wrong, or the emailed link did nothing when they clicked it. Nobody connected the tickets to the rows, because the rows do not look like failures. They look like people who changed their minds.
Paginate GET /v1/accounts?limit=100 and flag every account where details_submitted is false and created is more than about a week old. That combination is not a seller who is thinking it over; it is a seller whose onboarding session ended before it finished.
Then segment by requirements.currently_due. A long list means the form was never worked through at all; a short one means somebody got most of the way and lost the session near the end. The second group converts on a fresh link and the first group usually needs a different conversation.
The mechanism is the AccountLink itself: the url expires within minutes and is single use. A refresh, a back button, or a chat client fetching a preview of the link consumes it.
The problem in plain words
These accounts are invisible precisely because they are not broken. Nothing is disabled, no requirement is past due, no capability is inactive, and no event fires. A monitor built around charges_enabled correctly ignores them — an account that never opened has not stopped working — so the cohort accumulates underneath every other check you have.
Meanwhile the seller's experience is worse than a plain error. They clicked a link and got a page saying something went wrong, with nothing to retry. Most of them do not open a ticket; they just do not come back, and the row sits in your database looking like a lead who went cold rather than a product failure you can fix.
Why it happens
The link is single use and short lived. An AccountLink url is valid for a few minutes and can be used once. Refreshing the page, pressing back, or opening it a second time on a different device consumes it. The user did nothing wrong and there is nothing on the page telling them what happened.
Sending it through a message consumes it before the human sees it. Email and chat clients fetch links to build previews and to scan them for safety. That fetch is a use. This is why the docs say to hand the link to an already authenticated user inside your own app rather than emailing or texting it — a link that arrives in an inbox may already be spent by the time it is clicked.
refresh_url is the part everyone stubs out. Stripe sends the user to refresh_url exactly when the link is no longer usable, and its whole job is to mint a new one and redirect. Implemented as a static "something went wrong" page — the natural placeholder — it converts a recoverable expiry into a dead end, and the account is left at details_submitted: false forever.
Nobody re-onboards the cohort. Even after the handler is fixed, the accounts that stalled before the fix stay stalled. They need a deliberate pass with fresh links, and that pass never happens unless someone can produce the list.
The fix, as a flow
The script pairs details_submitted with the age of the account, because an unsubmitted account is a normal signup for a week and a lost one afterwards.
How to fix it
List the accounts that never submitted, with an age
details_submitted == false on its own includes everyone who signed up in the last hour. Pair it with created and a threshold of about seven days, so the report is people who are not coming back on their own rather than people mid-signup.
Split never-started from nearly-finished
Use the length of requirements.currently_due. It is a triage heuristic, not a status: a short list means most of the form was completed and the session died near the end, which is the group most worth a fresh link and a personal email.
Look at what your refresh_url actually serves
This is the fix. It has to call the account link endpoint again with the same parameters and 302 to the new url. If it renders an error page, every expiry in your funnel becomes a permanently lost account.
Stop sending links through messaging
Generate the link on a request from an authenticated user and redirect them straight to it. If you need an out-of-band nudge, email a link to your app, which authenticates them and then mints a fresh AccountLink server-side.
Re-onboard the backlog once, deliberately
The list this script prints is the work queue. Fixing the handler helps everyone who arrives from now on; the stalled cohort needs someone to walk them back in.
How to check it worked
Re-run the script after the refresh handler ships and the backlog has been contacted. Accounts under the age threshold are expected; the aged cohort should shrink toward zero.
python3 stripe_onboarding_stalled.py
# 412 account(s): 3 in flight, 0 abandoned
The full code
One paginated GET and nothing else — a restricted key with read access to Connected accounts is enough. The classifier is pure and takes the account plus its age in days, so the seven-day threshold and the never-started split are testable rules rather than an inline comparison against the clock.
"""Report connected accounts that never finished onboarding.
Read only. One paginated GET 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 time
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("stripe_onboarding_stalled")
API = "https://api.stripe.com/v1"
STALE_DAYS = 7 # below this, the seller may simply still be signing up
NEARLY_DONE = 3 # few enough fields left that the session probably died near the end
def classify(account, age_days, stale_days=STALE_DAYS):
"""Sort one connected account by how far its onboarding got. Pure, so the age
threshold and the never-started split can be tested without a clock.
`age_days` is the account's age in days, or None when `created` is missing.
The length of currently_due is a triage heuristic and nothing more: Stripe
does not publish a "how far through the form" field, and a short list is the
closest honest proxy for a session that ended near the end.
Returns (state, detail).
"""
reqs = account.get("requirements") or {}
due = [f for f in (reqs.get("currently_due") or []) if f]
if account.get("details_submitted"):
return ("submitted", "details_submitted is true: onboarding completed")
if age_days is None:
return ("unknown", "details_submitted is false and there is no created "
"timestamp to age it against")
if age_days < stale_days:
return ("in-flight",
"%.1f days old and not submitted: may still be signing up, so do "
"not chase it yet" % age_days)
if not due:
return ("unknown",
"%.0f days old, not submitted, and nothing is currently due: no "
"capability has been requested, so Stripe is not asking for "
"anything" % age_days)
if len(due) <= NEARLY_DONE:
return ("abandoned-late",
"%.0f days old with %d field(s) left (%s): got most of the way, "
"then the session ended. Worth a fresh link and an email"
% (age_days, len(due), ", ".join(due[:3])))
return ("abandoned-cold",
"%.0f days old with %d field(s) still due: the form was never worked "
"through" % (age_days, len(due)))
def get(session, path, **params):
r = session.get(API + path, params=params, timeout=30)
if r.status_code == 401:
raise SystemExit("401 from Stripe: the key is wrong, or is for the other mode")
r.raise_for_status()
return r.json()
def accounts(session, cap):
"""Yield connected accounts, paginating until Stripe stops or the cap is hit."""
seen = 0
params = {"limit": 100}
while True:
page = get(session, "/accounts", **params)
data = page.get("data", [])
for acct in data:
yield acct
seen += 1
if seen >= cap:
return
if not data or not page.get("has_more"):
return
params["starting_after"] = data[-1]["id"]
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--max-accounts", type=int, default=5000,
help="stop paginating after this many accounts")
ap.add_argument("--stale-days", type=float, default=STALE_DAYS,
help="an unsubmitted account older than this is abandoned")
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 = time.time()
counts = {}
scanned = 0
for acct in accounts(s, args.max_accounts):
scanned += 1
created = acct.get("created")
age = None if created is None else (now - created) / 86400.0
state, detail = classify(acct, age, args.stale_days)
counts[state] = counts.get(state, 0) + 1
if state in ("submitted", "in-flight"):
continue
log.warning("%s %-15s %s", acct.get("id", "acct_?"), state, detail)
late = counts.get("abandoned-late", 0)
cold = counts.get("abandoned-cold", 0)
log.info("%d account(s): %d in flight, %d abandoned",
scanned, counts.get("in-flight", 0), late + cold)
if late or cold:
log.warning(" repair, in this order:")
log.warning(" 1. make refresh_url mint a new link and 302 to it. Stripe "
"sends the user there precisely when the old one is spent:")
log.warning(" POST %s/account_links account, refresh_url, return_url, "
"type=account_onboarding", API)
log.warning(" 2. never email or SMS the returned url. It is single use, "
"and a client fetching a preview of it uses it.")
log.warning(" 3. re-onboard the %d account(s) above with fresh links, "
"starting with the %d that nearly finished.", late + cold, late)
return 1 if (late or cold) else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Report connected accounts that never finished onboarding.
*
* Read only. One paginated GET 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';
export const STALE_DAYS = 7; // below this, the seller may still be signing up
const NEARLY_DONE = 3; // few enough left that the session died near the end
/**
* Sort one connected account by how far its onboarding got. Pure, so the age
* threshold and the never-started split can be tested without a clock.
* `ageDays` is null when the account has no created timestamp.
* Returns [state, detail].
*/
export function classify(account, ageDays, staleDays = STALE_DAYS) {
const reqs = account.requirements ?? {};
const due = (reqs.currently_due ?? []).filter(Boolean);
if (account.details_submitted) {
return ['submitted', 'details_submitted is true: onboarding completed'];
}
if (ageDays === null || ageDays === undefined) {
return ['unknown',
'details_submitted is false and there is no created timestamp to age it against'];
}
if (ageDays < staleDays) {
return ['in-flight',
`${ageDays.toFixed(1)} days old and not submitted: may still be signing ` +
'up, so do not chase it yet'];
}
if (!due.length) {
return ['unknown',
`${ageDays.toFixed(0)} days old, not submitted, and nothing is currently ` +
'due: no capability has been requested, so Stripe is not asking for anything'];
}
if (due.length <= NEARLY_DONE) {
return ['abandoned-late',
`${ageDays.toFixed(0)} days old with ${due.length} field(s) left ` +
`(${due.slice(0, 3).join(', ')}): got most of the way, then the session ` +
'ended. Worth a fresh link and an email'];
}
return ['abandoned-cold',
`${ageDays.toFixed(0)} days old with ${due.length} field(s) still due: the ` +
'form was never worked through'];
}
async function get(key, path, params = {}) {
const url = new URL(API + path);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
const res = await fetch(url, { headers: { Authorization: `Bearer ${key}` } });
if (res.status === 401) {
throw new Error('401 from Stripe: the key is wrong, or is for the other mode');
}
if (!res.ok) throw new Error(`${res.status} from ${url.pathname}`);
return res.json();
}
export async function* accounts(key, cap = 5000) {
let seen = 0;
const params = { limit: 100 };
for (;;) {
const page = await get(key, '/accounts', params);
const data = page.data ?? [];
for (const acct of data) {
yield acct;
seen += 1;
if (seen >= cap) return;
}
if (data.length === 0 || !page.has_more) return;
params.starting_after = data[data.length - 1].id;
}
}
async function main() {
const key = process.env.STRIPE_API_KEY;
if (!key) {
console.error('set STRIPE_API_KEY (use a restricted, read-only key)');
process.exitCode = 2;
return;
}
const now = Date.now() / 1000;
const counts = {};
let scanned = 0;
for await (const acct of accounts(key)) {
scanned += 1;
const age = acct.created === undefined || acct.created === null
? null : (now - acct.created) / 86400;
const [state, detail] = classify(acct, age);
counts[state] = (counts[state] ?? 0) + 1;
if (state === 'submitted' || state === 'in-flight') continue;
console.warn(`${acct.id ?? 'acct_?'} ${state.padEnd(15)} ${detail}`);
}
const late = counts['abandoned-late'] ?? 0;
const cold = counts['abandoned-cold'] ?? 0;
console.log(`${scanned} account(s): ${counts['in-flight'] ?? 0} in flight, ` +
`${late + cold} abandoned`);
if (late || cold) {
console.warn(' repair, in this order:');
console.warn(' 1. make refresh_url mint a new link and 302 to it. Stripe ' +
'sends the user there precisely when the old one is spent:');
console.warn(` POST ${API}/account_links account, refresh_url, return_url, ` +
'type=account_onboarding');
console.warn(' 2. never email or SMS the returned url. It is single use, and ' +
'a client fetching a preview of it uses it.');
console.warn(` 3. re-onboard the ${late + cold} account(s) above with fresh ` +
`links, starting with the ${late} that nearly finished.`);
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 threshold is the whole check, so the threshold is what the tests pin. A day-old unsubmitted account is a normal signup and reporting it trains everyone to ignore the report; a forty-day-old one with two fields left is a person who tried and lost the session. The tests also cover the odd case where nothing is due at all, because that one is a different bug wearing the same symptom.
from stripe_onboarding_stalled import classify
def acct(submitted=False, due=("individual.dob.day", "individual.address.line1",
"business_profile.url", "external_account")):
return {"details_submitted": submitted,
"requirements": {"currently_due": list(due)}}
def test_a_submitted_account_is_finished():
assert classify(acct(submitted=True), 400.0)[0] == "submitted"
def test_a_fresh_signup_is_not_chased():
state, detail = classify(acct(), 1.5)
assert state == "in-flight"
assert "do not chase it yet" in detail
def test_seven_days_is_the_boundary():
assert classify(acct(), 6.9)[0] == "in-flight"
assert classify(acct(), 7.0)[0] == "abandoned-cold"
def test_a_short_remaining_list_means_they_nearly_finished():
state, detail = classify(acct(due=("external_account",)), 40.0)
assert state == "abandoned-late"
assert "external_account" in detail
def test_unsubmitted_with_nothing_due_is_a_different_bug():
# No capability requested, so Stripe is not asking for anything and no
# onboarding link will collect anything either.
state, detail = classify(acct(due=()), 40.0)
assert state == "unknown"
assert "no capability has been requested" in detail
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { classify } from './stripe-onboarding-stalled.mjs';
const acct = (submitted = false, due = [
'individual.dob.day', 'individual.address.line1',
'business_profile.url', 'external_account',
]) => ({ details_submitted: submitted, requirements: { currently_due: due } });
test('a submitted account is finished', () => {
assert.equal(classify(acct(true), 400.0)[0], 'submitted');
});
test('a fresh signup is not chased', () => {
const [state, detail] = classify(acct(), 1.5);
assert.equal(state, 'in-flight');
assert.match(detail, /do not chase it yet/);
});
test('seven days is the boundary', () => {
assert.equal(classify(acct(), 6.9)[0], 'in-flight');
assert.equal(classify(acct(), 7.0)[0], 'abandoned-cold');
});
test('a short remaining list means they nearly finished', () => {
const [state, detail] = classify(acct(false, ['external_account']), 40.0);
assert.equal(state, 'abandoned-late');
assert.match(detail, /external_account/);
});
test('unsubmitted with nothing due is a different bug', () => {
const [state, detail] = classify(acct(false, []), 40.0);
assert.equal(state, 'unknown');
assert.match(detail, /no capability has been requested/);
});
FAQ
How long is an account link valid?
A few minutes, and it can be used once. That is short by design: the url authenticates the session, so a link that lived for a day would be a credential sitting in an inbox. The consequence is that anything which delays or duplicates the click breaks it.
Why did the link stop working before the user clicked it?
Something fetched it first. Email and chat clients request links to build previews and to scan them, and that request consumes the single use. This is why the link should be handed to an already authenticated user inside your own app rather than sent as a message.
What should refresh_url do?
Create a new account link with the same parameters and redirect to it. Stripe sends the user there exactly when the old link is no longer usable, so it is the recovery path, not an error page. Serving a static message there turns every expiry into a lost account.
Why not just alert on charges_enabled instead?
Because these accounts have not broken. An account that never opened correctly reports charges_enabled false with no disabled_reason, and any monitor worth having filters it out to avoid paging on signups. This cohort needs its own list, keyed on details_submitted and age.
What does an unsubmitted account with nothing currently due mean?
That no capability has been requested for it, so Stripe has nothing to ask for. An onboarding link will collect nothing because there is nothing outstanding. Request the capabilities the account needs first, then send a link.
Related field notes
- A connected account sits with charges_enabled false
- requirements.errors explains the rejected document
- A connected account has no external account to pay out to
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.
- Hosted onboarding — Stripe Docs
- The account link object — Stripe API reference
- The account object — Stripe API reference
- Handling verification with the API — Stripe Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.