Diagnostic Stripe
save_default_payment_method off orphans the card after payment
The signup works end to end. The customer enters a card, the first invoice is paid, the subscription goes active, and everyone moves on. A month later the renewal fails and the subscription has nothing on it to charge. The card that paid the first invoice is not gone — it was simply never made the subscription's default, because a flag nobody set defaults to off.
Page GET /v1/subscriptions?status=active&expand[]=data.customer and look for three things being true at once: payment_settings.save_default_payment_method is off or absent, default_payment_method on the subscription is null, and customer.invoice_settings.default_payment_method is null too. That subscription has nothing to charge at renewal.
Read past_due and unpaid in the same pass. Any subscription in that state with the same three conditions is not at risk; it has already failed.
The problem in plain words
The confusing part is that the first payment genuinely worked. A card was collected, confirmed against the first invoice's PaymentIntent, and charged. It just was not kept: paying an invoice does not attach the payment method to anything, and unless you asked for it to be saved, the subscription ends its first cycle with the same empty default_payment_method it started with.
Whether that matters depends entirely on the customer having a default set elsewhere. Integrations that also write invoice_settings.default_payment_method on the Customer never notice, because renewals quietly fall back to it. Integrations that do not have a working first month and a dead second one, and the bug reaches production because nobody waits a billing cycle before shipping.
Why it happens
The default is off, and an unset field reads as null. payment_settings.save_default_payment_method is not something you turn off; it is something you have to turn on. A subscription created without the parameter comes back with the field absent, which any check comparing against the string "off" will treat as fine.
Paying an invoice is not saving a card. Confirming the first invoice's PaymentIntent charges the card and finishes. Storing it for reuse is a separate instruction, and the subscription-level way to give it is this flag. Doing it by hand afterwards means listening for the right event and writing the payment method back, which is more code and more places to fail.
The fallback masks the bug until it does not. When the Customer has an invoice_settings.default_payment_method, renewals work and the flag never matters. Change the signup flow so the Customer default is no longer written — a refactor, a new plan, a Checkout migration — and every subscription created after that date fails at its second invoice while the older ones carry on fine.
The gap between cause and symptom is a full billing period. Monthly plans give you thirty days, annual plans give you a year. Nothing in the signup logs, the tests or the Dashboard hints at it during that time, because at every moment before the renewal the subscription is genuinely healthy.
The fix, as a flow
The script reads the flag, the subscription default and the customer default together, because the flag being off only matters when neither default exists to catch the renewal.
How to fix it
Read the flag and both defaults in one pass
Expand the customer: GET /v1/subscriptions?status=active&limit=100&expand[]=data.customer. You need payment_settings.save_default_payment_method, default_payment_method and customer.invoice_settings.default_payment_method together, and expanding avoids a request per subscription.
Treat an absent flag as off
Stripe omits the field when it was never set, which is the overwhelmingly common case. A classifier that only recognises the literal string "off" will report every affected subscription as fine, which is the single most likely way to write this check and get a clean bill of health from a broken account.
Separate the ones that are covered from the ones that are exposed
The flag being off is not itself a fault. A subscription with a default_payment_method already set is fine; one with a customer-level default will fall back and also be fine. Only the subscription with neither has nothing to charge, and that is the list worth acting on.
Page past_due and unpaid as well as active
status=active shows you the ones that are about to fail. The same three conditions on a past_due or unpaid subscription mean it already has, and those need an email to the customer rather than a code change.
Set it at creation, and fix the existing ones in place
New subscriptions: POST /v1/subscriptions with payment_settings[save_default_payment_method]=on_subscription. Existing ones are updatable, including while incomplete, so setting it before the next invoice is enough for anything that has not yet renewed. Subscriptions already past_due need a payment method attached too; the flag alone has nothing to save.
How to check it worked
Re-run the script. Every active subscription should classify as on or saved, with none left stranded.
python3 stripe_save_default_pm.py
# 318 subscription(s), 0 stranded, 0 already failing
The full code
One paginated GET per status and no writes — a restricted key with read access to Subscriptions and Customers is enough, and is what you should give it. The classifier takes a whole subscription rather than a set of counts, because the interesting cases are the shapes the object can arrive in: the flag absent rather than "off", and the customer left unexpanded, which looks exactly like a customer with no default unless you check.
"""Report Stripe subscriptions that will have nothing to charge at renewal.
Read only. One paginated GET per status, no writes: give this a RESTRICTED key
with read access to Subscriptions and Customers. 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_save_default_pm")
API = "https://api.stripe.com/v1"
FAILED_STATUSES = ("past_due", "unpaid")
def verdict(subscription):
"""Classify one subscription. Pure, so the rules can be tested without a network.
Returns (state, detail). The subscription's `customer` must be expanded; when
it is only an id there is no way to know whether a customer-level default
exists, and guessing either way is worse than saying so.
"""
settings = subscription.get("payment_settings") or {}
save = settings.get("save_default_payment_method")
# Absent is the same as "off": Stripe omits the field when it was never set,
# which is how almost every affected subscription actually looks.
if save not in (None, "off", "on_subscription"):
return ("unknown", "unrecognised save_default_payment_method %r" % (save,))
if save == "on_subscription":
return ("on", "the card that pays an invoice becomes the subscription default")
if subscription.get("default_payment_method"):
return ("saved", "the flag is off, but a default is already set on the "
"subscription")
customer = subscription.get("customer")
if not isinstance(customer, dict):
return ("unknown",
"customer is not expanded, so the fallback default cannot be read; "
"re-read with expand[]=data.customer")
invoice_settings = customer.get("invoice_settings") or {}
if invoice_settings.get("default_payment_method"):
return ("fallback",
"nothing on the subscription; renewals fall back to the customer "
"default. Working, and one refactor away from not working.")
if subscription.get("status") in FAILED_STATUSES:
return ("failing",
"status %s with no payment method on the subscription and none on "
"the customer. The renewal has already failed."
% subscription.get("status"))
return ("stranded",
"no payment method on the subscription and none on the customer. The "
"next renewal has nothing to charge.")
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):
seen = 0
params = dict(params, limit=100)
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:
break
params["starting_after"] = data[-1]["id"]
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--max-subscriptions", type=int, default=5000,
help="stop paginating each status after this many subscriptions")
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 = {}
flagged = []
total = 0
for status in ("active",) + FAILED_STATUSES:
for sub in page_all(s, "/subscriptions", args.max_subscriptions,
status=status, **{"expand[]": "data.customer"}):
total += 1
state, detail = verdict(sub)
counts[state] = counts.get(state, 0) + 1
if state in ("stranded", "failing", "unknown"):
flagged.append((state, sub["id"], detail))
if not total:
log.info("no subscriptions for this key's mode")
return 0
for state, sub_id, detail in flagged[:25]:
log.warning("%-9s %s %s", state, sub_id, detail)
log.warning(" repair: POST %s/subscriptions/%s "
"-d \"payment_settings[save_default_payment_method]=on_subscription\"",
API, sub_id)
bad = counts.get("stranded", 0) + counts.get("failing", 0)
if counts.get("fallback"):
log.info("%d subscription(s) rely on the customer default; they work until "
"the signup flow stops writing it", counts["fallback"])
log.info("%d subscription(s), %d stranded, %d already failing",
total, counts.get("stranded", 0), counts.get("failing", 0))
if not bad:
return 0
log.warning("set it at creation so this stops recurring:")
log.warning(" POST %s/subscriptions "
"-d \"payment_settings[save_default_payment_method]=on_subscription\"",
API)
return 1
if __name__ == "__main__":
sys.exit(main())
/**
* Report Stripe subscriptions that will have nothing to charge at renewal.
*
* Read only. One paginated GET per status, no writes: give this a RESTRICTED key
* with read access to Subscriptions and Customers. The repair is printed, never
* performed.
*/
const API = 'https://api.stripe.com/v1';
export const FAILED_STATUSES = ['past_due', 'unpaid'];
/**
* Classify one subscription. Pure, so the rules can be tested without a network.
*
* The subscription's `customer` must be expanded; when it is only an id there is
* no way to know whether a customer-level default exists, and guessing either way
* is worse than saying so.
*/
export function verdict(subscription) {
const settings = subscription.payment_settings ?? {};
const save = settings.save_default_payment_method;
// Absent is the same as 'off': Stripe omits the field when it was never set,
// which is how almost every affected subscription actually looks.
if (save !== undefined && save !== null && save !== 'off' && save !== 'on_subscription') {
return ['unknown', `unrecognised save_default_payment_method ${JSON.stringify(save)}`];
}
if (save === 'on_subscription') {
return ['on', 'the card that pays an invoice becomes the subscription default'];
}
if (subscription.default_payment_method) {
return ['saved', 'the flag is off, but a default is already set on the subscription'];
}
const customer = subscription.customer;
if (!customer || typeof customer !== 'object') {
return ['unknown',
'customer is not expanded, so the fallback default cannot be read; ' +
're-read with expand[]=data.customer'];
}
if (customer.invoice_settings?.default_payment_method) {
return ['fallback',
'nothing on the subscription; renewals fall back to the customer default. ' +
'Working, and one refactor away from not working.'];
}
if (FAILED_STATUSES.includes(subscription.status)) {
return ['failing',
`status ${subscription.status} with no payment method on the subscription ` +
'and none on the customer. The renewal has already failed.'];
}
return ['stranded',
'no payment method on the subscription and none on the customer. The next ' +
'renewal has nothing to charge.'];
}
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* pageAll(key, path, limit, params = {}) {
let seen = 0;
const p = { ...params, limit: 100 };
for (;;) {
const page = await get(key, path, p);
const data = page.data ?? [];
for (const obj of data) { yield obj; seen += 1; }
if (data.length === 0 || !page.has_more || seen >= limit) break;
p.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 = new Map();
const flagged = [];
let total = 0;
for (const status of ['active', ...FAILED_STATUSES]) {
for await (const sub of pageAll(key, '/subscriptions', 5000,
{ status, 'expand[]': 'data.customer' })) {
total += 1;
const [state, detail] = verdict(sub);
counts.set(state, (counts.get(state) ?? 0) + 1);
if (state === 'stranded' || state === 'failing' || state === 'unknown') {
flagged.push([state, sub.id, detail]);
}
}
}
if (!total) {
console.log("no subscriptions for this key's mode");
return;
}
for (const [state, id, detail] of flagged.slice(0, 25)) {
console.warn(`${state.padEnd(9)} ${id} ${detail}`);
console.warn(` repair: POST ${API}/subscriptions/${id} ` +
'-d "payment_settings[save_default_payment_method]=on_subscription"');
}
const stranded = counts.get('stranded') ?? 0;
const failing = counts.get('failing') ?? 0;
if (counts.get('fallback')) {
console.log(`${counts.get('fallback')} subscription(s) rely on the customer ` +
'default; they work until the signup flow stops writing it');
}
console.log(`${total} subscription(s), ${stranded} stranded, ${failing} already failing`);
if (!(stranded + failing)) return;
console.warn('set it at creation so this stops recurring:');
console.warn(` POST ${API}/subscriptions ` +
'-d "payment_settings[save_default_payment_method]=on_subscription"');
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 of these tests exist because of how the object arrives rather than what it means. An absent save_default_payment_method is the same as "off" and is how nearly every affected subscription looks; an unexpanded customer is a string that a naive read turns into a false clean result. Both are the difference between a check that finds this and one that reports zero forever.
from stripe_save_default_pm import verdict
def sub(**over):
base = {"id": "sub_1", "status": "active", "payment_settings": {},
"default_payment_method": None,
"customer": {"id": "cus_1", "invoice_settings": {}}}
base.update(over)
return base
def test_an_absent_flag_is_treated_as_off():
# Stripe omits the field when it was never set. This is the common shape.
state, _ = verdict(sub(payment_settings={}))
assert state == "stranded"
def test_an_explicit_off_reaches_the_same_verdict():
assert verdict(sub(payment_settings={"save_default_payment_method": "off"}))[0] \
== "stranded"
def test_on_subscription_is_the_fix():
state, _ = verdict(sub(payment_settings={"save_default_payment_method": "on_subscription"}))
assert state == "on"
def test_a_subscription_default_makes_the_flag_moot():
assert verdict(sub(default_payment_method="pm_1"))[0] == "saved"
def test_a_customer_default_is_a_fallback_not_a_fix():
state, detail = verdict(sub(customer={"id": "cus_1",
"invoice_settings": {"default_payment_method": "pm_2"}}))
assert state == "fallback"
assert "refactor" in detail
def test_past_due_with_nothing_to_charge_has_already_failed():
state, _ = verdict(sub(status="past_due"))
assert state == "failing"
def test_an_unexpanded_customer_is_not_silently_stranded():
# A bare id looks exactly like a customer with no default. Say so instead.
state, detail = verdict(sub(customer="cus_1"))
assert state == "unknown"
assert "expand" in detail
def test_an_unrecognised_value_is_not_silently_healthy():
assert verdict(sub(payment_settings={"save_default_payment_method": "always"}))[0] \
== "unknown"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict } from './stripe-save-default-pm.mjs';
function sub(over = {}) {
return {
id: 'sub_1',
status: 'active',
payment_settings: {},
default_payment_method: null,
customer: { id: 'cus_1', invoice_settings: {} },
...over,
};
}
test('an absent flag is treated as off', () => {
assert.equal(verdict(sub({ payment_settings: {} }))[0], 'stranded');
});
test('an explicit off reaches the same verdict', () => {
assert.equal(
verdict(sub({ payment_settings: { save_default_payment_method: 'off' } }))[0],
'stranded');
});
test('on_subscription is the fix', () => {
assert.equal(
verdict(sub({ payment_settings: { save_default_payment_method: 'on_subscription' } }))[0],
'on');
});
test('a subscription default makes the flag moot', () => {
assert.equal(verdict(sub({ default_payment_method: 'pm_1' }))[0], 'saved');
});
test('a customer default is a fallback not a fix', () => {
const [state, detail] = verdict(sub({
customer: { id: 'cus_1', invoice_settings: { default_payment_method: 'pm_2' } },
}));
assert.equal(state, 'fallback');
assert.match(detail, /refactor/);
});
test('past_due with nothing to charge has already failed', () => {
assert.equal(verdict(sub({ status: 'past_due' }))[0], 'failing');
});
test('an unexpanded customer is not silently stranded', () => {
const [state, detail] = verdict(sub({ customer: 'cus_1' }));
assert.equal(state, 'unknown');
assert.match(detail, /expand/);
});
test('an unrecognised value is not silently healthy', () => {
assert.equal(
verdict(sub({ payment_settings: { save_default_payment_method: 'always' } }))[0],
'unknown');
});
FAQ
What does save_default_payment_method=on_subscription actually do?
When an invoice for that subscription is paid, the payment method used is written to the subscription's default_payment_method. Renewals then charge it directly. With the flag off, the card pays the invoice and is never promoted, so the subscription keeps whatever default it had, which is usually nothing.
Why does the first invoice succeed if there is no payment method?
Because the first invoice is paid by a PaymentIntent confirmed in the browser with a card the customer just entered. That confirmation is a one-off. Renewals are unattended and have to read a stored default, which is a completely different mechanism to the one that made the first payment work.
Can I set the flag on subscriptions that already exist?
Yes, including while the subscription is incomplete. It takes effect from the next invoice, so anything that has not yet renewed is fixed by the update alone. Subscriptions already past_due need a payment method attached as well, because there is no successful invoice left for the flag to learn from.
Is setting invoice_settings.default_payment_method on the Customer enough instead?
It works, and it is what silently rescues most integrations that never set this flag. It is fragile for a different reason: the subscription depends on a field maintained elsewhere, so the day the signup flow stops writing it, every subscription created after that date fails at its second invoice while the older ones carry on.
Does this check need write access?
No. It reads Subscriptions with the Customer expanded and nothing else. Give it a restricted key with read access to Subscriptions and Customers, and run the printed update yourself.
Related field notes
- Active subscriptions with nothing to charge on renewal
- Dunning ran out of retries and no attempt is scheduled
- Incomplete subscriptions die silently after 23 hours
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.
- The subscription object — Stripe API reference
- Create a subscription — Stripe API reference
- Update a subscription — Stripe API reference
- Set up future payments — Stripe Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.