Skip to content

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.

Read-only key Python and Node.js Tests included
A cardboard box sits on a textured surface.
Photo by Anastasiya Doicheva on Unsplash
The short answer

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.

Account linkcreatedsingle use,minutes longLink emailedclient fetches apreviewUser clickslink already spentrefresh_url isstaticerror page, no newlinkAccount neveropensdetails_submittedfalse
The link is valid for minutes and can be used once, so the client that previews it spends it before the human clicks.

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.

details_submitted falseplus created, plus currently_dueunder 7 days oldstill signing up, do not chaseaged, few fields leftfresh link and an emailaged, most fields leftnever worked throughaged, nothing dueno capability requested
The length of currently_due is triage order, not a status. A short list is somebody who nearly finished.

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.

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_onboarding_stalled.py
"""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())
stripe-onboarding-stalled.mjs
/**
 * 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.

test_stripe_onboarding_stalled.py
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
stripe-onboarding-stalled.test.mjs
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

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.