Diagnostic Stripe
charges have a null payment_intent, which means the legacy Charges API
European card volume declines at two or three times the rate of everything else, and nobody can find a cause. The cards are good. Radar is not blocking them. And 3D Secure never appears — not on a single one of these payments, ever, on any card from any issuer. That last detail is the whole diagnosis.
Paginate GET /v1/charges and count the charges whose payment_intent is null. Those were created by POST /v1/charges with a token or a source. That API predates SCA and has no way to run 3D Secure, so an issuer that requires authentication has nothing to accept and declines.
Then measure the damage rather than the count. Inside that same subset, count outcome.reason == "authentication_required" and compare the decline rate against the charges that do carry a PaymentIntent id. The gap between those two numbers is what the legacy path costs you per month.
The problem in plain words
Nothing about this looks like a bug. The code works, has worked for years, and still succeeds for most of your customers. Cards from the United States clear normally, because their issuers rarely demand authentication. The failures cluster in Europe, India and the UK, which reads to most teams as a fraud pattern or a bad BIN range rather than as a missing capability.
It is also invisible in the place people look first. The Dashboard shows a payment, an amount and a decline, and the decline message is the issuer's, not Stripe's. There is no warning banner on a charge saying that the API used to create it cannot authenticate. The only visible tell is a field that is null, and a null field is exactly the kind of thing an eye slides over.
Why it happens
The Charges API cannot perform 3D Secure at all. Authentication is a step in the PaymentIntent lifecycle: the intent moves to requires_action, the client completes the challenge, the intent is confirmed. A direct charge has no such state. When the issuer says authenticate, the only thing the legacy path can do is fail, and it fails with outcome.reason of authentication_required.
Retrying makes it worse, not better. The natural reaction to a decline is to try again, and a retry on the same source declines identically because nothing about the second attempt can authenticate either. Teams then add a backoff, which turns one decline into four, which is visible to the issuer as repeated failed attempts against the same card.
Half-migrated is the normal state. Almost nobody is entirely on the old API. Checkout was moved to PaymentIntents years ago; a renewal job, an admin "charge this customer" button and an internal reconciliation script were not, because they worked. So the legacy cohort is small, specific and easy to overlook, and it is usually the recurring one — the payments that matter most.
A null payment_intent is the cleanest signal you will get. Every charge created through a PaymentIntent carries that id, including ones created by invoices, subscriptions and Checkout. So the null set is not a proxy for the legacy path; it is the legacy path, countable in one paginated read.
The fix, as a flow
The script sorts every charge by the API that made it, then splits the declines, because only one decline reason is caused by the API and the rest would have happened either way.
How to fix it
Count the null-payment_intent charges, not the failures
Start with exposure rather than symptoms. Page GET /v1/charges over the last 90 days and count where payment_intent is null, as a share of all charges and as a sum of amount. That number tells you how much traffic is on a path that cannot authenticate, whether or not it has bitten yet.
Treat an absent key the same as an explicit null
Depending on the API version and the expansion you asked for, the field can come back as null or not come back at all. A check written as if charge.payment_intent is None catches both; one written as if "payment_intent" in charge catches neither reliably.
Separate authentication_required from every other decline
outcome.reason == "authentication_required" is the decline that proves the diagnosis. Other declines — insufficient_funds, card_declined — would have happened on the modern path too and are not evidence of anything. Report them separately or you will overstate the problem and get argued out of the migration.
Compare the two cohorts before writing the ticket
The persuasive number is a ratio, not a count: the decline rate among null-payment_intent charges against the decline rate among the rest, over the same window and the same currencies. A legacy path declining at 9% against a modern path at 3% is a migration; the same path declining at 3.1% is a cleanup task for next quarter.
Check the companion signal on the customers
Integrations on the legacy charge path usually store cards the legacy way too, under customer.sources. Those cards have to be dealt with before or during the cutover, because a PaymentIntent will not accept a card_ id where it expects a pm_ one.
How to check it worked
Re-run the script over the same window. The legacy count should be falling towards zero as call sites move across, and the authentication_required count should reach zero first, because the migrated paths can authenticate.
python3 stripe_legacy_charges.py --days 90
# 18,204 charge(s): 18,204 modern, 0 legacy, 0 declined for authentication
The full code
One paginated GET over /v1/charges and nothing else — a restricted key with read access to Charges is enough. The classifier is pure and takes a single charge, so the difference between this was going to decline anyway and this declined because the API cannot authenticate is a rule you can read rather than an aggregate you have to trust.
"""Report Stripe charges created without a PaymentIntent, and what it costs.
Read only. One paginated GET and no writes: give this a RESTRICTED key with read
access to Charges. 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_legacy_charges")
API = "https://api.stripe.com/v1"
def classify(charge):
"""Sort one charge by the API that created it and by what the issuer did.
Pure, so the rules can be tested without a network.
A charge created through a PaymentIntent carries that intent's id, whatever
made it: Checkout, an invoice, a subscription renewal, a direct confirm. A
null or absent `payment_intent` therefore is not a proxy for the legacy
Charges API, it is the legacy Charges API.
Returns (state, detail).
"""
if charge.get("payment_intent"):
return ("modern", "created through a PaymentIntent")
status = charge.get("status")
outcome = charge.get("outcome") or {}
reason = outcome.get("reason")
if status == "succeeded":
return ("legacy",
"succeeded on the legacy Charges API: no 3D Secure was possible "
"on this payment, and none was attempted")
if reason == "authentication_required":
return ("unauthenticated",
"declined for authentication_required: the Charges API cannot "
"run 3D Secure, so retrying the same source declines again")
if status == "failed":
return ("legacy_declined",
"legacy charge declined (%s): this one would likely have failed "
"on the modern path too" % (reason or "no outcome.reason"))
if status == "pending":
return ("legacy_pending",
"legacy charge still pending: an asynchronous method on an API "
"with no intent to track it")
return ("unknown", "unrecognised charge status: %r" % (status,))
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 charges(session, since, cap):
"""Yield charges newest first, paginating until Stripe stops or the cap is hit."""
seen = 0
params = {"limit": 100, "created[gte]": since}
while True:
page = get(session, "/charges", **params)
data = page.get("data", [])
for ch in data:
yield ch
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 read charges")
ap.add_argument("--max-charges", type=int, default=20000,
help="stop paginating after this many charges")
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, amounts = {}, {}
scanned = 0
for ch in charges(s, since, args.max_charges):
scanned += 1
state, detail = classify(ch)
counts[state] = counts.get(state, 0) + 1
amounts[state] = amounts.get(state, 0) + (ch.get("amount") or 0)
if state in ("unauthenticated", "unknown"):
log.warning("%s %-15s %s", ch.get("id", "ch_?"), state, detail)
legacy_states = ("legacy", "unauthenticated", "legacy_declined", "legacy_pending")
legacy = sum(counts.get(k, 0) for k in legacy_states)
blocked = counts.get("unauthenticated", 0)
log.info("%d charge(s): %d modern, %d legacy, %d declined for authentication",
scanned, counts.get("modern", 0), legacy, blocked)
if legacy and scanned:
log.warning(" %.1f%% of charges have no PaymentIntent, %d minor unit(s) "
"of volume on an API that cannot authenticate",
100.0 * legacy / scanned,
sum(amounts.get(k, 0) for k in legacy_states))
if blocked:
log.warning(" %d of those were declined for authentication_required. "
"A retry on the same source declines again.", blocked)
if legacy:
log.warning(" repair: replace POST %s/charges -d source=tok_... with", API)
log.warning(" POST %s/payment_intents -d amount=... -d currency=... "
"-d customer=cus_... -d payment_method=pm_... -d confirm=true", API)
log.warning(" and handle requires_action on the client. Convert stored "
"card_ sources to PaymentMethods before cutting over.")
return 1 if legacy else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Report Stripe charges created without a PaymentIntent, and what it costs.
*
* Read only. One paginated GET and no writes: give this a RESTRICTED key with
* read access to Charges. The repair is printed, never performed.
*/
const API = 'https://api.stripe.com/v1';
/**
* Sort one charge by the API that created it and by what the issuer did. Pure,
* so the rules can be tested without a network.
*
* A charge created through a PaymentIntent carries that intent's id, whatever
* made it. A null or absent payment_intent is the legacy Charges API itself,
* not a proxy for it. Returns [state, detail].
*/
export function classify(charge) {
if (charge.payment_intent) return ['modern', 'created through a PaymentIntent'];
const status = charge.status;
const outcome = charge.outcome ?? {};
const reason = outcome.reason ?? null;
if (status === 'succeeded') {
return ['legacy',
'succeeded on the legacy Charges API: no 3D Secure was possible on this ' +
'payment, and none was attempted'];
}
if (reason === 'authentication_required') {
return ['unauthenticated',
'declined for authentication_required: the Charges API cannot run 3D ' +
'Secure, so retrying the same source declines again'];
}
if (status === 'failed') {
return ['legacy_declined',
`legacy charge declined (${reason ?? 'no outcome.reason'}): this one ` +
'would likely have failed on the modern path too'];
}
if (status === 'pending') {
return ['legacy_pending',
'legacy charge still pending: an asynchronous method on an API with no ' +
'intent to track it'];
}
return ['unknown', `unrecognised charge status: ${JSON.stringify(status)}`];
}
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* charges(key, since, cap = 20000) {
let seen = 0;
const params = { limit: 100, 'created[gte]': since };
for (;;) {
const page = await get(key, '/charges', params);
const data = page.data ?? [];
for (const ch of data) {
yield ch;
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 since = Math.floor(Date.now() / 1000) - days * 86400;
const counts = {};
const amounts = {};
let scanned = 0;
for await (const ch of charges(key, since)) {
scanned += 1;
const [state, detail] = classify(ch);
counts[state] = (counts[state] ?? 0) + 1;
amounts[state] = (amounts[state] ?? 0) + (ch.amount ?? 0);
if (state === 'unauthenticated' || state === 'unknown') {
console.warn(`${ch.id ?? 'ch_?'} ${state.padEnd(15)} ${detail}`);
}
}
const legacyStates = ['legacy', 'unauthenticated', 'legacy_declined', 'legacy_pending'];
const legacy = legacyStates.reduce((n, k) => n + (counts[k] ?? 0), 0);
const blocked = counts.unauthenticated ?? 0;
console.log(`${scanned} charge(s): ${counts.modern ?? 0} modern, ${legacy} ` +
`legacy, ${blocked} declined for authentication`);
if (legacy && scanned) {
const volume = legacyStates.reduce((n, k) => n + (amounts[k] ?? 0), 0);
console.warn(` ${(100 * legacy / scanned).toFixed(1)}% of charges have no ` +
`PaymentIntent, ${volume} minor unit(s) of volume on an API ` +
'that cannot authenticate');
}
if (blocked) {
console.warn(` ${blocked} of those were declined for authentication_required. ` +
'A retry on the same source declines again.');
}
if (legacy) {
console.warn(` repair: replace POST ${API}/charges -d source=tok_... with`);
console.warn(` POST ${API}/payment_intents -d amount=... -d currency=... ` +
'-d customer=cus_... -d payment_method=pm_... -d confirm=true');
console.warn(' and handle requires_action on the client. Convert stored ' +
'card_ sources to PaymentMethods before cutting over.');
process.exitCode = 1;
}
}
// Only run when invoked directly. The test file imports this module, and without
// the guard main() would run there too, fail on the missing key, and set a
// non-zero exit code that fails the whole test file even as every test passes.
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
Two cases carry the note. A charge with no payment_intent key at all has to sort as legacy rather than modern, because the field is sometimes absent rather than null and the wrong test reports a clean account. And an authentication_required decline has to be its own state, separate from an ordinary decline, because only one of the two is caused by the API you are using.
from stripe_legacy_charges import classify
def test_charge_with_an_intent_is_modern():
state, _ = classify({"payment_intent": "pi_123", "status": "succeeded"})
assert state == "modern"
def test_absent_payment_intent_key_is_legacy_not_modern():
# The field is sometimes absent rather than null. A membership test would
# report a clean account here.
state, detail = classify({"status": "succeeded"})
assert state == "legacy"
assert "3D Secure" in detail
def test_authentication_required_is_its_own_state():
state, detail = classify({
"payment_intent": None,
"status": "failed",
"outcome": {"type": "issuer_declined", "reason": "authentication_required"},
})
assert state == "unauthenticated"
assert "declines again" in detail
def test_ordinary_decline_is_not_blamed_on_the_legacy_api():
state, detail = classify({
"payment_intent": None,
"status": "failed",
"outcome": {"type": "issuer_declined", "reason": "insufficient_funds"},
})
assert state == "legacy_declined"
assert "insufficient_funds" in detail
def test_unrecognised_status_is_not_silently_counted_as_modern():
state, _ = classify({"payment_intent": None, "status": "reversed"})
assert state == "unknown"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { classify } from './stripe-legacy-charges.mjs';
test('charge with an intent is modern', () => {
assert.equal(classify({ payment_intent: 'pi_123', status: 'succeeded' })[0], 'modern');
});
test('absent payment_intent key is legacy, not modern', () => {
const [state, detail] = classify({ status: 'succeeded' });
assert.equal(state, 'legacy');
assert.match(detail, /3D Secure/);
});
test('authentication_required is its own state', () => {
const [state, detail] = classify({
payment_intent: null,
status: 'failed',
outcome: { type: 'issuer_declined', reason: 'authentication_required' },
});
assert.equal(state, 'unauthenticated');
assert.match(detail, /declines again/);
});
test('an ordinary decline is not blamed on the legacy API', () => {
const [state, detail] = classify({
payment_intent: null,
status: 'failed',
outcome: { type: 'issuer_declined', reason: 'insufficient_funds' },
});
assert.equal(state, 'legacy_declined');
assert.match(detail, /insufficient_funds/);
});
test('unrecognised status is not silently counted as modern', () => {
assert.equal(classify({ payment_intent: null, status: 'reversed' })[0], 'unknown');
});
FAQ
Does a null payment_intent always mean the legacy Charges API?
In practice yes. Every charge created through a PaymentIntent carries that intent's id, including charges made by Checkout, invoices and subscription renewals. So the null set is the set of charges created by POST /v1/charges with a token or a source, which is the API that predates SCA.
Why do only European cards decline?
Because SCA is enforced by the issuer, and European, UK and Indian issuers enforce it. A US issuer that never asks for authentication is perfectly happy with a legacy charge, so the same code path succeeds there and fails in Europe, which is why this reads as a regional fraud pattern rather than an API problem.
Can I retry an authentication_required decline?
Not usefully. The retry runs through the same API, which still cannot present a 3D Secure challenge, so the issuer declines it for the same reason. The only fix is to create a PaymentIntent and handle requires_action on the client.
Do I have to migrate the stored cards at the same time?
You have to migrate them before the cutover. A PaymentIntent expects a pm_ id, and a card saved the old way is a card_ or src_ under customer.sources. Convert them to PaymentMethods first, or the migrated code path fails on customers it has always been able to charge.
Is the Charges API deprecated?
Stripe still accepts the calls, and that is the problem: nothing breaks loudly. Stripe's own guidance is that integrations still on it can see high decline rates from banks that enforce SCA, which is a slow revenue leak rather than an outage.
Related field notes
- Legacy card sources still live under customer.sources
- Off-session charges die on authentication_required
- 3DS handoff breaks and requires_action intents pile up
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.
- Older payments APIs — Stripe Docs
- Strong Customer Authentication — Stripe Docs
- The charge object — Stripe API reference
- Decline codes — Stripe Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.