Skip to content

Diagnostic Stripe

payouts fail with account_closed and nobody is watching

The ledger says the payout was paid. The recipient says no money arrived. Both are true: the payout reached paid, the bank rejected the credit four days later, Stripe moved it to failed and returned the funds to your balance. Nothing in your system recorded the second half of that story, because nothing was reading the object after it went green.

Read-only key Python and Node.js Tests included
Red padlock on black computer keyboard
Photo by FlyD on Unsplash
The short answer

Query GET /v1/payouts?status=failed&limit=100&created[gte]=<90 days ago> on the platform, and again per connected account with the Stripe-Account header. Group the results by failure_code, because that enum is what decides the repair.

account_closed, no_account and the invalid_account_number family need new bank details on the destination. debit_not_authorized and incorrect_account_type need the account holder to talk to their bank. insufficient_funds needs a top-up, not a bank change. Retrying without reading the code repeats whichever of these you happened to hit.

The problem in plain words

A payout that reads paid is not finished. The status reflects Stripe having sent the credit, and the receiving bank can reject it for up to five business days afterwards, at which point the payout flips to failed and the money is returned to your Stripe balance along with a failure_balance_transaction.

Two things go wrong at once when nobody is watching this. The recipient is unpaid and does not know why, so your support queue gets a message that sounds like an accusation of theft. And your reconciliation is now off in a way that is hard to spot: the original payout debited the balance, the reversal credited it back, and a report that sums payouts without accounting for reversals shows money going out that never left.

Payout createdpendingSent to thebankin_transit, thenpaidBank rejects itup to 5 businessdays laterStatus flips tofailedfunds return tobalanceDestinationfrozenlater payoutsnever runno attempts, so no new failures
The funds return to your balance days later, so a report that sums payouts without the reversals counts money that never left.

Why it happens

The terminal-looking state is not terminal. pending to in_transit to paid looks like a completed lifecycle, and every instinct says to stop reading an object once it reaches the state you were waiting for. The paid to failed transition arrives days later, on the bank's schedule, with nothing to prompt a re-read.

The failure codes need different people. They look like one category — "the payout failed" — and they are at least four. A closed account needs new details from the seller. debit_not_authorized needs the seller to authorise debits with their own bank, and no amount of re-entering the same account number will fix it. insufficient_funds is about your balance, not theirs. Treating them alike produces a support script that is wrong three times out of four.

The first failure stops the rest. A payout failure sets the external account's status to errored, and Stripe stops sending scheduled payouts to that destination. So the count of failed payouts goes up once and then stays flat, which reads like a resolved blip. It is the opposite: the number stopped growing because nothing is being attempted any more.

The event exists and is usually unsubscribed. payout.failed is a real event that would have told you on the day. Platforms that never subscribed to it find out from the recipient, which is always later and always more expensive.

The fix, as a flow

The script groups failed payouts by failure_code, because that enum is the only thing that says whether the fix belongs to the seller, their bank, or your own balance.

GET /v1/payoutsstatus=failed, grouped byfailure_codeaccount_closed, no_accountattach fresh bank detailsdebit_not_authorizedholder authorises with the bankinsufficient_fundsyour balance, top it upcould_not_processtransient, one retry
account_closed and debit_not_authorized both leave a recipient unpaid and need opposite actions.

How to fix it

Query failed payouts over a window wide enough to catch the pattern

Ninety days. A shorter window can show one failure and hide the fact that it is the same destination failing every cycle. Run it on the platform account and then once per connected account with the Stripe-Account header, since a platform's own payouts and its sellers' payouts are separate lists.

Group by failure_code before looking at anything else

The distribution is the diagnosis. Twenty failures across twenty codes is bad luck; twenty failures all reading debit_not_authorized is an onboarding flow that never told sellers to authorise debits. Read failure_message for the human sentence, but branch on the code.

Confirm the money came back

failure_balance_transaction is non-null on a failed payout and points at the balance transaction that returned the funds. If your reconciliation does not know about that object, every failed payout is a double count: once out, once back, neither matched.

Check whether the destination is now frozen

Read the external account's status. errored means scheduled payouts to it have stopped, which explains why the failures are not accumulating and why the balance is. Attaching fresh details is what clears it; editing the numbers on the existing object generally does not.

Subscribe to payout.failed so the next one arrives as an event

Check GET /v1/webhook_endpoints for payout.failed in enabled_events. A daily script is a good backstop and a bad primary: it turns a five-day-old failure into a four-day-old one, where the event would have told you the same day.

How to check it worked

Re-run the script after fresh details are attached and the next payout cycle has run. The failed count over the window should stop growing, and the destination should no longer report a frozen status.

python3 stripe_failed_payouts.py --days 90
# 0 failed payout(s) in the last 90 days

The full code

One paginated GET against /v1/payouts, optionally repeated per connected account — a restricted key with read access to Payouts is enough. The classifier maps failure_code to the person who can act, because the enum has more than a dozen members and the useful question is not which code it is but whether this needs the seller, their bank, or your balance.

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_failed_payouts.py
"""Group failed Stripe payouts by failure_code and say what each one needs.

Read only. One paginated GET per account and no writes: give this a RESTRICTED
key with read access to 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_failed_payouts")

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

# The destination is wrong or gone. Nothing but new bank details fixes these.
NEW_DETAILS = (
    "account_closed", "no_account", "invalid_account_number",
    "invalid_account_number_length", "incorrect_account_holder_name",
    "incorrect_account_holder_address", "incorrect_account_holder_tax_id",
    "unsupported_card",
)
# The account exists but its holder has to authorise something with their bank.
BANK_AUTHORISATION = (
    "debit_not_authorized", "incorrect_account_type", "declined",
    "bank_account_restricted", "account_frozen",
)
# Your balance, not their bank.
FUNDING = ("insufficient_funds",)
# Transient. Worth one retry before anyone is contacted.
TRANSIENT = ("could_not_process", "bank_ownership_changed")
# A configuration mismatch on the destination rather than a bad number.
CONFIGURATION = ("invalid_currency", "unsupported_currency")


def classify(payout):
    """Sort one payout by what its failure needs. Pure, so the table is testable.

    Takes a /v1/payouts object. Returns (state, detail). The states name the
    person who can act, which is the only grouping that changes what you do next.
    """
    status = payout.get("status")
    if status in ("paid", "in_transit", "pending"):
        return ("open", "status %s: not a failure, and not final either" % status)
    if status == "canceled":
        return ("canceled", "cancelled before it left, nothing was rejected")
    if status != "failed":
        return ("unknown", "unrecognised status %r" % (status,))

    code = payout.get("failure_code") or "unknown"
    message = payout.get("failure_message") or "no failure_message"
    returned = payout.get("failure_balance_transaction") is not None
    tail = "" if returned else " (no failure_balance_transaction: check the balance)"

    if code in NEW_DETAILS:
        return ("new-details",
                "%s: the destination is gone or wrong. Attach a fresh external "
                "account; re-entering the same number fails identically.%s"
                % (code, tail))
    if code in BANK_AUTHORISATION:
        return ("bank-authorisation",
                "%s: the account exists, its holder has to settle this with their "
                "bank. New details will not help.%s" % (code, tail))
    if code in FUNDING:
        return ("funding",
                "%s: your balance could not cover it. This is your side, not "
                "theirs.%s" % (code, tail))
    if code in TRANSIENT:
        return ("transient",
                "%s: worth one retry before anyone is contacted.%s" % (code, tail))
    if code in CONFIGURATION:
        return ("configuration",
                "%s: the destination cannot receive this currency.%s" % (code, tail))
    return ("unclassified",
            "failure_code %s: %s%s" % (code, message, tail))


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 failed_payouts(session, since, cap, account=None):
    """Yield failed payouts created since `since`, paginating to the cap."""
    seen = 0
    params = {"limit": 100, "status": "failed", "created[gte]": since}
    while True:
        page = get(session, "/payouts", account=account, **params)
        data = page.get("data", [])
        for po in data:
            yield po
            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("--days", type=int, default=90,
                    help="how far back to look (default 90)")
    ap.add_argument("--account", action="append", default=[],
                    help="also scan this connected account; repeatable")
    ap.add_argument("--max-payouts", type=int, default=2000,
                    help="stop paginating after this many failed payouts")
    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})

    since = int(time.time()) - args.days * 86400
    counts = {}
    by_code = {}
    returned_minor = 0
    total = 0

    for account in [None] + list(args.account):
        for po in failed_payouts(s, since, args.max_payouts, account):
            total += 1
            state, detail = classify(po)
            counts[state] = counts.get(state, 0) + 1
            code = po.get("failure_code") or "unknown"
            by_code[code] = by_code.get(code, 0) + 1
            returned_minor += int(po.get("amount") or 0)
            log.warning("%s  %-18s dest=%s  %s", po.get("id", "po_?"), state,
                        po.get("destination") or "?", detail)

    log.info("%d failed payout(s) in the last %d days", total, args.days)
    for code, n in sorted(by_code.items(), key=lambda kv: -kv[1]):
        log.warning("  %-34s %d", code, n)

    if total:
        log.warning("  %d in minor units came back to the balance: reconcile against "
                    "failure_balance_transaction or it is counted twice", returned_minor)
    if counts.get("new-details"):
        log.warning("  repair: attach a new external account and make it the default "
                    "for the currency. Editing the existing one rarely clears it.")
    if counts.get("bank-authorisation"):
        log.warning("  repair: the account holder authorises credits and debits with "
                    "their own bank. No API call substitutes for that.")
    if counts.get("funding"):
        log.warning("  repair: fund the balance before the next payout cycle")
    if total:
        log.warning("  check: the destination status is probably errored, which stops "
                    "scheduled payouts and is why the failures are not accumulating:")
        log.warning("  GET %s/accounts/{id}/external_accounts", API)
        log.warning("  check: payout.failed in enabled_events, or this stays a "
                    "five day old surprise:")
        log.warning("  GET %s/webhook_endpoints", API)
    return 1 if total else 0


if __name__ == "__main__":
    sys.exit(main())
stripe-failed-payouts.mjs
/**
 * Group failed Stripe payouts by failure_code and say what each one needs.
 *
 * Read only. One paginated GET per account and no writes: give this a RESTRICTED
 * key with read access to Payouts. The repair is printed, never performed.
 */
const API = 'https://api.stripe.com/v1';

// The destination is wrong or gone. Nothing but new bank details fixes these.
const NEW_DETAILS = [
  'account_closed', 'no_account', 'invalid_account_number',
  'invalid_account_number_length', 'incorrect_account_holder_name',
  'incorrect_account_holder_address', 'incorrect_account_holder_tax_id',
  'unsupported_card',
];
// The account exists but its holder has to authorise something with their bank.
const BANK_AUTHORISATION = [
  'debit_not_authorized', 'incorrect_account_type', 'declined',
  'bank_account_restricted', 'account_frozen',
];
// Your balance, not their bank.
const FUNDING = ['insufficient_funds'];
// Transient. Worth one retry before anyone is contacted.
const TRANSIENT = ['could_not_process', 'bank_ownership_changed'];
// A configuration mismatch on the destination rather than a bad number.
const CONFIGURATION = ['invalid_currency', 'unsupported_currency'];

/**
 * Sort one payout by what its failure needs. Pure, so the table is testable.
 * The states name the person who can act, which is the only grouping that
 * changes what you do next. Returns [state, detail].
 */
export function classify(payout) {
  const status = payout.status;
  if (['paid', 'in_transit', 'pending'].includes(status)) {
    return ['open', `status ${status}: not a failure, and not final either`];
  }
  if (status === 'canceled') {
    return ['canceled', 'cancelled before it left, nothing was rejected'];
  }
  if (status !== 'failed') {
    return ['unknown', `unrecognised status ${JSON.stringify(status)}`];
  }

  const code = payout.failure_code ?? 'unknown';
  const message = payout.failure_message ?? 'no failure_message';
  const returned = payout.failure_balance_transaction != null;
  const tail = returned ? '' : ' (no failure_balance_transaction: check the balance)';

  if (NEW_DETAILS.includes(code)) {
    return ['new-details',
      `${code}: the destination is gone or wrong. Attach a fresh external ` +
      `account; re-entering the same number fails identically.${tail}`];
  }
  if (BANK_AUTHORISATION.includes(code)) {
    return ['bank-authorisation',
      `${code}: the account exists, its holder has to settle this with their ` +
      `bank. New details will not help.${tail}`];
  }
  if (FUNDING.includes(code)) {
    return ['funding',
      `${code}: your balance could not cover it. This is your side, not theirs.${tail}`];
  }
  if (TRANSIENT.includes(code)) {
    return ['transient', `${code}: worth one retry before anyone is contacted.${tail}`];
  }
  if (CONFIGURATION.includes(code)) {
    return ['configuration',
      `${code}: the destination cannot receive this currency.${tail}`];
  }
  return ['unclassified', `failure_code ${code}: ${message}${tail}`];
}

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* failedPayouts(key, since, cap = 2000, account = null) {
  let seen = 0;
  const params = { account, limit: 100, status: 'failed', 'created[gte]': since };
  for (;;) {
    const page = await get(key, '/payouts', params);
    const data = page.data ?? [];
    for (const po of data) {
      yield po;
      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 days = Number(process.env.DAYS ?? 90);
  const extra = (process.env.ACCOUNTS ?? '').split(',').filter(Boolean);
  const since = Math.floor(Date.now() / 1000) - days * 86400;

  const counts = new Map();
  const byCode = new Map();
  let returnedMinor = 0;
  let total = 0;

  for (const account of [null, ...extra]) {
    for await (const po of failedPayouts(key, since, 2000, account)) {
      total += 1;
      const [state, detail] = classify(po);
      counts.set(state, (counts.get(state) ?? 0) + 1);
      const code = po.failure_code ?? 'unknown';
      byCode.set(code, (byCode.get(code) ?? 0) + 1);
      returnedMinor += po.amount ?? 0;
      console.warn(`${po.id ?? 'po_?'}  ${state.padEnd(18)} ` +
                   `dest=${po.destination ?? '?'}  ${detail}`);
    }
  }

  console.log(`${total} failed payout(s) in the last ${days} days`);
  for (const [code, n] of [...byCode].sort((a, b) => b[1] - a[1])) {
    console.warn(`  ${code.padEnd(34)} ${n}`);
  }

  if (total) {
    console.warn(`  ${returnedMinor} in minor units came back to the balance: ` +
                 'reconcile against failure_balance_transaction or it is counted twice');
  }
  if (counts.get('new-details')) {
    console.warn('  repair: attach a new external account and make it the default ' +
                 'for the currency. Editing the existing one rarely clears it.');
  }
  if (counts.get('bank-authorisation')) {
    console.warn('  repair: the account holder authorises credits and debits with ' +
                 'their own bank. No API call substitutes for that.');
  }
  if (counts.get('funding')) {
    console.warn('  repair: fund the balance before the next payout cycle');
  }
  if (total) {
    console.warn('  check: the destination status is probably errored, which stops ' +
                 'scheduled payouts and is why the failures are not accumulating:');
    console.warn(`  GET ${API}/accounts/{id}/external_accounts`);
    console.warn('  check: payout.failed in enabled_events, or this stays a five ' +
                 'day old surprise:');
    console.warn(`  GET ${API}/webhook_endpoints`);
    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

The tests exist to keep two pairs apart. account_closed and debit_not_authorized both mean an unpaid recipient and need opposite actions, and a paid payout must never classify as final, because the whole failure mode here is a status that changes after everyone stopped looking.

test_stripe_failed_payouts.py
from stripe_failed_payouts import classify


def test_paid_is_not_treated_as_final():
    # The paid to failed transition happens up to five business days later. A
    # classifier that calls paid "done" is the bug this guide is about.
    state, detail = classify({"status": "paid"})
    assert state == "open"
    assert "not final" in detail


def test_closed_account_needs_new_details():
    state, detail = classify({
        "status": "failed", "failure_code": "account_closed",
        "failure_balance_transaction": "txn_1",
    })
    assert state == "new-details"
    assert "fails identically" in detail


def test_debit_not_authorized_is_not_a_bank_details_problem():
    # The number is right. Attaching a new external account changes nothing, and
    # asking the seller for it wastes a round trip while they stay unpaid.
    state, detail = classify({
        "status": "failed", "failure_code": "debit_not_authorized",
        "failure_balance_transaction": "txn_2",
    })
    assert state == "bank-authorisation"
    assert "New details will not help" in detail


def test_insufficient_funds_is_your_side():
    state, detail = classify({
        "status": "failed", "failure_code": "insufficient_funds",
        "failure_balance_transaction": "txn_3",
    })
    assert state == "funding"
    assert "your side" in detail


def test_missing_reversal_is_called_out():
    _, detail = classify({"status": "failed", "failure_code": "account_closed"})
    assert "no failure_balance_transaction" in detail


def test_unknown_code_is_reported_rather_than_swallowed():
    state, detail = classify({
        "status": "failed", "failure_code": "brand_new_code",
        "failure_message": "Something Stripe added later",
    })
    assert state == "unclassified"
    assert "brand_new_code" in detail
    assert classify({"status": "in_flight"})[0] == "unknown"
stripe-failed-payouts.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { classify } from './stripe-failed-payouts.mjs';

test('paid is not treated as final', () => {
  // The paid to failed transition happens up to five business days later. A
  // classifier that calls paid "done" is the bug this guide is about.
  const [state, detail] = classify({ status: 'paid' });
  assert.equal(state, 'open');
  assert.match(detail, /not final/);
});

test('closed account needs new details', () => {
  const [state, detail] = classify({
    status: 'failed',
    failure_code: 'account_closed',
    failure_balance_transaction: 'txn_1',
  });
  assert.equal(state, 'new-details');
  assert.match(detail, /fails identically/);
});

test('debit not authorized is not a bank details problem', () => {
  // The number is right. Attaching a new external account changes nothing.
  const [state, detail] = classify({
    status: 'failed',
    failure_code: 'debit_not_authorized',
    failure_balance_transaction: 'txn_2',
  });
  assert.equal(state, 'bank-authorisation');
  assert.match(detail, /New details will not help/);
});

test('insufficient funds is your side', () => {
  const [state, detail] = classify({
    status: 'failed',
    failure_code: 'insufficient_funds',
    failure_balance_transaction: 'txn_3',
  });
  assert.equal(state, 'funding');
  assert.match(detail, /your side/);
});

test('missing reversal is called out', () => {
  const [, detail] = classify({ status: 'failed', failure_code: 'account_closed' });
  assert.match(detail, /no failure_balance_transaction/);
});

test('unknown code is reported rather than swallowed', () => {
  const [state, detail] = classify({
    status: 'failed',
    failure_code: 'brand_new_code',
    failure_message: 'Something Stripe added later',
  });
  assert.equal(state, 'unclassified');
  assert.match(detail, /brand_new_code/);
  assert.equal(classify({ status: 'in_flight' })[0], 'unknown');
});

FAQ

How can a payout go from paid to failed?

paid means Stripe sent the credit, not that the receiving bank accepted it. Banks can reject a credit for several business days afterwards, and when that happens Stripe moves the payout to failed and returns the funds to your balance. It is the normal behaviour of the banking rails, not a Stripe anomaly, and it is why a payout should be read again rather than filed once it turns green.

Where does the money go when a payout fails?

Back to your Stripe balance, recorded as the balance transaction referenced by failure_balance_transaction on the payout. Reconciliation that sums payouts without subtracting these reversals reports money leaving that never left, which is usually noticed weeks later as an unexplained surplus.

Why did the failures stop appearing after the first one?

Because the destination is frozen. A failed payout sets the external account's status to errored and Stripe stops sending scheduled payouts there. The flat failure count is not recovery, it is the absence of attempts, and the giveaway is a balance that keeps climbing next to it.

What is the difference between account_closed and debit_not_authorized?

account_closed means the destination no longer exists, so the fix is fresh bank details. debit_not_authorized means the account exists and its holder has not authorised the bank to accept this kind of movement, so the fix is a conversation between the holder and their bank. Sending new details for the second one produces the same failure with a different account number on it.

Is a daily script enough, or do I need the webhook?

Both, with the event first. payout.failed tells you on the day; a daily read of GET /v1/payouts?status=failed catches anything the endpoint missed while it was disabled or misconfigured. Running only the script means every failure is discovered up to a day late, on top of the days the bank already took.

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.