Skip to content

Diagnostic Stripe

a connected account has no external account to pay out to

A seller has been taking payments for five months. Their Stripe balance is a five-figure number and it has only ever gone up. There are no failed payouts to investigate, no errors in any log, and no alert anywhere, for the simple reason that nothing has ever been attempted: the account has no bank account attached, so automatic payouts have nowhere to send the money.

Read-only key Python and Node.js Tests included
A computer screen with a bunch of code
Photo by Jiri Navratil on Unsplash
The short answer

For each connected account call GET /v1/accounts/{id}/external_accounts?limit=100 and check two things: whether data is empty at all, and whether any entry has default_for_currency: true for the account's default_currency. Both cases stop payouts, and the second one is the harder to spot because the account visibly has a bank account attached.

Corroborate against the account object: requirements.currently_due containing the literal string external_account confirms Stripe agrees it is missing. Note that details_submitted can be true throughout — onboarding completed, it just never collected this.

The problem in plain words

Every monitoring instinct is built around things failing. This fails by never happening. There is no payout object to inspect, no failure_code to group by, no payout.failed event to subscribe to. The absence is the symptom, and absences do not raise alerts unless something is specifically counting them.

What makes it survive for months is that the seller's side looks completely healthy. Payments succeed, the Dashboard shows a growing balance, and the platform's own reporting shows the seller as one of the good ones. The discovery event is a seller asking where their money is, at which point the balance is large enough that the conversation is uncomfortable and the delay is measured in months rather than days.

Collectionturned offplatform willgather detailsOnboardingcompletesdetails_submittedtrueNo destinationattachedthe other halfnever builtNo payoutattemptednothing fails,nothing logsBalance climbsfound months later
There is no payout object, no failure code and no event, because nothing was ever attempted.

Why it happens

Onboarding can legitimately be told to skip it. external_account_collection gets disabled when a platform intends to collect bank details through its own interface. That is a supported configuration. The failure is the second half never being built, or being built and quietly failing, at which point accounts finish onboarding with details_submitted: true and no destination.

A missing default is not the same as a missing account. An account can have a bank account attached and still not pay out, if none is marked default_for_currency for the currency the balance is in. This is common when a seller's balance ends up in a currency their attached account does not serve, and it looks completely fine in any check that only counts rows.

Requirements say so, quietly. external_account appears in currently_due, but it sits in the same array as a dozen other verification fields, and a platform that treats requirements as one undifferentiated to-do list never notices that this particular string means the money cannot move at all.

Nothing else in the system disagrees. Charges work, capabilities are active, the account is not disabled. Every health check the platform runs passes, because the account genuinely is healthy in every respect except the one nobody is measuring.

The fix, as a flow

The script asks each account for its destinations and checks the default for the account's own currency, because a bank account attached in the wrong currency pays out exactly as often as none at all.

GET external_accountsplus default_currency andcurrently_dueDefault set for the currencypayouts can runAttached, no defaultflag one for the currencyAttached, wrong currencyattach one that matchesNothing attachedcollect details or re-enable
A row count passes an account whose only destination is in a currency the balance is not in.

How to fix it

List the external accounts for every connected account

One GET per account. Zero rows is the obvious case and the fastest to confirm. Do it for every account rather than for the ones that complained, because by construction the affected sellers do not know anything is wrong yet.

Check the default for the account's own default_currency

Read default_currency from the account object, then look for an external account with a matching currency and default_for_currency: true. An account with three attached destinations and no default for the currency the balance is actually in pays out exactly as often as one with none.

Cross-check requirements.currently_due for the literal string

external_account in currently_due is Stripe's own confirmation. When the list is empty and there is still no destination, the platform disabled collection during onboarding and Stripe is not going to ask for it on your behalf.

Look at the balance next to the finding

An account with no destination and a zero balance is a configuration bug to fix this week. The same account with months of accumulated funds is a conversation with a seller that gets worse every day it waits. The API call is the same; the priority is not, and only one of the two belongs at the top of the queue.

Decide who collects the details, then make that path exist

Either re-enable external account collection so Stripe's onboarding asks, or send an account link of type account_update and let the seller add it there. What must not happen is the current state, where onboarding believes the platform will collect it and the platform believes onboarding did.

How to check it worked

Re-run the script. Every account should report a default destination for its own currency, and the next payout cycle should produce actual payout objects for accounts that previously had none.

python3 stripe_missing_external_account.py
# 412 account(s): 0 with no destination, 0 with no default for their currency

The full code

Two GETs per account and no writes — a restricted key with read access to Connected accounts is enough. The classifier takes the list of external accounts, the account's default currency and its currently_due array, because no destination at all and a destination that cannot receive this currency produce the same silence and need different repairs.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 21 Stripe fixes, free and open source.
stripe_missing_external_account.py
"""Find connected accounts whose balance cannot move because nothing is attached.

Read only. Two GETs per account 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 requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("stripe_missing_external_account")

API = "https://api.stripe.com/v1"

# A destination in one of these states is attached but cannot be paid to. It is a
# different problem from having none, and it needs fresh details rather than a
# form the seller has already filled in.
UNUSABLE = ("errored", "verification_failed",
            "tokenized_account_number_deactivated")


def classify(external_accounts, default_currency, currently_due=()):
    """Decide whether this account can be paid out. Pure, so it can be tested.

    `external_accounts` is the `data` array from /v1/accounts/{id}/external_accounts.
    Returns (state, detail).
    """
    rows = list(external_accounts or [])
    due = [f for f in (currently_due or []) if f]
    asked = "external_account" in due
    currency = (default_currency or "").lower()

    if not rows:
        if asked:
            return ("none",
                    "no external account, and external_account is in currently_due: "
                    "Stripe is asking and nobody is collecting it")
        return ("none-unrequested",
                "no external account and Stripe is not asking for one: external "
                "account collection was turned off during onboarding")

    unusable = [r for r in rows if r.get("status") in UNUSABLE]
    matching = [r for r in rows
                if (r.get("currency") or "").lower() == currency]
    default = [r for r in matching if r.get("default_for_currency")]

    if default:
        bad = [r for r in default if r.get("status") in UNUSABLE]
        if bad:
            return ("unusable",
                    "the default destination for %s has status %s: scheduled payouts "
                    "to it have stopped" % (currency or "?", bad[0].get("status")))
        return ("attached",
                "%d destination(s), default set for %s" % (len(rows), currency or "?"))

    if matching:
        return ("no-default",
                "%d destination(s) in %s but none marked default_for_currency: "
                "payouts have nowhere to go" % (len(matching), currency or "?"))

    if unusable:
        return ("unusable",
                "%d destination(s), all in a failed state (%s)"
                % (len(rows), unusable[0].get("status")))

    return ("wrong-currency",
            "%d destination(s), none of them in %s, so the balance cannot be paid out"
            % (len(rows), currency or "the account default currency"))


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):
    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=1000,
                    help="stop after this many accounts; each one costs a GET")
    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
        acct_id = acct.get("id", "acct_?")
        reqs = acct.get("requirements") or {}
        page = get(s, "/accounts/%s/external_accounts" % acct_id, limit=100)
        state, detail = classify(page.get("data"), acct.get("default_currency"),
                                 reqs.get("currently_due"))
        counts[state] = counts.get(state, 0) + 1
        if state == "attached":
            continue
        log.warning("%s  %-17s payouts_enabled=%s  %s",
                    acct_id, state, acct.get("payouts_enabled"), detail)

    missing = counts.get("none", 0) + counts.get("none-unrequested", 0)
    no_default = counts.get("no-default", 0) + counts.get("wrong-currency", 0)

    log.info("%d account(s): %d with no destination, %d with no default for their "
             "currency", scanned, missing, no_default)

    if counts.get("none"):
        log.warning("  repair: send the seller an account link of type account_update "
                    "so they attach a bank account themselves")
    if counts.get("none-unrequested"):
        log.warning("  repair: Dashboard, Settings, Connect, Payouts: re-enable "
                    "external account collection, or finish the flow that was going "
                    "to collect it in your own interface")
    if no_default:
        log.warning("  repair: mark one destination default_for_currency for the "
                    "account default_currency, or attach one in that currency")
    if counts.get("unusable"):
        log.warning("  repair: attach fresh details. Editing the numbers on an "
                    "errored destination does not clear the status.")
    if missing or no_default or counts.get("unusable"):
        log.warning("  check: the balance on these accounts says how old this is:")
        log.warning("  GET %s/balance  with the Stripe-Account header", API)
    return 1 if (missing or no_default or counts.get("unusable")) else 0


if __name__ == "__main__":
    sys.exit(main())
stripe-missing-external-account.mjs
/**
 * Find connected accounts whose balance cannot move because nothing is attached.
 *
 * Read only. Two GETs per account 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';

// A destination in one of these states is attached but cannot be paid to. It is a
// different problem from having none, and it needs fresh details rather than a
// form the seller has already filled in.
const UNUSABLE = ['errored', 'verification_failed',
  'tokenized_account_number_deactivated'];

/**
 * Decide whether this account can be paid out. Pure, so it can be tested.
 * `externalAccounts` is the data array from /v1/accounts/{id}/external_accounts.
 * Returns [state, detail].
 */
export function classify(externalAccounts, defaultCurrency, currentlyDue = []) {
  const rows = externalAccounts ?? [];
  const due = (currentlyDue ?? []).filter(Boolean);
  const asked = due.includes('external_account');
  const currency = (defaultCurrency ?? '').toLowerCase();

  if (rows.length === 0) {
    if (asked) {
      return ['none',
        'no external account, and external_account is in currently_due: Stripe is ' +
        'asking and nobody is collecting it'];
    }
    return ['none-unrequested',
      'no external account and Stripe is not asking for one: external account ' +
      'collection was turned off during onboarding'];
  }

  const unusable = rows.filter((r) => UNUSABLE.includes(r.status));
  const matching = rows.filter((r) => (r.currency ?? '').toLowerCase() === currency);
  const def = matching.filter((r) => r.default_for_currency);

  if (def.length) {
    const bad = def.filter((r) => UNUSABLE.includes(r.status));
    if (bad.length) {
      return ['unusable',
        `the default destination for ${currency || '?'} has status ${bad[0].status}: ` +
        'scheduled payouts to it have stopped'];
    }
    return ['attached', `${rows.length} destination(s), default set for ${currency || '?'}`];
  }

  if (matching.length) {
    return ['no-default',
      `${matching.length} destination(s) in ${currency || '?'} but none marked ` +
      'default_for_currency: payouts have nowhere to go'];
  }

  if (unusable.length) {
    return ['unusable',
      `${rows.length} destination(s), all in a failed state (${unusable[0].status})`];
  }

  return ['wrong-currency',
    `${rows.length} destination(s), none of them in ` +
    `${currency || 'the account default currency'}, so the balance cannot be paid out`];
}

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 = 1000) {
  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 counts = new Map();
  let scanned = 0;

  for await (const acct of accounts(key)) {
    scanned += 1;
    const id = acct.id ?? 'acct_?';
    const page = await get(key, `/accounts/${id}/external_accounts`, { limit: 100 });
    const [state, detail] = classify(page.data, acct.default_currency,
      acct.requirements?.currently_due);
    counts.set(state, (counts.get(state) ?? 0) + 1);
    if (state === 'attached') continue;
    console.warn(`${id}  ${state.padEnd(17)} ` +
                 `payouts_enabled=${acct.payouts_enabled}  ${detail}`);
  }

  const missing = (counts.get('none') ?? 0) + (counts.get('none-unrequested') ?? 0);
  const noDefault = (counts.get('no-default') ?? 0) + (counts.get('wrong-currency') ?? 0);

  console.log(`${scanned} account(s): ${missing} with no destination, ${noDefault} ` +
              'with no default for their currency');

  if (counts.get('none')) {
    console.warn('  repair: send the seller an account link of type account_update ' +
                 'so they attach a bank account themselves');
  }
  if (counts.get('none-unrequested')) {
    console.warn('  repair: Dashboard, Settings, Connect, Payouts: re-enable external ' +
                 'account collection, or finish the flow that was going to collect it ' +
                 'in your own interface');
  }
  if (noDefault) {
    console.warn('  repair: mark one destination default_for_currency for the account ' +
                 'default_currency, or attach one in that currency');
  }
  if (counts.get('unusable')) {
    console.warn('  repair: attach fresh details. Editing the numbers on an errored ' +
                 'destination does not clear the status.');
  }
  if (missing || noDefault || counts.get('unusable')) {
    console.warn('  check: the balance on these accounts says how old this is:');
    console.warn(`  GET ${API}/balance  with the Stripe-Account header`);
    process.exitCode = 1;
  }
}

// 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

Two of these tests describe accounts that visibly have a bank account attached and still cannot be paid: one where nothing is marked default for the currency, one where every destination is in a currency the balance is not in. A row count alone passes both, which is why the classifier takes the currency rather than just the array.

test_stripe_missing_external_account.py
from stripe_missing_external_account import classify


def test_a_default_destination_is_the_healthy_case():
    state, detail = classify(
        [{"currency": "usd", "default_for_currency": True, "status": "verified"}],
        "usd")
    assert state == "attached"
    assert "default set for usd" in detail


def test_nothing_attached_separates_asked_from_never_asked():
    # Stripe asking and nobody collecting is a broken handoff. Stripe not asking
    # means the platform turned collection off and never built the other half.
    state, _ = classify([], "usd", ["external_account", "company.tax_id"])
    assert state == "none"
    assert classify([], "usd", ["company.tax_id"])[0] == "none-unrequested"


def test_attached_but_no_default_still_cannot_pay_out():
    state, detail = classify(
        [{"currency": "usd", "default_for_currency": False, "status": "verified"}],
        "usd")
    assert state == "no-default"
    assert "nowhere to go" in detail


def test_a_destination_in_the_wrong_currency_is_not_a_destination():
    state, detail = classify(
        [{"currency": "eur", "default_for_currency": True, "status": "verified"}],
        "usd")
    assert state == "wrong-currency"
    assert "usd" in detail


def test_case_does_not_decide_the_answer():
    # Stripe returns lowercase currencies, but an account object copied through a
    # cache or a spreadsheet may not.
    assert classify(
        [{"currency": "USD", "default_for_currency": True, "status": "verified"}],
        "USD")[0] == "attached"


def test_an_errored_default_is_reported_as_frozen_not_healthy():
    state, detail = classify(
        [{"currency": "usd", "default_for_currency": True, "status": "errored"}],
        "usd")
    assert state == "unusable"
    assert "have stopped" in detail
stripe-missing-external-account.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { classify } from './stripe-missing-external-account.mjs';

test('a default destination is the healthy case', () => {
  const [state, detail] = classify(
    [{ currency: 'usd', default_for_currency: true, status: 'verified' }], 'usd');
  assert.equal(state, 'attached');
  assert.match(detail, /default set for usd/);
});

test('nothing attached separates asked from never asked', () => {
  // Stripe asking and nobody collecting is a broken handoff. Stripe not asking
  // means the platform turned collection off and never built the other half.
  assert.equal(classify([], 'usd', ['external_account', 'company.tax_id'])[0], 'none');
  assert.equal(classify([], 'usd', ['company.tax_id'])[0], 'none-unrequested');
});

test('attached but no default still cannot pay out', () => {
  const [state, detail] = classify(
    [{ currency: 'usd', default_for_currency: false, status: 'verified' }], 'usd');
  assert.equal(state, 'no-default');
  assert.match(detail, /nowhere to go/);
});

test('a destination in the wrong currency is not a destination', () => {
  const [state, detail] = classify(
    [{ currency: 'eur', default_for_currency: true, status: 'verified' }], 'usd');
  assert.equal(state, 'wrong-currency');
  assert.match(detail, /usd/);
});

test('case does not decide the answer', () => {
  // Stripe returns lowercase currencies, but an account object copied through a
  // cache or a spreadsheet may not.
  assert.equal(classify(
    [{ currency: 'USD', default_for_currency: true, status: 'verified' }], 'USD')[0],
  'attached');
});

test('an errored default is reported as frozen not healthy', () => {
  const [state, detail] = classify(
    [{ currency: 'usd', default_for_currency: true, status: 'errored' }], 'usd');
  assert.equal(state, 'unusable');
  assert.match(detail, /have stopped/);
});

FAQ

How can an account finish onboarding with no bank account?

Because external account collection can be turned off on the platform, which is the supported way to say the platform will gather bank details itself. Stripe then stops asking, the account reaches details_submitted true, and the only thing standing between the seller and their money is a flow on your side that may never have been finished.

Why are there no failed payouts to look at?

Because none were attempted. Automatic payouts need a destination; with none, Stripe does not create a payout object at all. Everything downstream that watches for failures is watching a list that stays empty, which is indistinguishable from everything working.

What does default_for_currency actually control?

Which destination receives payouts for a given currency. An account can hold several external accounts, and only the one flagged default for the balance's currency gets used. A seller with a bank account attached in the wrong currency, or with none flagged, is in exactly the same position as a seller with none at all.

How do I know how long this has been going on?

Read the account's balance with the Stripe-Account header. The size of the available balance is a direct proxy for how many payout cycles have been skipped, and it is the number that decides whether this is a quiet bug fix or a call with a seller.

Can this script attach the bank account for me?

No, deliberately. Attaching an external account moves where money goes, which is the single most dangerous write in a payments integration, and it is not something an unattended monitor holding a long-lived key should be able to do. The script names the accounts and prints the two ways to collect the details.

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.