Diagnostic Stripe
intents hardcode payment_method_types to card only
You turned on iDEAL, Bancontact, Klarna and Link in the Dashboard weeks ago. The toggles are still on. The Payment Element in production still renders one card form, and conversion in the Netherlands is still flat. Nothing is broken and no error is thrown — the intent told Stripe exactly which methods to offer, and it named one.
Read GET /v1/payment_intents and look for automatic_payment_methods of null together with payment_method_types equal to ["card"]. That pair means the create call passed an explicit list, and an explicit list turns dynamic payment methods off completely.
Then read GET /v1/payment_method_configurations and compare. Any method with available: true and display_preference.value of "on" that never appears on an intent is a method you switched on and are not offering.
The problem in plain words
The Dashboard is not lying to you and neither is the API. The methods really are enabled on the account. They are simply not being asked for, because payment_method_types is an allowlist and Stripe honours it exactly. When the array is present, the payment method configuration, the ordering models and every Dashboard toggle become inert for that intent.
What makes it durable is that the code looks correct. payment_method_types: ['card'] was the required parameter for years, it appears in every tutorial written before 2023, and it is the first thing an LLM will hand you if you ask for a PaymentIntent. Nothing about it reads as a mistake in review.
The half-migrated case is worse than the fully broken one. A checkout page gets moved to automatic_payment_methods while the subscription upgrade flow, the invoice pay page and the mobile app keep their hardcoded list. Wallets appear in one place and not another, which everybody attributes to browser support.
Why it happens
An explicit list is a promise Stripe keeps. There is no merge and no fallback. If you name the methods, you own the list, forever, in every currency and country you later sell into.
The old parameter still works. Stripe did not deprecate it, because plenty of integrations legitimately need to pin a method. So there is no warning, no deprecation notice in the response, and nothing in the logs.
Eligibility is silent too. Even with dynamic methods on, a method only renders when the currency, the country and the amount all qualify. That produces the same visible symptom as a hardcoded list, so the two have to be separated before you go changing code — which is what comparing the enabled set against the offered set actually does.
The fix, as a flow
The script reads two fields on every intent rather than one, because a dynamic intent in a card only market carries exactly the same payment_method_types as a hardcoded one.
How to fix it
Sample the intents you actually create
GET /v1/payment_intents?limit=100&created[gte]={now-30d}, paginated. A month is enough to cover every code path that creates an intent, including the ones that only run at renewal.
Read both fields, not one
payment_method_types is always populated, even on a dynamic intent, because Stripe fills it with whatever it resolved. It is automatic_payment_methods being null that proves the list was passed in rather than computed. Judging on the types alone flags healthy intents that happened to resolve to card.
Ask what the account thinks is enabled
GET /v1/payment_method_configurations. Each method appears as its own object with available and a display_preference. Use display_preference.value, which is the resolved setting, rather than display_preference.preference, which is only what was requested.
Subtract, and read the remainder carefully
Methods that are enabled and never offered are the finding. If most intents are hardcoded, that is the cause. If none are, the same remainder means an eligibility mismatch instead — wrong currency, unsupported country, amount below the method's minimum — and the fix is a different one.
Migrate every creation site, not the busiest one
Drop the array and pass automatic_payment_methods[enabled]=true. If you need to suppress a method for one transaction, use excluded_payment_method_types rather than reinstating an allowlist, so new methods still arrive by default.
How to check it worked
Re-run the script after the migration has been through a release. The hardcoded count should fall to zero and the unused list should empty as real traffic exercises each method.
python3 stripe_payment_method_coverage.py --days 7
# healthy every enabled method reaches at least one intent
The full code
Two GET requests and no writes: a restricted key with read access to PaymentIntents and Payment Method Configurations is enough. The interesting part is that the script separates two problems with the same symptom, so the three pure functions are kept apart — one decides whether an intent was hardcoded, one reads the account's enabled set, and one weighs them against each other.
"""Report PaymentIntents that pin payment_method_types instead of using
dynamic payment methods, and enabled methods that never reach a customer.
Read only. Two GET requests, no writes: give this a RESTRICTED key with read
access to PaymentIntents and Payment Method Configurations. The repair is
printed, never performed.
"""
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_payment_method_coverage")
API = "https://api.stripe.com/v1"
# Sorted lists, because the comparison below sorts before matching. "card" alone
# is the classic tutorial line; "card" plus "link" is what it becomes after Link
# is switched on and the array is edited rather than removed.
CARD_ONLY = (["card"], ["card", "link"])
def is_card_only(intent):
"""True when this intent pinned an explicit card-only method list. Pure.
`payment_method_types` is populated on every intent, dynamic or not, because
Stripe fills it with whatever it resolved. Only `automatic_payment_methods`
being absent proves the list was passed in by the caller, so both fields have
to be read: judging on the types alone flags healthy intents that happened to
resolve to card.
"""
if intent.get("automatic_payment_methods"):
return False
return sorted(intent.get("payment_method_types") or []) in CARD_ONLY
def enabled_methods(configs):
"""Method names that are available and switched on for this account. Pure.
A configuration carries one sub-object per method alongside ordinary metadata
fields, so the method entries are found by shape rather than by name. Read
`display_preference.value`, the resolved setting, not `preference`, which is
only what was asked for and can still resolve to off.
"""
out = set()
for cfg in configs:
for name, val in cfg.items():
if not isinstance(val, dict):
continue
pref = val.get("display_preference") or {}
if val.get("available") and pref.get("value") == "on":
out.add(name)
return out
def verdict(stats, enabled):
"""Weigh the intents against the account's enabled methods. Pure.
stats: {"intents": n, "card_only": n, "offered": iterable of method names}.
enabled: the set from enabled_methods().
"""
total = stats.get("intents", 0)
if not total:
return ("no_data", "no PaymentIntents in the window: nothing to judge")
card_only = stats.get("card_only", 0)
unused = sorted(enabled - set(stats.get("offered") or ()))
if card_only >= total * 0.8:
return ("hardcoded",
"%d of %d intent(s) pin payment_method_types to card, so dynamic "
"payment methods are bypassed. Enabled and never offered: %s"
% (card_only, total, ", ".join(unused) or "(nothing else)"))
if card_only:
return ("partial",
"%d of %d intent(s) still pin payment_method_types: one creation "
"site was migrated and another was not" % (card_only, total))
if unused:
return ("unused",
"dynamic methods are on everywhere, but %s never appeared on an "
"intent: check currency, country and amount eligibility"
% ", ".join(unused))
return ("healthy", "every enabled method reaches at least one intent")
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")
if r.status_code == 403:
raise SystemExit("403 from Stripe: the restricted key lacks read access to " + path)
r.raise_for_status()
return r.json()
def sample_intents(session, since, cap):
"""Walk recent intents and tally what they pinned and what they offered."""
stats = {"intents": 0, "card_only": 0}
offered = set()
params = {"limit": 100, "created[gte]": since}
while True:
page = get(session, "/payment_intents", **params)
rows = page.get("data", [])
for pi in rows:
stats["intents"] += 1
if is_card_only(pi):
stats["card_only"] += 1
offered.update(pi.get("payment_method_types") or [])
if not rows or not page.get("has_more") or stats["intents"] >= cap:
break
params["starting_after"] = rows[-1]["id"]
stats["offered"] = offered
return stats
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--days", type=int, default=30, help="how far back to sample")
ap.add_argument("--max-intents", type=int, default=2000,
help="stop sampling after this many intents")
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
stats = sample_intents(s, since, args.max_intents)
configs = get(s, "/payment_method_configurations", limit=100).get("data", [])
enabled = enabled_methods(configs)
state, detail = verdict(stats, enabled)
line = "%-9s %s" % (state, detail)
if state in ("healthy", "no_data"):
log.info(line)
return 0
log.warning(line)
if state in ("hardcoded", "partial"):
log.warning(" repair: drop payment_method_types from the create call")
log.warning(' repair: POST %s/payment_intents -d amount=1099 -d currency=eur '
'-d "automatic_payment_methods[enabled]=true"', API)
log.warning(" repair: use excluded_payment_method_types for one-off "
"exclusions rather than an allowlist")
else:
log.warning(" repair: confirm currency, country and amount eligibility at "
"https://dashboard.stripe.com/settings/payment_methods")
log.info("sampled %d intent(s); offered %d method(s); enabled %d",
stats["intents"], len(stats.get("offered") or ()), len(enabled))
return 1
if __name__ == "__main__":
sys.exit(main())
/**
* Report PaymentIntents that pin payment_method_types instead of using dynamic
* payment methods, and enabled methods that never reach a customer.
*
* Read only. Two GET requests, no writes: give this a RESTRICTED key with read
* access to PaymentIntents and Payment Method Configurations. The repair is
* printed, never performed.
*/
const API = 'https://api.stripe.com/v1';
// "card" alone is the classic tutorial line; "card" plus "link" is what it
// becomes after Link is switched on and the array is edited rather than removed.
const CARD_ONLY = new Set(['card', 'card,link']);
/**
* True when this intent pinned an explicit card-only method list. Pure.
*
* Both fields matter: payment_method_types is populated on every intent, so only
* a missing automatic_payment_methods proves the list was passed in.
*/
export function isCardOnly(intent) {
if (intent.automatic_payment_methods) return false;
const types = [...(intent.payment_method_types ?? [])].sort();
return CARD_ONLY.has(types.join(','));
}
/**
* Method names that are available and switched on for this account. Pure.
*
* Read display_preference.value, the resolved setting, not preference.
*/
export function enabledMethods(configs) {
const out = new Set();
for (const cfg of configs) {
for (const [name, val] of Object.entries(cfg)) {
if (!val || typeof val !== 'object' || Array.isArray(val)) continue;
const pref = val.display_preference ?? {};
if (val.available && pref.value === 'on') out.add(name);
}
}
return out;
}
/**
* Weigh the intents against the account's enabled methods. Pure.
*/
export function verdict(stats, enabled) {
const total = stats.intents ?? 0;
if (!total) return ['no_data', 'no PaymentIntents in the window: nothing to judge'];
const cardOnly = stats.card_only ?? 0;
const offered = new Set(stats.offered ?? []);
const unused = [...enabled].filter((m) => !offered.has(m)).sort();
if (cardOnly >= total * 0.8) {
return ['hardcoded',
`${cardOnly} of ${total} intent(s) pin payment_method_types to card, so ` +
'dynamic payment methods are bypassed. Enabled and never offered: ' +
(unused.join(', ') || '(nothing else)')];
}
if (cardOnly) {
return ['partial',
`${cardOnly} of ${total} intent(s) still pin payment_method_types: one ` +
'creation site was migrated and another was not'];
}
if (unused.length) {
return ['unused',
`dynamic methods are on everywhere, but ${unused.join(', ')} never ` +
'appeared on an intent: check currency, country and amount eligibility'];
}
return ['healthy', 'every enabled method reaches at least one intent'];
}
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.status === 403) {
throw new Error(`403 from Stripe: the restricted key lacks read access to ${path}`);
}
if (!res.ok) throw new Error(`${res.status} from ${url.pathname}`);
return res.json();
}
export async function sampleIntents(key, since, cap = 2000) {
const stats = { intents: 0, card_only: 0 };
const offered = new Set();
const params = { limit: 100, 'created[gte]': since };
for (;;) {
const page = await get(key, '/payment_intents', params);
const rows = page.data ?? [];
for (const pi of rows) {
stats.intents += 1;
if (isCardOnly(pi)) stats.card_only += 1;
for (const t of pi.payment_method_types ?? []) offered.add(t);
}
if (!rows.length || !page.has_more || stats.intents >= cap) break;
params.starting_after = rows[rows.length - 1].id;
}
stats.offered = offered;
return stats;
}
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 since = Math.floor(Date.now() / 1000) - 30 * 86400;
const stats = await sampleIntents(key, since);
const { data: configs = [] } = await get(key, '/payment_method_configurations',
{ limit: 100 });
const enabled = enabledMethods(configs);
const [state, detail] = verdict(stats, enabled);
const line = `${state.padEnd(9)} ${detail}`;
if (state === 'healthy' || state === 'no_data') { console.log(line); return; }
console.warn(line);
if (state === 'hardcoded' || state === 'partial') {
console.warn(' repair: drop payment_method_types from the create call');
console.warn(` repair: POST ${API}/payment_intents -d amount=1099 ` +
'-d currency=eur -d "automatic_payment_methods[enabled]=true"');
console.warn(' repair: use excluded_payment_method_types for one-off ' +
'exclusions rather than an allowlist');
} else {
console.warn(' repair: confirm currency, country and amount eligibility at ' +
'https://dashboard.stripe.com/settings/payment_methods');
}
console.log(`sampled ${stats.intents} intent(s); offered ${stats.offered.size} ` +
`method(s); enabled ${enabled.size}`);
process.exitCode = 1;
}
// Only run when invoked directly, so importing this module from the test file
// does not execute main() and fail the suite on the missing key.
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
The test that matters is the dynamic intent that resolved to card anyway. It has payment_method_types: ["card"] just like the hardcoded one, and a check that reads only that field reports a healthy integration as broken and sends someone to rewrite code that was already right. The other one worth pinning is the split between hardcoded and unused, because they share a symptom and have different fixes.
from stripe_payment_method_coverage import is_card_only, enabled_methods, verdict
def test_bare_card_list_is_hardcoded():
assert is_card_only({"payment_method_types": ["card"]})
def test_card_plus_link_is_still_hardcoded():
# Link gets added to the array instead of the array being removed.
assert is_card_only({"payment_method_types": ["link", "card"]})
def test_dynamic_intent_that_resolved_to_card_is_not_hardcoded():
# The whole point: same types, different origin.
assert not is_card_only({
"automatic_payment_methods": {"enabled": True},
"payment_method_types": ["card"],
})
def test_a_longer_explicit_list_is_not_flagged():
assert not is_card_only({"payment_method_types": ["card", "ideal"]})
def test_enabled_methods_ignores_metadata_and_off_methods():
configs = [{
"id": "pmc_1", "object": "payment_method_configuration", "name": "default",
"card": {"available": True, "display_preference": {"value": "on"}},
"ideal": {"available": True, "display_preference": {"value": "off"}},
"klarna": {"available": False, "display_preference": {"value": "on"}},
}]
assert enabled_methods(configs) == {"card"}
def test_mostly_hardcoded_names_the_methods_going_to_waste():
stats = {"intents": 100, "card_only": 95, "offered": ["card"]}
state, detail = verdict(stats, {"card", "ideal", "klarna"})
assert state == "hardcoded"
assert "ideal, klarna" in detail
def test_a_minority_is_a_half_finished_migration():
stats = {"intents": 100, "card_only": 12, "offered": ["card", "ideal"]}
state, _ = verdict(stats, {"card", "ideal"})
assert state == "partial"
def test_nothing_hardcoded_but_a_method_never_offered_is_eligibility():
stats = {"intents": 100, "card_only": 0, "offered": ["card"]}
state, detail = verdict(stats, {"card", "klarna"})
assert state == "unused"
assert "eligibility" in detail
def test_full_coverage_is_healthy():
stats = {"intents": 40, "card_only": 0, "offered": ["card", "ideal"]}
assert verdict(stats, {"card", "ideal"})[0] == "healthy"
def test_an_empty_window_is_not_reported_as_healthy():
assert verdict({"intents": 0}, {"card"})[0] == "no_data"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { isCardOnly, enabledMethods, verdict } from './stripe-payment-method-coverage.mjs';
test('bare card list is hardcoded', () => {
assert.equal(isCardOnly({ payment_method_types: ['card'] }), true);
});
test('card plus link is still hardcoded', () => {
assert.equal(isCardOnly({ payment_method_types: ['link', 'card'] }), true);
});
test('dynamic intent that resolved to card is not hardcoded', () => {
assert.equal(isCardOnly({
automatic_payment_methods: { enabled: true },
payment_method_types: ['card'],
}), false);
});
test('a longer explicit list is not flagged', () => {
assert.equal(isCardOnly({ payment_method_types: ['card', 'ideal'] }), false);
});
test('enabledMethods ignores metadata and off methods', () => {
const configs = [{
id: 'pmc_1', object: 'payment_method_configuration', name: 'default',
card: { available: true, display_preference: { value: 'on' } },
ideal: { available: true, display_preference: { value: 'off' } },
klarna: { available: false, display_preference: { value: 'on' } },
}];
assert.deepEqual([...enabledMethods(configs)], ['card']);
});
test('mostly hardcoded names the methods going to waste', () => {
const stats = { intents: 100, card_only: 95, offered: ['card'] };
const [state, detail] = verdict(stats, new Set(['card', 'ideal', 'klarna']));
assert.equal(state, 'hardcoded');
assert.match(detail, /ideal, klarna/);
});
test('a minority is a half finished migration', () => {
const stats = { intents: 100, card_only: 12, offered: ['card', 'ideal'] };
assert.equal(verdict(stats, new Set(['card', 'ideal']))[0], 'partial');
});
test('nothing hardcoded but a method never offered is eligibility', () => {
const stats = { intents: 100, card_only: 0, offered: ['card'] };
const [state, detail] = verdict(stats, new Set(['card', 'klarna']));
assert.equal(state, 'unused');
assert.match(detail, /eligibility/);
});
test('full coverage is healthy', () => {
const stats = { intents: 40, card_only: 0, offered: ['card', 'ideal'] };
assert.equal(verdict(stats, new Set(['card', 'ideal']))[0], 'healthy');
});
test('an empty window is not reported as healthy', () => {
assert.equal(verdict({ intents: 0 }, new Set(['card']))[0], 'no_data');
});
FAQ
Why does my Payment Element only show a card form?
Because the PaymentIntent named its methods. If payment_method_types was passed to the create call, Stripe offers exactly that list and dynamic payment methods are bypassed, so the Dashboard toggles have no effect on that intent.
How do I tell a hardcoded intent from a dynamic one?
By automatic_payment_methods, not by the types. Stripe populates payment_method_types on every intent with whatever it resolved, so a dynamic intent in a card-only market looks identical. Only automatic_payment_methods being null proves the caller passed the list.
Is payment_method_types deprecated?
No, and that is why nothing warns you. Pinning a method is legitimate when you genuinely want one, so Stripe keeps honouring the parameter. It is the wrong default, not a removed feature.
I removed the array and a method still does not appear. Why?
Eligibility. A method renders only when the currency, the country and the amount all qualify, and each method has its own rules. That is the unused state in the script: dynamic methods are on, the method is enabled, and it still never gets offered because the transaction does not qualify.
How do I drop one method for a single transaction?
Use excluded_payment_method_types on that create call. Reinstating an allowlist to exclude one method means every method Stripe adds later is excluded too, silently, forever.
Related field notes
- No payment method domain registered, so wallets never show
- 3DS handoff breaks and requires_action intents pile up
- PaymentIntents sit in requires_payment_method for weeks
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.
- Dynamic payment methods — Stripe Docs
- The payment method configuration object — Stripe API reference
- The PaymentIntent object — Stripe API reference
- API keys — Stripe Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.