Diagnostic Stripe
saved cards are already expired but still attached
MRR leaks by a percent or two every month and the churn report calls it voluntary. It is not: these customers never chose to leave. Their saved card expired, the renewal failed with expired_card, the dunning emails went to an inbox they do not read, and the account settings page still shows the card as though it works.
For each customer, read GET /v1/payment_methods?customer={id}&type=card and compare card.exp_month and card.exp_year against today. A card is expired when exp_year < now.year, or when the years match and exp_month < now.month. A card whose expiry month is the current month is still good until the last day of it.
Escalate the ones that matter: an expired PaymentMethod referenced by customer.invoice_settings.default_payment_method or by an active subscription's default_payment_method is not dead weight, it is the next failed renewal.
The problem in plain words
The reason this survives is that Stripe's automatic card updater works, just not everywhere. It handles a good share of US-issued Visa, Mastercard, Amex and Discover reissues, and when it fires the customer never notices anything. Coverage outside that is partial and varies by issuer and country, and there is no field that tells you which of your saved cards participate. So the same integration gets a silent, self-healing renewal for one customer and a hard expired_card decline for the next, with nothing in your data to distinguish them beforehand.
Meanwhile the expired card stays attached to the Customer indefinitely. Nothing prunes it. Your own account page renders last4 and a brand icon and looks entirely healthy, so a customer who does go and check sees a card on file and concludes the problem is on your side.
Why it happens
Expiry is knowable months ahead and almost nobody acts on it. exp_month and exp_year are on the PaymentMethod from the moment it is saved. The information required to prevent this failure is sitting in the API the whole time, and the overwhelmingly common design is to wait for the decline and then start a dunning sequence.
The off-by-one is in the direction that costs you. A card is valid through the end of its expiry month. A check written as exp_month <= now.month declares a perfectly good card dead for its final month, and a system that acts on that emails customers to replace cards that work. The comparison has to be strictly less-than within the same year.
Being expired and being the default are very different findings. A stale card sitting on a customer alongside three working ones is untidy. The same card set as invoice_settings.default_payment_method on an active subscription is a renewal that has already failed or is about to, and the two should never appear in the same bucket in a report.
Dunning is where recovery goes to die. By the time the decline happens, recovery depends on an email reaching someone and persuading them to re-enter card details. That funnel converts poorly, which is exactly why the fix lives before the expiry date rather than after it.
The fix, as a flow
The script compares the expiry month against today and asks separately whether the card is a billing default, because a stale card beside three working ones is untidy and the same card on a renewal is lost revenue.
How to fix it
Scope to customers who are actually being billed
Sweeping every Customer on the account is mostly noise; a one-off purchaser from two years ago with a dead card costs you nothing. Start from GET /v1/subscriptions?status=active and check those customers, which is where an expired card turns into lost revenue.
Compare against the end of the expiry month
Expired means the expiry month is strictly in the past. Same year and same month is still valid, and worth reporting separately as the last month it will work, because that is the one where a nudge still prevents the decline.
Cross-reference the defaults
Read customer.invoice_settings.default_payment_method and each active subscription's default_payment_method. An expired card in either position is the finding to act on today; the rest is cleanup.
Confirm the damage in the payment data
GET /v1/payment_intents counting last_payment_error.decline_code == "expired_card", and GET /v1/charges where outcome.reason says the same. This turns an estimate into a number of failed renewals, which is what makes the ticket move.
Detach and re-collect, then close the loop
Detach the dead PaymentMethod and send the customer a Customer Portal link or a SetupIntent to add a new one. Then subscribe to payment_method.automatically_updated so that when the network updater does fire, your local copy of exp_month, exp_year and last4 stops drifting out of date.
How to check it worked
Re-run the script. No customer with an active subscription should have an expired card in a default position.
python3 stripe_expired_cards.py
# valid cus_QxAbc123 pm_1Nx good to 04/2029
The full code
Two GETs per customer and no writes — a restricted key with read access to Customers, Subscriptions and PaymentMethods is enough. The date comparison is a pure function taking explicit year and month arguments rather than reading the clock, both so the boundary can be tested and so a run near midnight on the last day of a month cannot disagree with itself halfway through.
"""Report expired card PaymentMethods still attached to Stripe customers.
Read only. GETs only, no writes: give this a RESTRICTED key with read access to
Customers, Subscriptions and PaymentMethods. The repair is printed, never
performed, because this script holds a credential to a live payments account.
"""
import argparse
import datetime as dt
import logging
import os
import sys
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("stripe_expired_cards")
API = "https://api.stripe.com/v1"
def verdict(exp_month, exp_year, now_year, now_month, is_default=False):
"""Classify one saved card against today. Pure, so the boundary is testable.
A card is valid through the END of its expiry month, so the same month in
the same year is still good. Returns (state, detail).
"""
if not exp_month or not exp_year:
return ("unreadable",
"no exp_month/exp_year on this payment method: it cannot be aged")
label = "%02d/%d" % (int(exp_month), int(exp_year))
expired = (exp_year < now_year
or (exp_year == now_year and exp_month < now_month))
if expired and is_default:
return ("expired-default",
"expired %s and it is the billing default: the next renewal "
"fails with expired_card" % label)
if expired:
return ("expired",
"expired %s and still attached. Nothing prunes it, so your UI "
"keeps showing it as a card on file." % label)
if exp_year == now_year and exp_month == now_month:
return ("last-month",
"valid to the end of %s and then it stops. This is the month a "
"nudge still prevents the decline." % label)
return ("valid", "good to %s" % label)
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 billed_customers(session, limit):
"""Customer ids with an active subscription, and the default PM per customer."""
out = {}
params = {"limit": 100, "status": "active"}
while True:
page = get(session, "/subscriptions", **params)
data = page.get("data", [])
for sub in data:
cus = sub.get("customer")
if isinstance(cus, dict):
cus = cus.get("id")
if not cus:
continue
defaults = out.setdefault(cus, set())
pm = sub.get("default_payment_method")
if isinstance(pm, dict):
pm = pm.get("id")
if pm:
defaults.add(pm)
if not data or not page.get("has_more") or len(out) >= limit:
break
params["starting_after"] = data[-1]["id"]
return out
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--max-customers", type=int, default=500,
help="stop after this many billed 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})
today = dt.date.today()
customers = billed_customers(s, args.max_customers)
if not customers:
log.info("no active subscriptions for this key's mode")
return 0
bad = 0
for cus, sub_defaults in customers.items():
customer = get(s, "/customers/" + cus)
settings = customer.get("invoice_settings") or {}
defaults = set(sub_defaults)
if settings.get("default_payment_method"):
defaults.add(settings["default_payment_method"])
pms = get(s, "/payment_methods", customer=cus, type="card",
limit=100).get("data", [])
for pm in pms:
card = pm.get("card") or {}
state, detail = verdict(card.get("exp_month"), card.get("exp_year"),
today.year, today.month,
pm.get("id") in defaults)
line = "%-15s %s %s %s" % (state, cus, pm.get("id"), detail)
if state in ("valid",):
log.info(line)
continue
if state == "last-month":
log.info(line)
continue
bad += 1
log.warning(line)
log.warning(" repair: POST %s/payment_methods/%s/detach, then send "
"the customer a Customer Portal session or a SetupIntent "
"to add a new card", API, pm.get("id"))
log.warning(" and subscribe to payment_method.automatically_updated "
"so network updates refresh your local exp_month/exp_year")
log.info("%d billed customer(s), %d expired card(s) still attached",
len(customers), bad)
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Report expired card PaymentMethods still attached to Stripe customers.
*
* Read only. GETs only, no writes: give this a RESTRICTED key with read access
* to Customers, Subscriptions and PaymentMethods. The repair is printed, never
* performed.
*/
const API = 'https://api.stripe.com/v1';
/**
* Classify one saved card against today. Pure, so the boundary is testable.
* A card is valid through the END of its expiry month, so the same month in the
* same year is still good.
*/
export function verdict(expMonth, expYear, nowYear, nowMonth, isDefault = false) {
if (!expMonth || !expYear) {
return ['unreadable',
'no exp_month/exp_year on this payment method: it cannot be aged'];
}
const label = `${String(expMonth).padStart(2, '0')}/${expYear}`;
const expired = expYear < nowYear || (expYear === nowYear && expMonth < nowMonth);
if (expired && isDefault) {
return ['expired-default',
`expired ${label} and it is the billing default: the next renewal fails ` +
'with expired_card'];
}
if (expired) {
return ['expired',
`expired ${label} and still attached. Nothing prunes it, so your UI keeps ` +
'showing it as a card on file.'];
}
if (expYear === nowYear && expMonth === nowMonth) {
return ['last-month',
`valid to the end of ${label} and then it stops. This is the month a nudge ` +
'still prevents the decline.'];
}
return ['valid', `good to ${label}`];
}
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 billedCustomers(key, limit = 500) {
const out = new Map();
const params = { limit: 100, status: 'active' };
for (;;) {
const page = await get(key, '/subscriptions', params);
const data = page.data ?? [];
for (const sub of data) {
const cus = typeof sub.customer === 'object' ? sub.customer?.id : sub.customer;
if (!cus) continue;
if (!out.has(cus)) out.set(cus, new Set());
const pm = typeof sub.default_payment_method === 'object'
? sub.default_payment_method?.id
: sub.default_payment_method;
if (pm) out.get(cus).add(pm);
}
if (data.length === 0 || !page.has_more || out.size >= limit) break;
params.starting_after = data[data.length - 1].id;
}
return out;
}
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 now = new Date();
const nowYear = now.getFullYear();
const nowMonth = now.getMonth() + 1;
const customers = await billedCustomers(key);
if (customers.size === 0) {
console.log("no active subscriptions for this key's mode");
return;
}
let bad = 0;
for (const [cus, subDefaults] of customers) {
const customer = await get(key, `/customers/${cus}`);
const defaults = new Set(subDefaults);
const settingsDefault = customer.invoice_settings?.default_payment_method;
if (settingsDefault) defaults.add(settingsDefault);
const { data: pms = [] } = await get(key, '/payment_methods',
{ customer: cus, type: 'card', limit: 100 });
for (const pm of pms) {
const card = pm.card ?? {};
const [state, detail] = verdict(card.exp_month, card.exp_year,
nowYear, nowMonth, defaults.has(pm.id));
const line = `${state.padEnd(15)} ${cus} ${pm.id} ${detail}`;
if (state === 'valid' || state === 'last-month') { console.log(line); continue; }
bad += 1;
console.warn(line);
console.warn(` repair: POST ${API}/payment_methods/${pm.id}/detach, then ` +
'send the customer a Customer Portal session or a SetupIntent ' +
'to add a new card');
console.warn(' and subscribe to payment_method.automatically_updated so ' +
'network updates refresh your local exp_month/exp_year');
}
}
console.log(`${customers.size} billed customer(s), ${bad} expired card(s) ` +
'still attached');
process.exitCode = bad ? 1 : 0;
}
// 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 first test is the one that decides whether this script is safe to act on. A card marked 06/2026 works until the last day of June 2026, so on the 3rd of June it is valid, not expired. Get that wrong and the script emails a month's worth of customers asking them to replace cards that are working perfectly.
from stripe_expired_cards import verdict
def test_the_expiry_month_itself_is_still_valid():
# 06/2026 works until 30 June 2026. `exp_month <= now_month` would call this
# expired and send a replace-your-card email for a card that works.
state, detail = verdict(6, 2026, 2026, 6)
assert state == "last-month"
assert "end of 06/2026" in detail
def test_last_month_of_the_same_year_is_expired():
assert verdict(5, 2026, 2026, 6)[0] == "expired"
def test_a_previous_year_is_expired_whatever_the_month():
# December of last year is still in the past in January.
assert verdict(12, 2025, 2026, 1)[0] == "expired"
def test_an_expired_default_is_escalated():
state, detail = verdict(1, 2024, 2026, 6, True)
assert state == "expired-default"
assert "expired_card" in detail
def test_a_card_with_no_expiry_fields_is_not_silently_valid():
assert verdict(None, None, 2026, 6)[0] == "unreadable"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict } from './stripe-expired-cards.mjs';
test('the expiry month itself is still valid', () => {
const [state, detail] = verdict(6, 2026, 2026, 6);
assert.equal(state, 'last-month');
assert.match(detail, /end of 06\/2026/);
});
test('last month of the same year is expired', () => {
assert.equal(verdict(5, 2026, 2026, 6)[0], 'expired');
});
test('a previous year is expired whatever the month', () => {
assert.equal(verdict(12, 2025, 2026, 1)[0], 'expired');
});
test('an expired default is escalated', () => {
const [state, detail] = verdict(1, 2024, 2026, 6, true);
assert.equal(state, 'expired-default');
assert.match(detail, /expired_card/);
});
test('a card with no expiry fields is not silently valid', () => {
assert.equal(verdict(null, null, 2026, 6)[0], 'unreadable');
});
FAQ
Does Stripe not update expired cards automatically?
For many of them, yes. The automatic card updater handles a large share of US-issued Visa, Mastercard, Amex and Discover reissues, and when it works the customer never notices. Coverage outside that is partial and varies by issuer and country, and no field on the PaymentMethod tells you whether a given card participates. So you cannot rely on it and you cannot predict where it will fail.
Is a card valid on the last day of its expiry month?
Yes. Expiry is the end of the stated month, so a card marked 06/2026 works through 30 June 2026. That is why the check compares strictly less-than within the same year, and reports the current month as a separate state rather than as expired.
Why detach the old card instead of leaving it?
Because nothing else will, and while it is attached your own UI renders it as a card on file. A customer who checks their account after a failed payment sees a valid-looking card and concludes the failure is on your side, which turns a two-minute self-service fix into a support ticket.
What is payment_method.automatically_updated for?
It fires when the card network updates a saved card's details behind the scenes. Subscribing to it keeps your local copy of exp_month, exp_year and last4 in step with Stripe's. Without it, the updater silently fixes the card at Stripe while your database and your account page keep showing the old one.
Should I warn customers before the card expires?
Yes, and it is the only part of this that reliably recovers revenue. Once the renewal has declined you are relying on a dunning email reaching someone and persuading them to re-enter card details, which converts poorly. A Customer Portal link sent while the card still works converts far better.
Related field notes
- An active subscription has no payment method attached
- past_due subscriptions accumulate unnoticed
- A trial ends with no payment method on file
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 PaymentMethod object — Stripe API reference
- List a customer's PaymentMethods — Stripe API reference
- Card payments overview — Stripe Docs
- 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.