Diagnostic Stripe
saved cards expire within 60 days and nothing warns anyone
The churn chart has lumps in it, and the lumps are always at the end of a month. Nobody cancelled. Their card expired, the renewal declined, and the first the customer heard about it was a failed-payment email that reads like an accusation. The expiry date was sitting in the API from the day they saved the card.
For every customer with an active subscription, read GET /v1/payment_methods?customer={id}&type=card and turn card.exp_month and card.exp_year into an actual instant: a card dies at the start of the month after its expiry month. Flag anything falling inside the next 60 days.
Sort what you find. A card that is also the billing default is the next failed renewal. A card with card.wallet.type of apple_pay or google_pay is a network token that survives the plastic being reissued, so warning that customer is noise.
The problem in plain words
This is the preventable half of an expired card. Everything needed to stop it is knowable months ahead: the expiry month is on the PaymentMethod from the moment it is stored, and it does not change. The overwhelmingly common design still waits for the decline and then starts dunning, which means recovery depends on an email persuading someone to re-enter card details after you have already failed to charge them.
Stripe's automatic card updater takes some of this off your hands, and that is exactly what makes it hard to reason about. It covers a good share of US-issued Visa, Mastercard, Amex and Discover reissues; coverage elsewhere is partial and varies by issuer and country. No field tells you which of your saved cards participate. So the outcome is a coin flip you cannot inspect, and the only thing under your control is whether the customer was warned.
Why it happens
A card is valid through the end of its expiry month, not the start. A card marked 04/2029 works on 30 April 2029. Comparing exp_month against the current month with <= declares a working card dead for its final month, and a system built on that emails customers to replace cards that are fine. Compute the expiry as the first instant of the following month and the off-by-one disappears.
December is where the arithmetic breaks. The next month after 12 is 1 of the following year. Any code that adds one to the month without rolling the year over gets month 13, which most date libraries either reject or silently normalise in a way that differs between Python and JavaScript. That is a bug that only fires for one twelfth of your cards, in December.
Not every credential expires with the plastic. Apple Pay, Google Pay and Link save network tokens rather than the raw card number. When the issuer reissues the card, the token follows. Warning those customers costs you a support ticket and some credibility, so read card.wallet and card.networks and leave them out.
The default card is a different finding from the rest. A customer with four saved cards, one expiring, has an untidy account page. A customer whose invoice_settings.default_payment_method expires in three weeks has a renewal that is going to fail on a date you can name. Those two should never sit in the same bucket in a report.
The fix, as a flow
The script starts from active subscriptions rather than from customers, converts each card expiry into an instant, and drops wallet credentials before anything reaches the warning list.
How to fix it
Start from the subscriptions, not the customers
GET /v1/subscriptions?status=active&limit=100 gives you the customers where an expiring card actually costs money. Sweeping every Customer on the account buries the finding under one-off purchasers from three years ago whose dead cards cost you nothing.
Convert the expiry to an instant
The card stops working at the start of the month after exp_month. Roll December into January of the next year explicitly rather than trusting month arithmetic to normalise, then subtract from now to get days remaining.
Exclude the credentials that survive reissue
Read card.wallet.type. A value of apple_pay, google_pay or link means a network token, which is reissued along with the card. Those belong in a separate bucket that nobody emails.
Split the defaults out
Compare each expiring PaymentMethod against customer.invoice_settings.default_payment_method and the subscription's own default_payment_method. Those are the ones with a dated consequence, and they are the list the nudge campaign should run against first.
Nudge at 45 days, then let the recovery tools do the rest
Email a Customer Portal link (POST /v1/billing_portal/sessions) well before the expiry rather than after the decline. Turn on Smart Retries so the cards you do not save are retried on a schedule that recovers more than a fixed one, and encourage wallet saves at checkout, since those tokens do not expire with the plastic.
How to check it worked
Re-run the script after the nudges have gone out. The default-card bucket is the one that should empty; the rest is a list you keep watching.
python3 stripe_card_expiry_window.py
# ok cus_QxAbc123 pm_1Nx expires in 240 day(s), outside the 60 day window
The full code
Two GETs per customer, no writes — a restricted key with read access to Subscriptions, Customers and PaymentMethods covers it. Both pure functions are exported: the one that turns a month and a year into the instant the card dies, and the one that sorts a card by how long it has left. The date conversion is separate precisely because December and the last day of a month are where this check goes wrong, and neither is something you want to discover from a customer.
"""Report saved Stripe cards expiring within the next 60 days.
Read only. GETs only, no writes: give this a RESTRICTED key with read access to
Subscriptions, Customers 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 time
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("stripe_card_expiry_window")
API = "https://api.stripe.com/v1"
WINDOW_DAYS = 60 # how far ahead to look
NUDGE_DAYS = 45 # when to send the first email
# Wallet-backed credentials are network tokens: the issuer reissues them along
# with the card, so their printed expiry date is not a churn event.
TOKENISED_WALLETS = ("apple_pay", "google_pay", "link", "samsung_pay")
def expires_at(exp_month, exp_year):
"""Unix seconds at which a card stops being valid.
A card is good through the END of its expiry month, so the instant it dies is
the start of the following month. December rolls the year over explicitly
rather than relying on month 13 normalising the way you hope.
"""
month, year = int(exp_month), int(exp_year)
if month == 12:
month, year = 1, year + 1
else:
month += 1
return int(dt.datetime(year, month, 1, tzinfo=dt.timezone.utc).timestamp())
def verdict(days_left, is_default=False, wallet=None):
"""Classify one saved card. Pure, so the boundaries can be tested offline.
`days_left` is None when the PaymentMethod carries no usable expiry.
Returns (state, detail).
"""
if days_left is None:
return ("unreadable", "no exp_month/exp_year on this payment method")
if days_left <= 0:
return ("expired",
"already expired%s; this is a decline that has happened, not one "
"coming" % (" and it is the billing default" if is_default else ""))
if wallet in TOKENISED_WALLETS:
return ("tokenised",
"prints an expiry in %.0f day(s) but is a %s credential, which is "
"reissued with the card. Do not email this customer."
% (days_left, wallet))
if days_left > WINDOW_DAYS:
return ("ok", "expires in %.0f day(s), outside the %d day window"
% (days_left, WINDOW_DAYS))
if is_default:
return ("urgent",
"expires in %.0f day(s) and is the billing default: name the renewal "
"that fails and email the portal link today" % days_left)
return ("warn",
"expires in %.0f day(s); the nudge belongs at %d days, before the "
"decline rather than after it" % (days_left, NUDGE_DAYS))
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-subscriptions", type=int, default=1000,
help="stop after this many active 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})
now = time.time()
flagged = 0
seen_customers = set()
for sub in page_all(s, "/subscriptions", args.max_subscriptions,
status="active", limit=100):
cid = sub.get("customer")
if not cid or cid in seen_customers:
continue
seen_customers.add(cid)
customer = get(s, "/customers/" + cid)
invoice_settings = customer.get("invoice_settings") or {}
defaults = {sub.get("default_payment_method"),
invoice_settings.get("default_payment_method")}
defaults.discard(None)
pms = get(s, "/payment_methods", customer=cid, type="card", limit=100)
for pm in pms.get("data", []):
card = pm.get("card") or {}
if not card.get("exp_month") or not card.get("exp_year"):
days_left = None
else:
days_left = (expires_at(card["exp_month"], card["exp_year"]) - now) / 86400.0
wallet = (card.get("wallet") or {}).get("type")
state, detail = verdict(days_left, pm.get("id") in defaults, wallet)
line = "%-10s %s %s %s" % (state, cid, pm.get("id"), detail)
if state in ("ok", "tokenised"):
log.info(line)
continue
log.warning(line)
flagged += 1
if not flagged:
log.info("clear no card on an active subscription expires within %d days",
WINDOW_DAYS)
return 0
log.warning(" %d card(s) need a nudge. Email a portal link, do not wait for "
"the decline:", flagged)
log.warning(" POST %s/billing_portal/sessions -d customer=cus_X "
"-d return_url=https://example.com/billing", API)
log.warning(" and turn on Smart Retries at "
"https://dashboard.stripe.com/settings/billing/automatic")
return 1
if __name__ == "__main__":
sys.exit(main())
/**
* Report saved Stripe cards expiring within the next 60 days.
*
* Read only. GETs only, no writes: give this a RESTRICTED key with read access
* to Subscriptions, Customers and PaymentMethods. The repair is printed only.
*/
const API = 'https://api.stripe.com/v1';
export const WINDOW_DAYS = 60; // how far ahead to look
const NUDGE_DAYS = 45; // when to send the first email
// Wallet-backed credentials are network tokens: the issuer reissues them along
// with the card, so their printed expiry date is not a churn event.
const TOKENISED_WALLETS = new Set(['apple_pay', 'google_pay', 'link', 'samsung_pay']);
/**
* Unix seconds at which a card stops being valid: the start of the month after
* its expiry month, with December rolled into the next year explicitly.
*/
export function expiresAt(expMonth, expYear) {
let month = Number(expMonth);
let year = Number(expYear);
if (month === 12) { month = 1; year += 1; } else { month += 1; }
return Date.UTC(year, month - 1, 1) / 1000;
}
/**
* Classify one saved card. Pure, so the boundaries can be tested offline.
* `daysLeft` is null when the PaymentMethod carries no usable expiry.
*/
export function verdict(daysLeft, isDefault = false, wallet = null) {
if (daysLeft === null || daysLeft === undefined) {
return ['unreadable', 'no exp_month/exp_year on this payment method'];
}
if (daysLeft <= 0) {
return ['expired',
`already expired${isDefault ? ' and it is the billing default' : ''}; ` +
'this is a decline that has happened, not one coming'];
}
if (TOKENISED_WALLETS.has(wallet)) {
return ['tokenised',
`prints an expiry in ${daysLeft.toFixed(0)} day(s) but is a ${wallet} ` +
'credential, which is reissued with the card. Do not email this customer.'];
}
if (daysLeft > WINDOW_DAYS) {
return ['ok',
`expires in ${daysLeft.toFixed(0)} day(s), outside the ${WINDOW_DAYS} day window`];
}
if (isDefault) {
return ['urgent',
`expires in ${daysLeft.toFixed(0)} day(s) and is the billing default: name ` +
'the renewal that fails and email the portal link today'];
}
return ['warn',
`expires in ${daysLeft.toFixed(0)} day(s); the nudge belongs at ${NUDGE_DAYS} ` +
'days, before the decline rather than after it'];
}
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 now = Date.now() / 1000;
let flagged = 0;
const seen = new Set();
for (const sub of await pageAll(key, '/subscriptions', 1000, { status: 'active' })) {
const cid = sub.customer;
if (!cid || seen.has(cid)) continue;
seen.add(cid);
const customer = await get(key, `/customers/${cid}`);
const defaults = new Set([
sub.default_payment_method,
customer.invoice_settings?.default_payment_method,
].filter(Boolean));
const pms = await get(key, '/payment_methods', { customer: cid, type: 'card', limit: 100 });
for (const pm of pms.data ?? []) {
const card = pm.card ?? {};
const daysLeft = (card.exp_month && card.exp_year)
? (expiresAt(card.exp_month, card.exp_year) - now) / 86400
: null;
const [state, detail] = verdict(daysLeft, defaults.has(pm.id), card.wallet?.type ?? null);
const line = `${state.padEnd(10)} ${cid} ${pm.id} ${detail}`;
if (state === 'ok' || state === 'tokenised') { console.log(line); continue; }
console.warn(line);
flagged += 1;
}
}
if (!flagged) {
console.log(`clear no card on an active subscription expires within ${WINDOW_DAYS} days`);
return;
}
console.warn(` ${flagged} card(s) need a nudge. Email a portal link, do not wait for the decline:`);
console.warn(` POST ${API}/billing_portal/sessions -d customer=cus_X -d return_url=https://example.com/billing`);
console.warn(' and turn on Smart Retries at https://dashboard.stripe.com/settings/billing/automatic');
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 things are tested and they are the two things that break: the calendar and the window edge. December has to roll into January of the next year, a card has to stay valid through the last day of its expiry month, and 60 days has to be inside the window while 61 is outside. A wallet credential has to fall out of the warning list before anything else looks at it.
import datetime as dt
from stripe_card_expiry_window import expires_at, verdict
def utc(year, month):
return int(dt.datetime(year, month, 1, tzinfo=dt.timezone.utc).timestamp())
def test_a_card_is_valid_through_the_end_of_its_month():
# 04/2029 dies at the first instant of May, not of April.
assert expires_at(4, 2029) == utc(2029, 5)
def test_december_rolls_into_the_next_year():
assert expires_at(12, 2026) == utc(2027, 1)
def test_february_of_a_leap_year_still_lands_on_march():
assert expires_at(2, 2028) == utc(2028, 3)
def test_an_expiry_already_past_is_a_decline_that_happened():
state, detail = verdict(-3.0, is_default=True)
assert state == "expired"
assert "billing default" in detail
def test_the_window_edge_is_inclusive():
assert verdict(60.0)[0] == "warn"
assert verdict(60.1)[0] == "ok"
def test_the_default_card_is_its_own_bucket():
assert verdict(20.0)[0] == "warn"
assert verdict(20.0, is_default=True)[0] == "urgent"
def test_wallet_credentials_are_not_warned_about():
state, detail = verdict(10.0, is_default=True, wallet="apple_pay")
assert state == "tokenised"
assert "reissued" in detail
def test_a_card_with_no_expiry_is_not_silently_fine():
assert verdict(None)[0] == "unreadable"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { expiresAt, verdict } from './stripe-card-expiry-window.mjs';
test('a card is valid through the end of its month', () => {
assert.equal(expiresAt(4, 2029), Date.UTC(2029, 4, 1) / 1000);
});
test('december rolls into the next year', () => {
assert.equal(expiresAt(12, 2026), Date.UTC(2027, 0, 1) / 1000);
});
test('february of a leap year still lands on march', () => {
assert.equal(expiresAt(2, 2028), Date.UTC(2028, 2, 1) / 1000);
});
test('an expiry already past is a decline that happened', () => {
const [state, detail] = verdict(-3.0, true);
assert.equal(state, 'expired');
assert.match(detail, /billing default/);
});
test('the window edge is inclusive', () => {
assert.equal(verdict(60.0)[0], 'warn');
assert.equal(verdict(60.1)[0], 'ok');
});
test('the default card is its own bucket', () => {
assert.equal(verdict(20.0)[0], 'warn');
assert.equal(verdict(20.0, true)[0], 'urgent');
});
test('wallet credentials are not warned about', () => {
const [state, detail] = verdict(10.0, true, 'apple_pay');
assert.equal(state, 'tokenised');
assert.match(detail, /reissued/);
});
test('a card with no expiry is not silently fine', () => {
assert.equal(verdict(null)[0], 'unreadable');
});
FAQ
Does the automatic card updater not handle this?
Partly. It covers many US-issued Visa, Mastercard, Amex and Discover reissues, and coverage elsewhere is partial and varies by issuer and country. No API field tells you which of your saved cards participate, so you cannot predict per card whether it will self-heal. The nudge is what you control.
Is a card valid on the last day of its expiry month?
Yes. A card marked 04/2029 works through 30 April 2029 and stops at the first instant of May. Computing the expiry as the start of the following month gets this right without a special case for month lengths or leap years.
Why exclude Apple Pay and Google Pay cards?
Because card.wallet.type of apple_pay or google_pay means the PaymentMethod holds a network token rather than the raw card number. When the issuer reissues the card, the token is updated and the charge keeps working. Emailing those customers about an expiry that will not affect them costs support time and trust.
What is the right lead time for the email?
Around 45 days. It is far enough ahead that the customer is not being asked to fix something urgent, and close enough that the message is still relevant when it arrives. Sending after the decline turns a housekeeping email into a failure notice.
Should I check every customer or only subscribers?
Only customers with an active subscription, or with an upcoming invoice. A dead card on a one-off purchaser from two years ago costs nothing and buries the findings that do cost something.
Related field notes
- Saved cards are already expired but still attached
- Dunning ran out of retries and no attempt is scheduled
- PaymentMethods are created but never attached to a customer
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
- Cards overview — Stripe Docs
- Create a customer portal session — Stripe API reference
- Smart Retries — Stripe Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.