Diagnostic Stripe
trials ending in days with no card on file
Card-free trials are good for signups and they build a queue. Everyone who started a trial on the first of the month reaches its end on the same day, and the ones without a card all fail together. What happens next is one Stripe setting most teams have never opened, and its default is the loudest of the three options and the quietest to you.
Read GET /v1/subscriptions?status=trialing&limit=100&expand[]=data.customer. Flag rows where trial_end falls inside the next 72 hours and default_payment_method, default_source and customer.invoice_settings.default_payment_method are all null.
Then read trial_settings.end_behavior.missing_payment_method on those rows. It decides whether they land in past_due, in paused, or gone, and it defaults to create_invoice, which is the first of those.
The problem in plain words
When a trial ends and no payment method resolves, Stripe consults one field: trial_settings.end_behavior.missing_payment_method. It has three values and they produce three completely different outcomes.
create_invoice, the default, cuts an invoice that fails immediately because there is nothing to charge. The subscription drops into past_due and joins whatever pile you already have there, with access probably still granted. pause moves the subscription to paused and stops invoicing, which is recoverable but earns nothing and generates no dunning email. cancel ends it outright.
The reason this is worth a scheduled check rather than a one-off decision is the shape of the failure. It is not one subscription going wrong, it is a cohort. Trials started in a marketing push all end within a day or two of each other, so a setting you never chose produces a spike of broken subscriptions on a date you could have predicted a week in advance.
Why it happens
The default was never chosen. create_invoice is what you get by not setting the field. It is a reasonable default for trials that required a card up front and a poor one for trials that did not, and nothing in the API distinguishes the two.
The trial itself never asked for a card. That is usually deliberate — it is why the signup converts — but it means the subscription exists for the whole trial with all its payment-method slots empty and no code path that fills them.
Nobody handles customer.subscription.trial_will_end. Stripe fires it three days before the trial end date, precisely so you can email the customer a link to add a card. An integration that does not subscribe to it has no notice at all; the first signal is the failure itself.
The three outcomes fail differently, and two of them fail quietly. A past_due subscription at least shows up in a dunning report. A paused one shows up nowhere: it is not active, not past due, not canceled, and it will sit there until someone goes looking.
The fix, as a flow
The script finds trials ending inside the next 72 hours with no payment method, then reads one field to say which of three very different things will happen to each of them.
How to fix it
List trialing subscriptions with the customer expanded
The customer-level default is the third place Stripe looks, so without expand[]=data.customer you will flag customers who do have a card on file at the account level.
Filter to the next 72 hours
A trial ending in three weeks is not a problem yet and putting it in the same list as one ending tomorrow makes the list unactionable. Seventy-two hours also lines up with customer.subscription.trial_will_end, which fires three days out.
Read the end behaviour on every flagged row
This is what turns a count into a prediction. The same twelve card-free trials become twelve past_due subscriptions, twelve paused ones, or twelve cancellations depending on one field, and the script should tell you which before the date rather than after.
Send the customers a billing-portal link
The only real repair is a card, and only the customer can supply one. A portal link or a SetupIntent, sent while the trial is still running, converts a subscription that would otherwise fail.
Set the end behaviour deliberately
For card-free trials, pause is usually the honest choice: it stops billing, it is reversible, and it does not manufacture invoices that can never be paid. Whatever you pick, pick it, and subscribe to customer.subscription.trial_will_end so the next cohort gets warned.
How to check it worked
Re-run the script a few days before your next big trial cohort ends. Nothing should be listed as ending soon without a card.
python3 stripe_trial_no_card.py
# 88 trialing, 0 ending within 72h with no card, 4 with no card further out
The full code
One GET request per page and no writes: a restricted key with read access to Subscriptions and Customers is enough. The classifier takes the current time as an argument rather than reading the clock itself, which is what lets the tests pin the 72-hour boundary exactly instead of approximately.
"""Report Stripe trials ending soon with no payment method on file.
Read only. GET requests only, 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 time
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("stripe_trial_no_card")
API = "https://api.stripe.com/v1"
# Stripe fires customer.subscription.trial_will_end three days out, so this is the
# window in which a warning email is still the documented remedy.
HORIZON = 259200
OUTCOMES = {
"create_invoice": "Stripe invoices on the trial end date, the invoice fails "
"immediately, and the subscription drops into past_due",
"pause": "the subscription moves to paused and stops invoicing, which is "
"recoverable but earns nothing until someone resumes it",
"cancel": "the subscription is cancelled outright on the trial end date",
}
def verdict(sub, now, horizon=HORIZON):
"""Classify one trialing subscription. Pure, so the horizon can be tested.
Checks the three payment-method slots that apply to a trial ending, then reads
trial_settings.end_behavior.missing_payment_method to say what will happen.
"""
if sub.get("default_payment_method") or sub.get("default_source"):
return ("carded", "a payment method resolves, so the trial will convert")
customer = sub.get("customer")
if not isinstance(customer, dict):
return ("unknown",
"customer was not expanded, so the customer-level default cannot be "
"read; re-run with expand[]=data.customer")
settings = customer.get("invoice_settings") or {}
if settings.get("default_payment_method"):
return ("carded",
"falls back to customer.invoice_settings.default_payment_method")
behaviour = (((sub.get("trial_settings") or {}).get("end_behavior") or {})
.get("missing_payment_method") or "create_invoice")
outcome = OUTCOMES.get(
behaviour, "end behaviour %r is not one Stripe documents" % (behaviour,))
trial_end = sub.get("trial_end")
if not isinstance(trial_end, (int, float)):
return ("no-card", "no payment method and no trial_end to schedule against")
remaining = trial_end - now
if remaining <= horizon:
return ("imminent",
"no payment method, trial ends in %.0f h: %s"
% (remaining / 3600.0, outcome))
return ("no-card",
"no payment method, trial ends in %.0f day(s): %s"
% (remaining / 86400.0, outcome))
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_trialing(session, limit):
"""Walk the trialing subscriptions. Read only; every call here is a GET."""
out = []
params = {"status": "trialing", "limit": 100, "expand[]": "data.customer"}
while True:
page = get(session, "/subscriptions", **params)
out.extend(page.get("data", []))
if not page.get("has_more") or len(out) >= limit:
break
params["starting_after"] = page["data"][-1]["id"]
return out[:limit]
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--hours", type=int, default=72,
help="how far ahead counts as imminent (default 72)")
ap.add_argument("--max", type=int, default=1000,
help="stop 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})
subs = page_trialing(s, args.max)
if not subs:
log.info("no trialing subscriptions for this key's mode")
return 0
now = time.time()
counts = {}
for sub in subs:
state, detail = verdict(sub, now, args.hours * 3600)
counts[state] = counts.get(state, 0) + 1
if state == "carded":
continue
line = "%-9s %s %s" % (state, sub.get("id", "?"), detail)
if state == "no-card":
log.info(line)
continue
log.warning(line)
customer = sub.get("customer")
cus_id = customer.get("id") if isinstance(customer, dict) else customer
log.warning(" repair: email %s a billing-portal link and collect a card "
"before %s", cus_id or "the customer", sub.get("trial_end"))
log.warning(" and choose the end behaviour deliberately: POST "
"%s/subscriptions/%s -d "
"trial_settings[end_behavior][missing_payment_method]=pause",
API, sub.get("id"))
log.info("%d trialing, %d ending within %dh with no card, %d with no card "
"further out", len(subs), counts.get("imminent", 0), args.hours,
counts.get("no-card", 0))
if counts.get("unknown"):
log.warning("%d row(s) could not be classified: re-run with the customer "
"expanded", counts["unknown"])
if counts.get("imminent"):
log.warning("subscribe to customer.subscription.trial_will_end; it fires "
"three days out, which is the window this check reports on")
return 1 if counts.get("imminent") or counts.get("unknown") else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Report Stripe trials ending soon with no payment method on file.
*
* Read only. GET requests only, 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';
// Stripe fires customer.subscription.trial_will_end three days out, so this is the
// window in which a warning email is still the documented remedy.
export const HORIZON = 259200;
const OUTCOMES = {
create_invoice: 'Stripe invoices on the trial end date, the invoice fails ' +
'immediately, and the subscription drops into past_due',
pause: 'the subscription moves to paused and stops invoicing, which is ' +
'recoverable but earns nothing until someone resumes it',
cancel: 'the subscription is cancelled outright on the trial end date',
};
/**
* Classify one trialing subscription. Pure, so the horizon can be tested.
*/
export function verdict(sub, now, horizon = HORIZON) {
if (sub.default_payment_method || sub.default_source) {
return ['carded', 'a payment method resolves, so the trial will convert'];
}
const customer = sub.customer;
if (customer === null || typeof customer !== 'object') {
return ['unknown',
'customer was not expanded, so the customer-level default cannot be read; ' +
're-run with expand[]=data.customer'];
}
const settings = customer.invoice_settings ?? {};
if (settings.default_payment_method) {
return ['carded', 'falls back to customer.invoice_settings.default_payment_method'];
}
const behaviour = sub.trial_settings?.end_behavior?.missing_payment_method
?? 'create_invoice';
const outcome = OUTCOMES[behaviour]
?? `end behaviour ${JSON.stringify(behaviour)} is not one Stripe documents`;
const trialEnd = sub.trial_end;
if (typeof trialEnd !== 'number') {
return ['no-card', 'no payment method and no trial_end to schedule against'];
}
const remaining = trialEnd - now;
if (remaining <= horizon) {
return ['imminent',
`no payment method, trial ends in ${(remaining / 3600).toFixed(0)} h: ${outcome}`];
}
return ['no-card',
`no payment method, trial ends in ${(remaining / 86400).toFixed(0)} day(s): ${outcome}`];
}
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 pageTrialing(key, limit) {
const out = [];
const q = { status: 'trialing', limit: 100, 'expand[]': 'data.customer' };
for (;;) {
const page = await get(key, '/subscriptions', q);
out.push(...(page.data ?? []));
if (!page.has_more || out.length >= limit) break;
q.starting_after = page.data[page.data.length - 1].id;
}
return out.slice(0, limit);
}
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 subs = await pageTrialing(key, 1000);
if (subs.length === 0) {
console.log("no trialing subscriptions for this key's mode");
return;
}
const now = Date.now() / 1000;
const counts = new Map();
for (const sub of subs) {
const [state, detail] = verdict(sub, now);
counts.set(state, (counts.get(state) ?? 0) + 1);
if (state === 'carded') continue;
const line = `${state.padEnd(9)} ${sub.id ?? '?'} ${detail}`;
if (state === 'no-card') { console.log(line); continue; }
console.warn(line);
const cus = typeof sub.customer === 'object' && sub.customer !== null
? sub.customer.id : sub.customer;
console.warn(` repair: email ${cus ?? 'the customer'} a billing-portal link ` +
`and collect a card before ${sub.trial_end}`);
console.warn(` and choose the end behaviour deliberately: ` +
`POST ${API}/subscriptions/${sub.id} ` +
`-d trial_settings[end_behavior][missing_payment_method]=pause`);
}
console.log(`${subs.length} trialing, ${counts.get('imminent') ?? 0} ending ` +
`within 72h with no card, ${counts.get('no-card') ?? 0} with no card further out`);
if (counts.get('unknown')) {
console.warn(`${counts.get('unknown')} row(s) could not be classified: re-run ` +
'with the customer expanded');
}
if (counts.get('imminent')) {
console.warn('subscribe to customer.subscription.trial_will_end; it fires three ' +
'days out, which is the window this check reports on');
}
process.exitCode = (counts.get('imminent') ?? 0) + (counts.get('unknown') ?? 0)
? 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
Two things are worth holding still here. The first is that an absent trial_settings has to be read as create_invoice, because that is Stripe's default and treating it as unknown would hide the most common case. The second is the horizon: a trial ending in three weeks and one ending tomorrow are different findings and must not share a bucket.
from stripe_trial_no_card import verdict
NOW = 1_800_000_000
HOUR = 3600
def trial(hours_out, behaviour=None, customer=None):
sub = {"trial_end": NOW + hours_out * HOUR,
"customer": {} if customer is None else customer}
if behaviour:
sub["trial_settings"] = {"end_behavior": {"missing_payment_method": behaviour}}
return sub
def test_a_card_on_the_subscription_is_not_a_finding():
sub = {"default_payment_method": "pm_1", "customer": {}}
assert verdict(sub, NOW)[0] == "carded"
def test_a_card_on_the_customer_counts_too():
sub = trial(24, customer={"invoice_settings": {"default_payment_method": "pm_2"}})
assert verdict(sub, NOW)[0] == "carded"
def test_missing_trial_settings_is_read_as_the_stripe_default():
# create_invoice is what you get by not setting the field, and it is the case
# that produces past_due, so it must not be reported as unknown.
state, detail = verdict(trial(12), NOW)
assert state == "imminent"
assert "past_due" in detail
def test_pause_is_named_as_a_different_outcome():
state, detail = verdict(trial(12, "pause"), NOW)
assert state == "imminent"
assert "paused" in detail
def test_a_trial_ending_in_three_weeks_is_not_imminent():
state, detail = verdict(trial(24 * 21), NOW)
assert state == "no-card"
assert "day(s)" in detail
def test_unexpanded_customer_is_not_silently_carded():
state, detail = verdict({"trial_end": NOW + HOUR, "customer": "cus_9"}, NOW)
assert state == "unknown"
assert "expand" in detail
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict } from './stripe-trial-no-card.mjs';
const NOW = 1_800_000_000;
const HOUR = 3600;
function trial(hoursOut, behaviour, customer) {
const sub = { trial_end: NOW + hoursOut * HOUR, customer: customer ?? {} };
if (behaviour) {
sub.trial_settings = { end_behavior: { missing_payment_method: behaviour } };
}
return sub;
}
test('a card on the subscription is not a finding', () => {
assert.equal(verdict({ default_payment_method: 'pm_1', customer: {} }, NOW)[0],
'carded');
});
test('a card on the customer counts too', () => {
const sub = trial(24, null,
{ invoice_settings: { default_payment_method: 'pm_2' } });
assert.equal(verdict(sub, NOW)[0], 'carded');
});
test('missing trial settings is read as the stripe default', () => {
const [state, detail] = verdict(trial(12), NOW);
assert.equal(state, 'imminent');
assert.match(detail, /past_due/);
});
test('pause is named as a different outcome', () => {
const [state, detail] = verdict(trial(12, 'pause'), NOW);
assert.equal(state, 'imminent');
assert.match(detail, /paused/);
});
test('a trial ending in three weeks is not imminent', () => {
const [state, detail] = verdict(trial(24 * 21), NOW);
assert.equal(state, 'no-card');
assert.match(detail, /day\(s\)/);
});
test('unexpanded customer is not silently carded', () => {
const [state, detail] = verdict({ trial_end: NOW + HOUR, customer: 'cus_9' }, NOW);
assert.equal(state, 'unknown');
assert.match(detail, /expand/);
});
FAQ
What happens by default when a trial ends with no card?
trial_settings.end_behavior.missing_payment_method defaults to create_invoice. Stripe cuts an invoice on the trial end date, it fails immediately because there is nothing to charge, and the subscription becomes past_due.
Which end behaviour should I choose for card-free trials?
pause is usually the honest one. It stops invoicing, puts the subscription in paused, and is reversible once a card is attached. It does not manufacture invoices that can never be paid, and it does not delete a customer relationship the way cancel does.
How much warning does Stripe give me?
customer.subscription.trial_will_end fires three days before the trial end date. That is the whole notice period, which is why this check uses a 72-hour horizon: anything it reports is something the webhook should also have told you about.
Why does a paused subscription never come back on its own?
There is no timeout on paused. Stripe stops creating invoices and waits. The subscription resumes only when you explicitly resume it after a default payment method exists, so paused subscriptions accumulate quietly unless something is watching for them.
Can I check this without a live secret key?
Yes. A restricted key with read access to Subscriptions and Customers covers the whole check, and if it leaks nobody can move money with it.
Related field notes
- Active subscriptions with nothing to charge
- past_due subscriptions keep their access
- Free trials forced to manual renewal
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.
- Trial periods on subscriptions — Stripe Docs
- The subscription object — Stripe API reference
- Collection methods — Stripe Docs
- 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.