Skip to content

Diagnostic Stripe

legacy card sources still live under customer.sources

Half your customers renew without incident. The other half fail with Cannot charge a customer that has no active card, and when you open one of them in the Dashboard there is a card sitting right there. Both statements are true. The card is in a store the code that renews them cannot see.

Read-only key Python and Node.js Tests included
Green circuit board with a central processor and glowing components.
Photo by Brecht Corbeel on Unsplash
The short answer

For each customer, read GET /v1/customers/{id}/sources?object=card and GET /v1/payment_methods?customer={id}&type=card. Cards saved before the PaymentMethods API live in the first list as card_ or src_ objects and are invisible to the second.

The sharpest signal is the split: customer.default_source set while customer.invoice_settings.default_payment_method is null and the PaymentMethods list is empty. That customer has a usable card and every modern code path reports them as having none.

The problem in plain words

What makes this expensive is that it fails per customer rather than per deploy. The renewal job runs, most customers charge, a subset throws, and the error is one that sounds like a customer problem: no active card. Support tells the customer to re-enter their card. Some of them do, which quietly fixes that one row and removes the evidence, and the rest churn.

The error messages compound the confusion because they name ids that look right. Customer cus_... does not have a linked card with ID ... is Stripe saying the id you passed is in the other store, not that the customer has nothing. So the id is real, the customer is real, the card is real, and the call still fails.

Card savedyears agoundercustomer.sourcesBilling coderewrittenreadspayment_methodsList comes backemptythe card iselsewhereRenewal failsno active cardSupport saysre-entercustomer churnsinstead
The Dashboard renders both stores on one page, so the card is plainly there while the API call that needs it returns nothing.

Why it happens

There are genuinely two card stores. customer.sources holds the old objects and is reached through /v1/customers/{id}/sources. PaymentMethods are a separate collection reached through /v1/payment_methods. A card in one does not appear in the other, and no field on the customer tells you that the other store is where everything lives.

Billing falls back in a way that hides the problem for months. When invoice_settings.default_payment_method is null, subscription invoices fall back to customer.default_source. So an account that never migrated renews perfectly, and only the customers touched by newer code — who got a PaymentMethod and therefore stopped falling back — behave differently. The half-migrated account is less consistent than either extreme.

A legacy source carries no SCA mandate. These objects predate authentication, so even where they charge, they charge on the path that cannot authenticate. Converting them is not only tidying: it is what earns the off-session mandate that European renewals need.

The migration is per customer, and the failures are per customer. There is no account-level switch. Every customer is in one of a handful of states — modern only, legacy only, both, neither — and each state has a different repair. A script that reports one number cannot tell you which repair to run, which is why the classifier here returns a state rather than a boolean.

The fix, as a flow

The script reads both card stores for the same customer, and both defaults, because which default is populated decides what the next renewal will actually charge.

sources plus payment_methodsand both default fieldsPaymentMethod and modern defaultmigratedlegacy only, default_source setmodern code sees no cardboth, no modern defaultrenews on the old cardboth, modern default setresidue, safe to removeneither store has a cardask, do not migrate
Cards in both stores with no default_payment_method still renew on the legacy card, because Billing falls back to default_source.

How to fix it

Read both stores for the same customer

One call to /v1/customers/{id}/sources?object=card and one to /v1/payment_methods?customer={id}&type=card. Two customers can produce identical Dashboard pages and different results here, and the pair of answers is the only thing that distinguishes them.

Read both defaults too

default_source and invoice_settings.default_payment_method are separate fields with separate meanings. Which one is populated decides what Billing will use on the next renewal, so a customer with cards in both stores and no default_payment_method is still being charged the old way.

Find the customers with no card at all

Empty in both stores is a different problem with the same error message. Those customers cannot be migrated, only asked — usually through a SetupIntent, which also collects the mandate.

Migrate, then set the modern default, then delete

In that order. Setting invoice_settings[default_payment_method] before the PaymentMethod exists fails; deleting the source before the default is moved leaves the customer with nothing chargeable between the two calls.

Stop reading default_source anywhere in the billing path

Until that field is out of your code, a customer created by an old path can still end up with a legacy default, and the cohort refills behind you. The check is cheap enough to run weekly and prove the number stays at zero.

How to check it worked

Re-run the script. Every customer should report modern, with no split defaults and no legacy-only rows left.

python3 stripe_legacy_card_sources.py
# 3,141 customer(s): 3,141 modern, 0 legacy-only, 0 split, 0 cardless

The full code

One paginated GET over the customers, then one or two small GETs per customer — a restricted key with read access to Customers and PaymentMethods covers all of it. The second call is skipped where a customer already has a modern default and no legacy sources, because that is the majority and it is the only place the cost of this script lives.

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_legacy_card_sources.py
"""Report Stripe customers whose cards are still in the legacy sources store.

Read only. Paginated GETs and no writes: give this a RESTRICTED key with read
access to Customers and PaymentMethods. 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_legacy_card_sources")

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

# Cards saved before the PaymentMethods API. `src_` covers the Sources API that
# briefly sat between the two; both live under customer.sources and neither is
# visible to GET /v1/payment_methods.
LEGACY_PREFIXES = ("card_", "src_")


def classify(customer, sources, payment_methods):
    """Sort one customer by which card store actually holds their card.

    Pure, so the states can be tested without a network. `sources` is the data
    array from GET /v1/customers/{id}/sources?object=card and `payment_methods`
    the data array from GET /v1/payment_methods?customer={id}&type=card.

    Returns (state, detail).
    """
    legacy = [s for s in (sources or [])
              if str(s.get("id", "")).startswith(LEGACY_PREFIXES)]
    modern = list(payment_methods or [])
    default_source = customer.get("default_source")
    default_pm = (customer.get("invoice_settings") or {}).get("default_payment_method")

    if not legacy and modern:
        if not default_pm:
            return ("no_default",
                    "%d PaymentMethod(s) and no invoice_settings."
                    "default_payment_method: Billing has nothing to fall back to"
                    % len(modern))
        return ("modern", "%d PaymentMethod(s), modern default set" % len(modern))

    if not legacy and not modern:
        return ("cardless",
                "no card in either store: this is the other cause of "
                "'cannot charge a customer that has no active card'")

    if legacy and not modern:
        if default_source:
            return ("split_brain",
                    "%d legacy source(s) and default_source set, but no "
                    "PaymentMethod at all: every modern code path sees this "
                    "customer as having no card" % len(legacy))
        return ("legacy_only",
                "%d legacy source(s) and no PaymentMethod: charged only by code "
                "that still reads customer.sources" % len(legacy))

    if not default_pm:
        return ("split_default",
                "%d legacy source(s) alongside %d PaymentMethod(s), but "
                "default_payment_method is null: Billing falls back to "
                "default_source and renews on the legacy card"
                % (len(legacy), len(modern)))

    return ("residue",
            "%d legacy source(s) left behind a completed migration: the modern "
            "default is set, so these are safe to remove" % len(legacy))


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 customers(session, cap):
    """Yield customers, paginating until Stripe stops or the cap is hit."""
    seen = 0
    params = {"limit": 100}
    while True:
        page = get(session, "/customers", **params)
        data = page.get("data", [])
        for cust in data:
            yield cust
            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-customers", type=int, default=5000,
                    help="stop paginating after this many customers")
    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 cust in customers(s, args.max_customers):
        scanned += 1
        cid = cust.get("id", "")
        srcs = get(s, "/customers/%s/sources" % cid,
                   object="card", limit=100).get("data", [])

        # Skip the second call for the healthy majority: a customer with no
        # legacy source and a modern default is already migrated, and the
        # PaymentMethod list cannot change that answer.
        default_pm = (cust.get("invoice_settings") or {}).get("default_payment_method")
        pms = []
        if srcs or not default_pm:
            pms = get(s, "/payment_methods",
                      customer=cid, type="card", limit=100).get("data", [])

        state, detail = classify(cust, srcs, pms)
        counts[state] = counts.get(state, 0) + 1
        if state != "modern":
            log.warning("%s  %-14s %s", cid or "cus_?", state, detail)

    split = counts.get("split_brain", 0) + counts.get("split_default", 0)
    legacy_only = counts.get("legacy_only", 0)
    cardless = counts.get("cardless", 0)

    log.info("%d customer(s): %d modern, %d legacy-only, %d split, %d cardless",
             scanned, counts.get("modern", 0), legacy_only, split, cardless)

    if legacy_only or split or counts.get("residue"):
        log.warning("  repair, in this order, per customer:")
        log.warning("  1. create a PaymentMethod from the legacy card, or send "
                    "the customer through a SetupIntent to re-add it")
        log.warning("  2. POST %s/customers/{id} with "
                    "invoice_settings[default_payment_method]=pm_...", API)
        log.warning("  3. only then remove the old object at "
                    "%s/customers/{id}/sources/{card_id}", API)
    if cardless:
        log.warning("  %d customer(s) have no card in either store: a SetupIntent "
                    "is the only repair, and it collects the mandate too", cardless)
    bad = scanned - counts.get("modern", 0)
    return 1 if bad else 0


if __name__ == "__main__":
    sys.exit(main())
stripe-legacy-card-sources.mjs
/**
 * Report Stripe customers whose cards are still in the legacy sources store.
 *
 * Read only. Paginated GETs and no writes: give this a RESTRICTED key with read
 * access to Customers and PaymentMethods. The repair is printed, never performed.
 */
const API = 'https://api.stripe.com/v1';

// Cards saved before the PaymentMethods API. `src_` covers the Sources API that
// briefly sat between the two; neither is visible to GET /v1/payment_methods.
const LEGACY_PREFIXES = ['card_', 'src_'];

/**
 * Sort one customer by which card store actually holds their card. Pure, so the
 * states can be tested without a network. Returns [state, detail].
 */
export function classify(customer, sources, paymentMethods) {
  const legacy = (sources ?? []).filter(
    (s) => LEGACY_PREFIXES.some((p) => String(s.id ?? '').startsWith(p)));
  const modern = paymentMethods ?? [];
  const defaultSource = customer.default_source ?? null;
  const defaultPm = (customer.invoice_settings ?? {}).default_payment_method ?? null;

  if (legacy.length === 0 && modern.length > 0) {
    if (!defaultPm) {
      return ['no_default',
        `${modern.length} PaymentMethod(s) and no invoice_settings.` +
        'default_payment_method: Billing has nothing to fall back to'];
    }
    return ['modern', `${modern.length} PaymentMethod(s), modern default set`];
  }

  if (legacy.length === 0 && modern.length === 0) {
    return ['cardless',
      "no card in either store: this is the other cause of 'cannot charge a " +
      "customer that has no active card'"];
  }

  if (modern.length === 0) {
    if (defaultSource) {
      return ['split_brain',
        `${legacy.length} legacy source(s) and default_source set, but no ` +
        'PaymentMethod at all: every modern code path sees this customer as ' +
        'having no card'];
    }
    return ['legacy_only',
      `${legacy.length} legacy source(s) and no PaymentMethod: charged only by ` +
      'code that still reads customer.sources'];
  }

  if (!defaultPm) {
    return ['split_default',
      `${legacy.length} legacy source(s) alongside ${modern.length} ` +
      'PaymentMethod(s), but default_payment_method is null: Billing falls back ' +
      'to default_source and renews on the legacy card'];
  }

  return ['residue',
    `${legacy.length} legacy source(s) left behind a completed migration: the ` +
    'modern default is set, so these are safe to remove'];
}

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* customers(key, cap = 5000) {
  let seen = 0;
  const params = { limit: 100 };
  for (;;) {
    const page = await get(key, '/customers', params);
    const data = page.data ?? [];
    for (const cust of data) {
      yield cust;
      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 = {};
  let scanned = 0;
  for await (const cust of customers(key)) {
    scanned += 1;
    const srcs = (await get(key, `/customers/${cust.id}/sources`,
                            { object: 'card', limit: 100 })).data ?? [];

    // Skip the second call for the healthy majority: no legacy source plus a
    // modern default is already migrated, whatever the PaymentMethod list says.
    const defaultPm = (cust.invoice_settings ?? {}).default_payment_method ?? null;
    let pms = [];
    if (srcs.length || !defaultPm) {
      pms = (await get(key, '/payment_methods',
                       { customer: cust.id, type: 'card', limit: 100 })).data ?? [];
    }

    const [state, detail] = classify(cust, srcs, pms);
    counts[state] = (counts[state] ?? 0) + 1;
    if (state !== 'modern') {
      console.warn(`${cust.id ?? 'cus_?'}  ${state.padEnd(14)} ${detail}`);
    }
  }

  const split = (counts.split_brain ?? 0) + (counts.split_default ?? 0);
  const legacyOnly = counts.legacy_only ?? 0;
  const cardless = counts.cardless ?? 0;

  console.log(`${scanned} customer(s): ${counts.modern ?? 0} modern, ` +
              `${legacyOnly} legacy-only, ${split} split, ${cardless} cardless`);

  if (legacyOnly || split || counts.residue) {
    console.warn('  repair, in this order, per customer:');
    console.warn('  1. create a PaymentMethod from the legacy card, or send the ' +
                 'customer through a SetupIntent to re-add it');
    console.warn(`  2. POST ${API}/customers/{id} with ` +
                 'invoice_settings[default_payment_method]=pm_...');
    console.warn(`  3. only then remove the old object at ` +
                 `${API}/customers/{id}/sources/{card_id}`);
  }
  if (cardless) {
    console.warn(`  ${cardless} customer(s) have no card in either store: a ` +
                 'SetupIntent is the only repair, and it collects the mandate too');
  }
  if (scanned - (counts.modern ?? 0)) 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 state worth pinning is the split default: cards in both stores, no default_payment_method, which means Billing quietly renews on the legacy card even though the migration looks finished. A check that only asks does this customer have a PaymentMethod reports that customer as done, and the renewal still cannot authenticate.

test_stripe_legacy_card_sources.py
from stripe_legacy_card_sources import classify

MODERN_DEFAULT = {"invoice_settings": {"default_payment_method": "pm_1"}}


def test_migrated_customer_is_modern():
    state, _ = classify(MODERN_DEFAULT, [], [{"id": "pm_1"}])
    assert state == "modern"


def test_legacy_card_with_no_payment_method_is_the_split_brain_case():
    cust = {"default_source": "card_1", "invoice_settings": {}}
    state, detail = classify(cust, [{"id": "card_1"}], [])
    assert state == "split_brain"
    assert "no card" in detail


def test_src_objects_count_as_legacy_too():
    cust = {"default_source": "src_1", "invoice_settings": {}}
    assert classify(cust, [{"id": "src_1"}], [])[0] == "split_brain"


def test_both_stores_with_no_modern_default_still_renews_on_the_old_card():
    cust = {"default_source": "card_1", "invoice_settings": {}}
    state, detail = classify(cust, [{"id": "card_1"}], [{"id": "pm_1"}])
    assert state == "split_default"
    assert "falls back" in detail


def test_both_stores_with_a_modern_default_is_only_residue():
    state, _ = classify(MODERN_DEFAULT, [{"id": "card_1"}], [{"id": "pm_1"}])
    assert state == "residue"


def test_no_card_anywhere_is_its_own_state():
    state, _ = classify({"invoice_settings": {}}, [], [])
    assert state == "cardless"
stripe-legacy-card-sources.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { classify } from './stripe-legacy-card-sources.mjs';

const MODERN_DEFAULT = { invoice_settings: { default_payment_method: 'pm_1' } };

test('migrated customer is modern', () => {
  assert.equal(classify(MODERN_DEFAULT, [], [{ id: 'pm_1' }])[0], 'modern');
});

test('legacy card with no PaymentMethod is the split-brain case', () => {
  const cust = { default_source: 'card_1', invoice_settings: {} };
  const [state, detail] = classify(cust, [{ id: 'card_1' }], []);
  assert.equal(state, 'split_brain');
  assert.match(detail, /no card/);
});

test('src objects count as legacy too', () => {
  const cust = { default_source: 'src_1', invoice_settings: {} };
  assert.equal(classify(cust, [{ id: 'src_1' }], [])[0], 'split_brain');
});

test('both stores with no modern default still renews on the old card', () => {
  const cust = { default_source: 'card_1', invoice_settings: {} };
  const [state, detail] = classify(cust, [{ id: 'card_1' }], [{ id: 'pm_1' }]);
  assert.equal(state, 'split_default');
  assert.match(detail, /falls back/);
});

test('both stores with a modern default is only residue', () => {
  assert.equal(
    classify(MODERN_DEFAULT, [{ id: 'card_1' }], [{ id: 'pm_1' }])[0], 'residue');
});

test('no card anywhere is its own state', () => {
  assert.equal(classify({ invoice_settings: {} }, [], [])[0], 'cardless');
});

FAQ

Why does GET /v1/payment_methods return nothing when the Dashboard shows a card?

Because the card is in the other store. Cards saved before the PaymentMethods API are card_ or src_ objects under customer.sources, reached through /v1/customers/{id}/sources. The Dashboard renders both stores on one page; the API does not.

What does 'Cannot charge a customer that has no active card' actually mean?

That the store your call reads is empty. It has two causes with the same wording: the customer genuinely has no card anywhere, or the card is a legacy source and the call is looking for a PaymentMethod. The classifier keeps them apart because only one of the two can be repaired without asking the customer.

Which default does a subscription renewal use?

invoice_settings.default_payment_method if it is set, and customer.default_source if it is not. That fallback is why a half-migrated account keeps renewing on legacy cards long after the new code has shipped.

Can I convert a legacy source into a PaymentMethod?

Often yes, and where it is not possible the alternative is a SetupIntent that asks the customer to re-add the card. The SetupIntent route is slower but earns an SCA mandate, which the legacy object never had.

What order should the migration run in?

Create or convert the PaymentMethod, set invoice_settings[default_payment_method], and only then remove the old source. Reversing the last two steps leaves the customer with no chargeable card in the gap between the calls.

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.