Skip to content

Diagnostic Stripe

no external account can settle the account's currency

The payout call comes back with Sorry, you don't have any external accounts in that currency (usd). The account plainly has a bank account: you can see it in the Dashboard, the seller added it during onboarding, and it has been sitting there for months. It is an Australian bank account, the balance is in dollars, and there is no arrangement under which one settles the other.

Read-only key Python and Node.js Tests included
A supermarket aisle
Photo by Dominik on Unsplash
The short answer

Payouts settle per currency. A balance bucket in usd needs an external account whose currency is usd and which is marked default_for_currency. Read GET /v1/accounts/{id}/external_accounts?limit=100 and check for both conditions against the account's default_currency; a bank account in the wrong currency is not a partial answer, it is no answer.

Before blaming the bank details, check the route is legal at all. GET /v1/country_specs/{platform_country} returns supported_transfer_countries and supported_bank_account_currencies. If the connected account's country is not in the first list, no external account of any currency will make the transfer work.

The problem in plain words

The error message is unusually good and still gets misread, because it names a currency rather than a bank account and the person reading it is looking straight at a bank account. So the first attempt at a fix is to re-enter the same account and routing numbers, which changes nothing, and the second is to add a second bank account in the same wrong currency, which also changes nothing.

The version of this that costs the most is the one where the corridor is not supported. Somebody spends a fortnight collecting bank details from a seller in a country the platform cannot pay out to, and every one of those details is correct. There is no field to fix. The account needed a different payout arrangement from the day it was created, and nothing on the account object says so.

Seller addsbankAUD account,acceptedBalance buildssettles in USDPayoutattemptedneeds a USDdestinationNo match founderror names thecurrencyDetailsre-enteredsame account, sameresultthe numbers were never wrong
Stripe does not convert to reach a destination, so a bank account in another currency is not a worse option than the right one.

Why it happens

Stripe will not convert to reach a destination. A USD balance goes to a USD bank account. There is no implicit FX at payout time, so a destination in another currency is not a worse option than the right one, it is not an option.

default_for_currency is the field that actually decides which destination is used. Adding a correctly denominated bank account is only half the change. Until one of them is flagged as the default for that currency, automatic payouts have nothing to target and the balance sits exactly where it was. This is why a first attempt at the fix often appears to do nothing.

Country and currency are two different constraints. supported_transfer_countries says where the platform may send money at all. supported_bank_account_currencies says which currencies a bank account in a given country can hold. An account can pass the first and fail the second, and the repair is different: the first needs a different payout product, the second needs a different bank.

Recipients on the recipient service agreement are excluded from cross-border payouts entirely. That is a property of how the account was created, not something a bank account can satisfy, and it produces exactly the same unhelpful symptom as a wrong-currency destination.

The fix, as a flow

The script checks whether the payout route is legal before it looks at any bank details, because an unsupported corridor also produces a currency mismatch and no bank account will ever resolve it.

country spec plusGET /accounts/{id}/external_accountscountry not transferableno API fix, change productcountry cannot hold itdifferent bank requiredno matching currencyadd a destination in itmatch, not the defaultone flag awaymatch and defaultsettles
The corridor is checked first. Where the route is unsupported the currency is not what is wrong and collecting a bank account cannot help.

How to fix it

Check the corridor before you check the bank details

One GET /v1/country_specs/{platform_country} for the whole run. If the connected account's country is missing from supported_transfer_countries, stop: collecting bank details is wasted work and telling the seller their details are wrong is worse than wasted.

Compare each destination's currency against default_currency

GET /v1/accounts/{id}/external_accounts?limit=100. Match on the currency field of each bank account. Report the currencies that are present alongside the one that is needed, because that single line usually explains the whole thing to whoever is reading it.

Treat a matching but unflagged destination as its own finding

A USD bank account with default_for_currency false is a different problem from having no USD bank account, and it has a one-line fix. Collapsing the two into "currency problem" sends someone to collect details that are already on the account.

Check the currency is one that country can bank in

supported_bank_account_currencies maps each currency to the countries whose bank accounts may hold it. A destination country that cannot hold the settlement currency is a dead end no matter how many times the details are re-entered.

Separate this from a balance stuck in a second currency

This note is about the account's own default_currency having no way out. A residual balance in some other currency that no payout will ever drain is a related but distinct problem, covered in a second-currency balance bucket can never be paid out.

How to check it worked

Re-run the script. Every account should report settles, meaning a destination exists in the settlement currency and is flagged as the default for it.

python3 stripe_settlement_currency.py
# 412 account(s): 412 settling, 0 blocked

The full code

Three read-only calls: the platform account for its country, one country spec for the corridor rules, and the external accounts for each connected account. The classifier is pure and takes all three facts at once, because the order of the checks is the useful part — an unsupported corridor has to be reported before a currency mismatch, since in that case the currency is not what is wrong and collecting a new bank account cannot help.

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_settlement_currency.py
"""Report connected accounts with no external account able to settle their balance.

Read only. Three kinds of GET and no writes: give this a RESTRICTED key with read
access to Connected accounts and External 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_settlement_currency")

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


def verdict(account, external_accounts, spec=None):
    """Classify one account's settlement path. Pure, so the order is testable.

    `spec` is the platform's country spec, or None to skip the corridor checks.
    Returns (state, detail). The corridor is checked first on purpose: when the
    route is not supported, the currency is not what is wrong and no external
    account will fix it, so reporting a currency mismatch there is misleading.
    """
    country = account.get("country")
    currency = (account.get("default_currency") or "").lower()
    accounts = external_accounts or []

    if not currency:
        return ("unknown", "the account has no default_currency to settle in")

    if spec:
        transferable = spec.get("supported_transfer_countries")
        if transferable is not None and country not in transferable:
            return ("unsupported-corridor",
                    "%s is not in this platform's supported_transfer_countries; no "
                    "bank account of any currency makes this payout legal" % country)
        bankable = spec.get("supported_bank_account_currencies")
        if bankable is not None and country not in (bankable.get(currency) or []):
            return ("unbankable-currency",
                    "a bank account in %s cannot hold %s under this country spec"
                    % (country, currency.upper()))

    if not accounts:
        return ("no-destination",
                "no external account at all, so no payout is ever attempted")

    matching = [e for e in accounts
                if (e.get("currency") or "").lower() == currency]
    if not matching:
        held = sorted({(e.get("currency") or "?").lower() for e in accounts})
        return ("currency-missing",
                "settles in %s but the only destination(s) are %s"
                % (currency.upper(), ", ".join(c.upper() for c in held)))
    if not any(e.get("default_for_currency") for e in matching):
        return ("not-default",
                "a %s destination exists but none is default_for_currency, so "
                "automatic payouts have no target" % currency.upper())
    return ("settles", "%s destination present and default_for_currency" % currency.upper())


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 paginate(session, path, limit):
    """Walk a list endpoint, stopping once `limit` objects have been yielded."""
    seen = 0
    params = {"limit": 100}
    while True:
        page = get(session, path, **params)
        data = page.get("data", [])
        for obj in data:
            yield obj
            seen += 1
            if seen >= limit:
                return
        if not page.get("has_more") or not data:
            return
        params["starting_after"] = data[-1]["id"]


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--max-accounts", type=int, default=500,
                    help="stop after this many connected accounts")
    ap.add_argument("--skip-country-spec", action="store_true",
                    help="skip the corridor checks and only compare currencies")
    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})

    spec = None
    if not args.skip_country_spec:
        platform = get(s, "/account")
        spec = get(s, "/country_specs/%s" % platform.get("country", "US"))
        log.info("platform in %s, %d transfer country/countries supported",
                 platform.get("country"),
                 len(spec.get("supported_transfer_countries") or []))

    total = settling = blocked = 0
    for acct in paginate(s, "/accounts", args.max_accounts):
        total += 1
        externals = get(s, "/accounts/%s/external_accounts" % acct["id"],
                        limit=100).get("data", [])
        state, detail = verdict(acct, externals, spec)
        line = "%-21s %s  %s" % (state, acct["id"], detail)
        if state == "settles":
            settling += 1
            continue
        blocked += 1
        log.warning(line)
        if state == "not-default":
            log.warning("  repair: POST %s/accounts/%s/external_accounts/{ba_id} with "
                        "default_for_currency=true", API, acct["id"])
        elif state in ("currency-missing", "no-destination", "unbankable-currency"):
            log.warning("  repair: POST %s/accounts/%s with an external_account token "
                        "in %s, then flag it default_for_currency=true",
                        API, acct["id"], (acct.get("default_currency") or "?").upper())
        elif state == "unsupported-corridor":
            log.warning("  repair: none by API. Move this recipient to Global Payouts "
                        "or a locally acquiring platform account.")

    log.info("%d account(s): %d settling, %d blocked", total, settling, blocked)
    return 1 if blocked else 0


if __name__ == "__main__":
    sys.exit(main())
stripe-settlement-currency.mjs
/**
 * Report connected accounts with no external account able to settle their balance.
 *
 * Read only. Three kinds of GET and no writes: give this a RESTRICTED key with
 * read access to Connected accounts and External accounts. The repair is printed,
 * never performed.
 */
const API = 'https://api.stripe.com/v1';

/**
 * Classify one account's settlement path. Pure, so the order of the checks is
 * testable. `spec` is the platform's country spec, or null to skip the corridor.
 */
export function verdict(account, externalAccounts, spec = null) {
  const country = account.country;
  const currency = (account.default_currency ?? '').toLowerCase();
  const accounts = externalAccounts ?? [];

  if (!currency) return ['unknown', 'the account has no default_currency to settle in'];

  if (spec) {
    const transferable = spec.supported_transfer_countries;
    if (transferable && !transferable.includes(country)) {
      return ['unsupported-corridor',
        `${country} is not in this platform's supported_transfer_countries; no bank ` +
        'account of any currency makes this payout legal'];
    }
    const bankable = spec.supported_bank_account_currencies;
    if (bankable && !(bankable[currency] ?? []).includes(country)) {
      return ['unbankable-currency',
        `a bank account in ${country} cannot hold ${currency.toUpperCase()} under ` +
        'this country spec'];
    }
  }

  if (accounts.length === 0) {
    return ['no-destination', 'no external account at all, so no payout is ever attempted'];
  }

  const matching = accounts.filter(
    (e) => (e.currency ?? '').toLowerCase() === currency);
  if (matching.length === 0) {
    const held = [...new Set(accounts.map((e) => (e.currency ?? '?').toUpperCase()))].sort();
    return ['currency-missing',
      `settles in ${currency.toUpperCase()} but the only destination(s) are ` +
      held.join(', ')];
  }
  if (!matching.some((e) => e.default_for_currency)) {
    return ['not-default',
      `a ${currency.toUpperCase()} destination exists but none is ` +
      'default_for_currency, so automatic payouts have no target'];
  }
  return ['settles',
    `${currency.toUpperCase()} destination present and default_for_currency`];
}

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();
}

async function* paginate(key, path, limit) {
  let seen = 0;
  const params = { limit: 100 };
  for (;;) {
    const page = await get(key, path, params);
    const data = page.data ?? [];
    for (const obj of data) {
      yield obj;
      if (++seen >= limit) return;
    }
    if (!page.has_more || data.length === 0) 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;
  }

  let spec = null;
  if (!process.argv.includes('--skip-country-spec')) {
    const platform = await get(key, '/account');
    spec = await get(key, `/country_specs/${platform.country ?? 'US'}`);
    console.log(`platform in ${platform.country}, ` +
                `${(spec.supported_transfer_countries ?? []).length} transfer ` +
                'country/countries supported');
  }

  let total = 0, settling = 0, blocked = 0;
  for await (const acct of paginate(key, '/accounts', 500)) {
    total += 1;
    const { data: externals = [] } =
      await get(key, `/accounts/${acct.id}/external_accounts`, { limit: 100 });
    const [state, detail] = verdict(acct, externals, spec);
    if (state === 'settles') { settling += 1; continue; }
    blocked += 1;
    console.warn(`${state.padEnd(21)} ${acct.id}  ${detail}`);
    if (state === 'not-default') {
      console.warn(`  repair: POST ${API}/accounts/${acct.id}/external_accounts/` +
                   '{ba_id} with default_for_currency=true');
    } else if (['currency-missing', 'no-destination', 'unbankable-currency'].includes(state)) {
      console.warn(`  repair: POST ${API}/accounts/${acct.id} with an ` +
                   `external_account token in ` +
                   `${(acct.default_currency ?? '?').toUpperCase()}, then flag it ` +
                   'default_for_currency=true');
    } else if (state === 'unsupported-corridor') {
      console.warn('  repair: none by API. Move this recipient to Global Payouts or ' +
                   'a locally acquiring platform account.');
    }
  }

  console.log(`${total} account(s): ${settling} settling, ${blocked} blocked`);
  process.exitCode = blocked ? 1 : 0;
}

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

Ordering is the thing under test. An account in an unsupported corridor also has a currency mismatch, and reporting the mismatch sends someone to collect bank details that can never work; the corridor check has to win. The other case worth pinning is the destination in the right currency with default_for_currency false, which is a one-line fix that looks identical to the expensive one if the two are collapsed.

test_stripe_settlement_currency.py
from stripe_settlement_currency import verdict

US_SPEC = {
    "supported_transfer_countries": ["US", "GB", "DE"],
    "supported_bank_account_currencies": {"usd": ["US"], "gbp": ["GB"], "eur": ["DE"]},
}
US = {"country": "US", "default_currency": "usd"}


def test_a_matching_default_destination_settles():
    externals = [{"currency": "usd", "default_for_currency": True}]
    assert verdict(US, externals, US_SPEC)[0] == "settles"


def test_a_matching_destination_that_is_not_the_default_is_its_own_finding():
    # One flag away from working, and nothing to collect from the seller.
    state, detail = verdict(US, [{"currency": "usd", "default_for_currency": False}],
                            US_SPEC)
    assert state == "not-default"
    assert "default_for_currency" in detail


def test_a_wrong_currency_destination_names_what_is_actually_attached():
    state, detail = verdict(US, [{"currency": "aud", "default_for_currency": True}],
                            US_SPEC)
    assert state == "currency-missing"
    assert "AUD" in detail
    assert "USD" in detail


def test_no_destination_at_all_is_separate_from_a_wrong_one():
    assert verdict(US, [], US_SPEC)[0] == "no-destination"


def test_an_unsupported_corridor_outranks_the_currency_check():
    # Both are true for this account. Reporting the currency sends someone to
    # collect bank details that cannot be made to work.
    acct = {"country": "BR", "default_currency": "brl"}
    state, detail = verdict(acct, [{"currency": "aud"}], US_SPEC)
    assert state == "unsupported-corridor"
    assert "BR" in detail


def test_a_country_that_cannot_hold_the_currency_is_reported_as_such():
    acct = {"country": "GB", "default_currency": "usd"}
    assert verdict(acct, [{"currency": "gbp"}], US_SPEC)[0] == "unbankable-currency"


def test_the_corridor_checks_are_skipped_without_a_spec():
    acct = {"country": "BR", "default_currency": "brl"}
    externals = [{"currency": "brl", "default_for_currency": True}]
    assert verdict(acct, externals, None)[0] == "settles"


def test_a_missing_default_currency_is_not_silently_settling():
    assert verdict({"country": "US"}, [{"currency": "usd"}], None)[0] == "unknown"
stripe-settlement-currency.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict } from './stripe-settlement-currency.mjs';

const US_SPEC = {
  supported_transfer_countries: ['US', 'GB', 'DE'],
  supported_bank_account_currencies: { usd: ['US'], gbp: ['GB'], eur: ['DE'] },
};
const US = { country: 'US', default_currency: 'usd' };

test('a matching default destination settles', () => {
  const externals = [{ currency: 'usd', default_for_currency: true }];
  assert.equal(verdict(US, externals, US_SPEC)[0], 'settles');
});

test('a matching destination that is not the default is its own finding', () => {
  const [state, detail] = verdict(
    US, [{ currency: 'usd', default_for_currency: false }], US_SPEC);
  assert.equal(state, 'not-default');
  assert.match(detail, /default_for_currency/);
});

test('a wrong currency destination names what is actually attached', () => {
  const [state, detail] = verdict(
    US, [{ currency: 'aud', default_for_currency: true }], US_SPEC);
  assert.equal(state, 'currency-missing');
  assert.match(detail, /AUD/);
  assert.match(detail, /USD/);
});

test('no destination at all is separate from a wrong one', () => {
  assert.equal(verdict(US, [], US_SPEC)[0], 'no-destination');
});

test('an unsupported corridor outranks the currency check', () => {
  const acct = { country: 'BR', default_currency: 'brl' };
  const [state, detail] = verdict(acct, [{ currency: 'aud' }], US_SPEC);
  assert.equal(state, 'unsupported-corridor');
  assert.match(detail, /BR/);
});

test('a country that cannot hold the currency is reported as such', () => {
  const acct = { country: 'GB', default_currency: 'usd' };
  assert.equal(verdict(acct, [{ currency: 'gbp' }], US_SPEC)[0], 'unbankable-currency');
});

test('the corridor checks are skipped without a spec', () => {
  const acct = { country: 'BR', default_currency: 'brl' };
  const externals = [{ currency: 'brl', default_for_currency: true }];
  assert.equal(verdict(acct, externals, null)[0], 'settles');
});

test('a missing default_currency is not silently settling', () => {
  assert.equal(verdict({ country: 'US' }, [{ currency: 'usd' }], null)[0], 'unknown');
});

FAQ

Why does Stripe say I have no external accounts in that currency when one is attached?

Because the attached one is denominated in a different currency. Payouts settle per currency and Stripe does not convert to reach a destination, so a bank account in AUD cannot receive a USD balance. The message is about the currency of the destination, not its existence.

What does default_for_currency actually do?

It nominates which destination automatic payouts use for a given currency. Adding a correctly denominated bank account without setting it leaves the balance where it is, which is why a first attempt at this fix often appears to have done nothing.

How do I know whether the payout is even allowed?

GET /v1/country_specs/{platform_country}. If the connected account's country is not in supported_transfer_countries, the route is not available to that platform and no bank account will change it. supported_bank_account_currencies then says which currencies a bank account in each country can hold.

What do I do about an unsupported corridor?

Nothing through this API. The recipient needs a different arrangement: Global Payouts, or a platform account that acquires locally in that country. Collecting more bank details from the seller is wasted effort and reads to them as your product being broken.

Is this the same as a balance stuck in a second currency?

No. This is the account's own default_currency having no settlement path. A residual balance in an unrelated currency that no payout will drain is a separate problem with a separate fix, and an account can have both at once.

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.