Skip to content

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.

Read-only key Python and Node.js Tests included
Turned on macbook pro beside gray mug
Photo by Igor Miske on Unsplash
The short answer

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.

Card savedexp 09/2026Months passnothing reads thedateCard expiresissuer reissues ornotRenewaldeclinesexpired_cardDunning emailfirst the customerhearsrecovery depends on an email
Every date in this chain was readable from the API on the day the card was saved.

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.

GET /v1/payment_methodsfor each active subscriberOver 60 days leftoutside the windowWallet credentialsurvives reissue, skip itUnder 60 daysnudge at 45 daysUnder 60 and defaultname the renewal that fails
A wallet token is reissued with the card, so warning that customer costs a support ticket and buys nothing.

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.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 114 Stripe fixes, free and open source.
stripe_card_expiry_window.py
"""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())
stripe-card-expiry-window.mjs
/**
 * 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.

test_stripe_card_expiry_window.py
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"
stripe-card-expiry-window.test.mjs
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

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.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.