Diagnostic Stripe
PaymentMethods are created but never attached to a customer
The first charge works. The customer comes back a month later, picks their saved card, and the request fails with a sentence about a PaymentMethod that was previously used without being attached to a Customer. The pm_ id is right there in your database, it looks fine, and it will never work again.
Page GET /v1/payment_methods?type=card and count the ones where customer is null and created is more than a day old. Those were minted by Elements and never attached to anyone. Compare that against the attached population: the orphan share is the rate at which your integration is throwing saved cards away.
Confirm the mechanism from the payment side. GET /v1/payment_intents counting intents where customer is set but setup_future_usage is null finds the exact code path that charges a customer's card and then discards it, and last_payment_error.code == "payment_method_unexpected_state" counts the reuses that have already failed.
The problem in plain words
A PaymentMethod created in the browser belongs to nobody. customer is null and stays that way unless something attaches it. That is not a bug; it is what lets you collect card details before you have decided which Customer they belong to. The trap is that the object is perfectly usable exactly once, so nothing goes wrong on the day you write the code.
Once an unattached PaymentMethod has been consumed by a PaymentIntent, it is burned. Not expired, not detached, not recoverable: the id resolves, the object is still readable through the API, and any attempt to charge it again is rejected. So your database fills up with card records that render a brand and a last4 in your UI, and every one of them is a checkout that will fail at the last step.
Why it happens
Saving a card is a parameter, not a side effect. Charging a customer and saving their card look like one action from the outside and are two in the API. A PaymentIntent with customer set will happily charge and then leave nothing behind, because setup_future_usage was never passed. Nothing warns you; the payment succeeded, which is what you were watching.
The failure is separated from its cause by weeks. The mistake happens at the first checkout. The symptom appears at the second one, which for a typical customer is a month or a quarter later. By then the deploy that introduced it is long merged, and the error message points at the reuse rather than at the save.
The error message reads like a permissions problem. "This PaymentMethod was previously used without being attached to a Customer or was detached from a Customer, and may not be used again" describes two entirely different histories in one sentence. Teams spend the first hour looking for the code that detached it, and there is none.
A bare attach is not the same as saving during payment. POST /v1/payment_methods/{id}/attach works and is the obvious fix, but it skips the setup that setup_future_usage performs at confirmation time — the issuer never sees that this credential is being stored for later, so subsequent off-session charges are more likely to be declined for authentication.
The fix, as a flow
The script counts unattached PaymentMethods against attached ones, then looks at the PaymentIntents that created them, because the ratio says how bad it is and the intents say which line of code did it.
How to fix it
Count the orphans, and give them a day to grow up
GET /v1/payment_methods?type=card&limit=100 without a customer parameter lists the account-wide population. An unattached PaymentMethod created in the last few minutes is a checkout in progress, not a leak, so only count the ones older than 24 hours.
Turn the count into a ratio
Two hundred orphans means nothing on its own. Two hundred orphans against two hundred attached cards means half of every card your customers enter is discarded. The ratio is the number that tells you whether this is residue from an old integration or the current behaviour of your checkout.
Find the code path in the PaymentIntents
An intent with customer set and setup_future_usage unset is the smoking gun: that request knew who the customer was and still did not save the card. Counting them tells you the fix is one parameter, and roughly how much of your traffic goes through it.
Count the reuses that already failed
last_payment_error.code == "payment_method_unexpected_state" on recent PaymentIntents is the customer-visible half. If that count is above zero this is not a hygiene finding, it is a live checkout failure with a queue of affected customers behind it.
Save at confirmation time, not afterwards
Pass customer and setup_future_usage=off_session on the PaymentIntent, and the PaymentMethod is attached for you when the payment succeeds. To store a card without charging it, create a SetupIntent with usage=off_session. Reserve the bare attach call for migrating cards you already hold.
How to check it worked
Re-run the script after a deploy. New PaymentMethods should arrive attached, so the orphan count stops growing even while the historical residue is still there.
python3 stripe_orphaned_payment_methods.py
# clear every card PaymentMethod in the window is attached to a customer
The full code
Two paginated GETs and nothing else — a restricted key with read access to PaymentMethods and PaymentIntents is enough, and is what you should give it. The classification is a pure function taking four counts, because the difference between "residue from 2022" and "half of today's checkouts" is a ratio, and a ratio is the kind of thing that is easy to get backwards and impossible to notice.
"""Report Stripe PaymentMethods that were never attached to a Customer.
Read only. GETs only, no writes: give this a RESTRICTED key with read access to
PaymentMethods and PaymentIntents. 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_orphaned_payment_methods")
API = "https://api.stripe.com/v1"
MIN_AGE_HOURS = 24 # younger than this is a checkout still in progress
WARN_RATIO = 0.25 # a quarter of cards discarded is a code path, not residue
HIGH_RATIO = 0.50
def verdict(orphans, attached, unsaved_intents, reuse_errors):
"""Classify the orphan population. Pure, so the ratios can be tested offline.
orphans unattached card PaymentMethods older than MIN_AGE_HOURS
attached card PaymentMethods with a customer set
unsaved_intents PaymentIntents with a customer but no setup_future_usage
reuse_errors PaymentIntents that failed with payment_method_unexpected_state
Returns (state, detail).
"""
total = orphans + attached
if reuse_errors:
return ("burned",
"%d PaymentIntent(s) failed with payment_method_unexpected_state: "
"a consumed pm_ is already being charged a second time. %d orphan(s) "
"on the account." % (reuse_errors, orphans))
if not total:
return ("clear",
"no card PaymentMethods older than %d hours to judge" % MIN_AGE_HOURS)
ratio = orphans / float(total)
if ratio >= HIGH_RATIO:
return ("leaking",
"%d of %d card PaymentMethods (%.0f%%) were never attached. This is "
"the current behaviour of the checkout, not old residue."
% (orphans, total, ratio * 100))
if unsaved_intents:
return ("unsaved",
"%d PaymentIntent(s) charged a known customer with setup_future_usage "
"unset, so those cards were discarded after one use. %d orphan(s) so "
"far." % (unsaved_intents, orphans))
if ratio >= WARN_RATIO:
return ("orphaned",
"%d of %d card PaymentMethods (%.0f%%) have no customer. Reusing any "
"of them will fail." % (orphans, total, ratio * 100))
if orphans:
return ("residue",
"%d of %d card PaymentMethods have no customer. Small enough to be "
"history rather than the live path." % (orphans, total))
return ("clear", "every card PaymentMethod in the window is attached to a customer")
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 page_all(session, path, limit, **params):
"""Yield objects from a paginated list endpoint until `limit` is reached."""
seen = 0
while True:
page = get(session, path, **params)
data = page.get("data", [])
for obj in data:
yield obj
seen += 1
if not data or not page.get("has_more") or seen >= limit:
return
params["starting_after"] = data[-1]["id"]
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--max-objects", type=int, default=2000,
help="stop paginating each list after this many objects")
ap.add_argument("--min-age-hours", type=float, default=MIN_AGE_HOURS,
help="ignore unattached PaymentMethods younger than this")
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})
cutoff = time.time() - args.min_age_hours * 3600.0
orphans = attached = 0
sample = []
for pm in page_all(s, "/payment_methods", args.max_objects, type="card", limit=100):
if pm.get("customer"):
attached += 1
elif (pm.get("created") or 0) < cutoff:
orphans += 1
if len(sample) < 5:
sample.append(pm.get("id"))
unsaved = reuse_errors = 0
for pi in page_all(s, "/payment_intents", args.max_objects, limit=100):
if pi.get("customer") and not pi.get("setup_future_usage"):
unsaved += 1
err = pi.get("last_payment_error") or {}
if err.get("code") == "payment_method_unexpected_state":
reuse_errors += 1
state, detail = verdict(orphans, attached, unsaved, reuse_errors)
line = "%-9s %s" % (state, detail)
if state == "clear":
log.info(line)
return 0
log.warning(line)
for pm_id in sample:
log.warning(" orphan %s", pm_id)
log.warning(" save the card as part of the payment rather than storing the id:")
log.warning(" POST %s/payment_intents -d customer=cus_X "
"-d setup_future_usage=off_session", API)
log.warning(" to store a card without charging it:")
log.warning(" POST %s/setup_intents -d customer=cus_X -d usage=off_session", API)
return 1
if __name__ == "__main__":
sys.exit(main())
/**
* Report Stripe PaymentMethods that were never attached to a Customer.
*
* Read only. GETs only, no writes: give this a RESTRICTED key with read access
* to PaymentMethods and PaymentIntents. The repair is printed, never performed.
*/
const API = 'https://api.stripe.com/v1';
export const MIN_AGE_HOURS = 24; // younger than this is a checkout in progress
const WARN_RATIO = 0.25;
const HIGH_RATIO = 0.50;
/**
* Classify the orphan population. Pure, so the ratios can be tested offline.
* Returns [state, detail].
*/
export function verdict(orphans, attached, unsavedIntents, reuseErrors) {
const total = orphans + attached;
if (reuseErrors) {
return ['burned',
`${reuseErrors} PaymentIntent(s) failed with payment_method_unexpected_state: ` +
`a consumed pm_ is already being charged a second time. ${orphans} orphan(s) ` +
'on the account.'];
}
if (!total) {
return ['clear', `no card PaymentMethods older than ${MIN_AGE_HOURS} hours to judge`];
}
const ratio = orphans / total;
const pct = (ratio * 100).toFixed(0);
if (ratio >= HIGH_RATIO) {
return ['leaking',
`${orphans} of ${total} card PaymentMethods (${pct}%) were never attached. ` +
'This is the current behaviour of the checkout, not old residue.'];
}
if (unsavedIntents) {
return ['unsaved',
`${unsavedIntents} PaymentIntent(s) charged a known customer with ` +
`setup_future_usage unset, so those cards were discarded after one use. ` +
`${orphans} orphan(s) so far.`];
}
if (ratio >= WARN_RATIO) {
return ['orphaned',
`${orphans} of ${total} card PaymentMethods (${pct}%) have no customer. ` +
'Reusing any of them will fail.'];
}
if (orphans) {
return ['residue',
`${orphans} of ${total} card PaymentMethods have no customer. Small enough ` +
'to be history rather than the live path.'];
}
return ['clear', 'every card PaymentMethod in the window is attached to a customer'];
}
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 pageAll(key, path, limit, params = {}) {
const out = [];
const q = { limit: 100, ...params };
for (;;) {
const page = await get(key, path, q);
const data = page.data ?? [];
out.push(...data);
if (data.length === 0 || !page.has_more || out.length >= limit) return out;
q.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 cutoff = Date.now() / 1000 - MIN_AGE_HOURS * 3600;
let orphans = 0;
let attached = 0;
const sample = [];
for (const pm of await pageAll(key, '/payment_methods', 2000, { type: 'card' })) {
if (pm.customer) attached += 1;
else if ((pm.created ?? 0) < cutoff) {
orphans += 1;
if (sample.length < 5) sample.push(pm.id);
}
}
let unsaved = 0;
let reuseErrors = 0;
for (const pi of await pageAll(key, '/payment_intents', 2000)) {
if (pi.customer && !pi.setup_future_usage) unsaved += 1;
if (pi.last_payment_error?.code === 'payment_method_unexpected_state') reuseErrors += 1;
}
const [state, detail] = verdict(orphans, attached, unsaved, reuseErrors);
const line = `${state.padEnd(9)} ${detail}`;
if (state === 'clear') { console.log(line); return; }
console.warn(line);
for (const id of sample) console.warn(` orphan ${id}`);
console.warn(' save the card as part of the payment rather than storing the id:');
console.warn(` POST ${API}/payment_intents -d customer=cus_X -d setup_future_usage=off_session`);
console.warn(' to store a card without charging it:');
console.warn(` POST ${API}/setup_intents -d customer=cus_X -d usage=off_session`);
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 are about precedence and about the two ratios. A reuse that has already failed has to outrank every hygiene finding, because it is a customer at a checkout right now, and 25% has to mean 25% rather than 26% — a threshold that is off by a rounding is a threshold that reports the wrong shape of problem on the exact day someone reads it.
from stripe_orphaned_payment_methods import verdict
def test_nothing_to_judge_is_clear():
assert verdict(0, 0, 0, 0)[0] == "clear"
def test_all_attached_is_clear():
state, detail = verdict(0, 40, 0, 0)
assert state == "clear"
assert "attached" in detail
def test_a_failed_reuse_outranks_every_hygiene_finding():
# Small orphan count, tiny ratio, but a customer is failing at checkout now.
state, detail = verdict(1, 999, 0, 3)
assert state == "burned"
assert "payment_method_unexpected_state" in detail
def test_half_the_cards_orphaned_is_the_live_path():
state, detail = verdict(50, 50, 0, 0)
assert state == "leaking"
assert "50%" in detail
def test_the_warn_ratio_is_inclusive():
# 24% is residue, 25% is a code path. Off by one on this boundary reports
# an active leak as history.
assert verdict(2, 8, 0, 0)[0] == "residue"
assert verdict(25, 75, 0, 0)[0] == "orphaned"
def test_unsaved_intents_are_named_even_with_few_orphans():
state, detail = verdict(3, 97, 12, 0)
assert state == "unsaved"
assert "setup_future_usage" in detail
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict } from './stripe-orphaned-payment-methods.mjs';
test('nothing to judge is clear', () => {
assert.equal(verdict(0, 0, 0, 0)[0], 'clear');
});
test('all attached is clear', () => {
const [state, detail] = verdict(0, 40, 0, 0);
assert.equal(state, 'clear');
assert.match(detail, /attached/);
});
test('a failed reuse outranks every hygiene finding', () => {
const [state, detail] = verdict(1, 999, 0, 3);
assert.equal(state, 'burned');
assert.match(detail, /payment_method_unexpected_state/);
});
test('half the cards orphaned is the live path', () => {
const [state, detail] = verdict(50, 50, 0, 0);
assert.equal(state, 'leaking');
assert.match(detail, /50%/);
});
test('the warn ratio is inclusive', () => {
assert.equal(verdict(2, 8, 0, 0)[0], 'residue');
assert.equal(verdict(25, 75, 0, 0)[0], 'orphaned');
});
test('unsaved intents are named even with few orphans', () => {
const [state, detail] = verdict(3, 97, 12, 0);
assert.equal(state, 'unsaved');
assert.match(detail, /setup_future_usage/);
});
FAQ
What does payment_method_unexpected_state actually mean?
That the PaymentMethod is not in a state where it can be used for this request. In practice it almost always means the object was created without a customer, consumed by one PaymentIntent, and is now being charged again. The same error also covers a PaymentMethod that was detached, which is why the message names both histories.
Can I attach an orphaned PaymentMethod after the fact?
Only if it has not been used yet. An unattached PaymentMethod that has never been confirmed can still be attached with POST /v1/payment_methods/{id}/attach. Once a PaymentIntent has consumed it, nothing brings it back and the customer has to enter the card again.
Is setup_future_usage=off_session or on_session the right value?
off_session if you will ever charge the card when the customer is not there, which includes every subscription renewal and every retry. on_session records consent only for customer-present reuse, and unattended charges against it are more likely to be declined for authentication.
Why not just call attach every time?
It works, but it skips what setup_future_usage does at confirmation. Passing the parameter tells the issuer during authorization that the credential is being stored, which is what makes later off-session charges succeed. A bare attach saves the card without that signal.
Does listing PaymentMethods without a customer parameter work?
Yes. The customer parameter on GET /v1/payment_methods is optional; omitting it lists the account-wide population, which is the only way to see the orphans at all, since by definition they belong to no customer to filter by.
Related field notes
- SetupIntents are created but never confirmed by the client
- Saved cards are already expired but still attached
- Active subscriptions with nothing to charge on renewal
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.
- List PaymentMethods — Stripe API reference
- Attach a PaymentMethod to a Customer — Stripe API reference
- Save a payment method during payment — Stripe Docs
- The PaymentMethod object — Stripe API reference
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.