Diagnostic Stripe
past_due subscriptions keep their access forever
Renewals have been failing for months and nobody noticed, because the customers are still logged in and still using the product. The entitlement check in your app asks whether the subscription is canceled. It is not canceled. It is past_due, which the check has never heard of, so the answer is yes, let them in.
Read GET /v1/subscriptions?status=past_due&limit=100&expand[]=data.latest_invoice. Every row is a customer with access and no payment. Age each one from latest_invoice.created and read latest_invoice.attempt_count to tell live dunning from a subscription that Stripe has finished with and parked.
Then fix the entitlement check: gate on status being active or trialing, not on it being anything other than canceled.
The problem in plain words
past_due means the renewal invoice failed and Stripe is working on it. What happens at the end of that work is a Dashboard setting, and one of the valid choices is to do nothing — leave the subscription past due. That is a silent, permanent state. Stripe keeps generating an invoice each period, each one fails, and the pile grows.
Two independent mistakes have to line up for this to hurt, and they usually do. The billing side is configured to leave past-due subscriptions alone, and the application side treats every status except canceled as entitled. Each is defensible on its own. Together they mean a customer whose card expired in March is still using the product in October.
It is also invisible in the numbers people watch. These subscriptions still count in active-subscriber reports built on "not canceled", so churn looks fine while the cash does not arrive.
Why it happens
The end-of-retries behaviour is set to leave it past due. Under Billing, Revenue recovery, Retries, the post-retry action can be cancel the subscription, mark it unpaid, or leave it as is. The last one is a real option and it produces exactly this.
The entitlement check is written as a denial list. status != "canceled" reads as reasonable until you enumerate the statuses it lets through: incomplete, past_due, unpaid, and paused all pass.
Nothing distinguishes a retrying subscription from a parked one. Both read past_due. The difference is in the latest invoice: an invoice a few days old with a rising attempt_count is dunning in progress and may still recover. An invoice two months old is a subscription Stripe has stopped working on and nobody has closed.
An attempt_count of zero is a different problem wearing the same status. No attempt at all usually means no payment method resolved, so there is nothing for Smart Retries to retry and the dunning you are waiting on is never going to happen.
The fix, as a flow
The script reads the past due list with each latest invoice expanded, then uses the invoice age and attempt count to separate live dunning from a subscription Stripe has finished with.
How to fix it
Pull the past-due list with its latest invoice
expand[]=data.latest_invoice turns the invoice id into an object, which is where both signals live. Without it you get a list of ids and no way to tell which of them still have a chance.
Split live dunning from parked subscriptions
Age the invoice. Inside about a month, with attempts on the clock, retries may still be running and the right move is to wait or email. Beyond that, no configuration keeps retrying, and the subscription is sitting there purely because nothing closed it.
Look for zero attempts
A past-due subscription whose invoice has never been attempted is not a dunning problem. It has no chargeable payment method, and no amount of retry configuration will help it.
Compare the count to your active subscriptions
The ratio is the argument. Twelve past-due against four thousand active is housekeeping; twelve against ninety is a leak someone needs to own this week.
Fix the entitlement check before the billing settings
Changing the post-retry action to cancel only helps future failures. The customers already in past_due keep their access until the check that granted it is corrected to an allow list of active and trialing.
How to check it worked
Re-run the script. The parked count should be zero, and anything still listed should be inside a live retry window.
python3 stripe_past_due_subs.py
# 6 past_due against 1204 active (0.5%), 0 parked, 0 never attempted
The full code
Three GET requests and no writes: a restricted key with read access to Subscriptions and Invoices is enough. The split between live dunning and a parked subscription is a pure function of the invoice's age and attempt count, so the thresholds are visible and adjustable rather than implied by the output.
"""Report Stripe subscriptions parked in past_due while access continues.
Read only. GET requests only, no writes: give this a RESTRICTED key with read
access to Subscriptions and Invoices. 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_past_due_subs")
API = "https://api.stripe.com/v1"
# No retry schedule Stripe offers runs longer than a month, so an invoice older
# than this is not waiting on anything: the subscription has simply been left.
DUNNING_DAYS = 30
def verdict(sub, now, dunning_days=DUNNING_DAYS):
"""Classify one past_due subscription from its latest invoice.
Pure, so the difference between live dunning and a parked subscription can be
tested without a network. Needs the invoice expanded; an unexpanded id is
reported as unknown rather than guessed at.
"""
invoice = sub.get("latest_invoice")
if not isinstance(invoice, dict):
return ("unknown",
"latest_invoice was not expanded; re-run with "
"expand[]=data.latest_invoice")
created = invoice.get("created")
if not isinstance(created, (int, float)):
return ("unknown", "latest_invoice has no created timestamp to age")
attempts = invoice.get("attempt_count") or 0
days = (now - created) / 86400.0
if attempts == 0:
return ("never-attempted",
"invoice %.0f day(s) old with no payment attempt at all: usually no "
"payment method resolves, so retries never run" % days)
if days > dunning_days:
return ("parked",
"%d attempt(s), invoice %.0f day(s) old: past any retry schedule, so "
"nothing further will happen to this on its own" % (attempts, days))
return ("dunning",
"%d attempt(s) over %.0f day(s): retries are still running and this may "
"recover" % (attempts, 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_subscriptions(session, status, limit, expand=None):
"""Walk one status page by page. Read only; every call here is a GET."""
out = []
params = {"status": status, "limit": 100}
if expand:
params["expand[]"] = expand
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("--dunning-days", type=int, default=DUNNING_DAYS,
help="invoice age past which retries are certainly over")
ap.add_argument("--max", type=int, default=1000,
help="stop after this many subscriptions per status")
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})
past_due = page_subscriptions(s, "past_due", args.max, expand="data.latest_invoice")
active = page_subscriptions(s, "active", args.max)
if not past_due:
log.info("no past_due subscriptions for this key's mode")
return 0
now = time.time()
counts = {}
for sub in past_due:
state, detail = verdict(sub, now, args.dunning_days)
counts[state] = counts.get(state, 0) + 1
log.warning("%-15s %s %s", state, sub.get("id", "?"), detail)
if state == "parked":
log.warning(" repair: close it out with POST %s/subscriptions/%s "
"-d cancel_at_period_end=true, or DELETE %s/subscriptions/%s "
"to end it now", API, sub.get("id"), API, sub.get("id"))
elif state == "never-attempted":
log.warning(" repair: attach a payment method first, then pay invoice %s",
(sub.get("latest_invoice") or {}).get("id", "in_..."))
ratio = 100.0 * len(past_due) / max(1, len(past_due) + len(active))
log.info("%d past_due against %d active (%.1f%%), %d parked, %d never attempted",
len(past_due), len(active), ratio, counts.get("parked", 0),
counts.get("never-attempted", 0))
log.warning("entitlement check: gate on status in (active, trialing), not on "
"status != canceled")
log.warning("billing setting: Billing > Revenue recovery > Retries, set the "
"post-retry action to cancel or mark unpaid")
return 1
if __name__ == "__main__":
sys.exit(main())
/**
* Report Stripe subscriptions parked in past_due while access continues.
*
* Read only. GET requests only, no writes: give this a RESTRICTED key with read
* access to Subscriptions and Invoices. The repair is printed, never performed.
*/
const API = 'https://api.stripe.com/v1';
// No retry schedule Stripe offers runs longer than a month, so an invoice older
// than this is not waiting on anything: the subscription has simply been left.
export const DUNNING_DAYS = 30;
/**
* Classify one past_due subscription from its latest invoice.
* Pure, so live dunning and a parked subscription can be told apart in a test.
*/
export function verdict(sub, now, dunningDays = DUNNING_DAYS) {
const invoice = sub.latest_invoice;
if (invoice === null || typeof invoice !== 'object') {
return ['unknown',
'latest_invoice was not expanded; re-run with expand[]=data.latest_invoice'];
}
const created = invoice.created;
if (typeof created !== 'number') {
return ['unknown', 'latest_invoice has no created timestamp to age'];
}
const attempts = invoice.attempt_count ?? 0;
const days = (now - created) / 86400;
if (attempts === 0) {
return ['never-attempted',
`invoice ${days.toFixed(0)} day(s) old with no payment attempt at all: ` +
'usually no payment method resolves, so retries never run'];
}
if (days > dunningDays) {
return ['parked',
`${attempts} attempt(s), invoice ${days.toFixed(0)} day(s) old: past any ` +
'retry schedule, so nothing further will happen to this on its own'];
}
return ['dunning',
`${attempts} attempt(s) over ${days.toFixed(0)} day(s): retries are still ` +
'running and this may recover'];
}
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 pageSubscriptions(key, status, limit, expand) {
const out = [];
const q = { status, limit: 100 };
if (expand) q['expand[]'] = expand;
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 pastDue = await pageSubscriptions(key, 'past_due', 1000, 'data.latest_invoice');
const active = await pageSubscriptions(key, 'active', 1000);
if (pastDue.length === 0) {
console.log("no past_due subscriptions for this key's mode");
return;
}
const now = Date.now() / 1000;
const counts = new Map();
for (const sub of pastDue) {
const [state, detail] = verdict(sub, now);
counts.set(state, (counts.get(state) ?? 0) + 1);
console.warn(`${state.padEnd(15)} ${sub.id ?? '?'} ${detail}`);
if (state === 'parked') {
console.warn(` repair: close it out with POST ${API}/subscriptions/${sub.id} ` +
`-d cancel_at_period_end=true, or DELETE ${API}/subscriptions/${sub.id} ` +
`to end it now`);
} else if (state === 'never-attempted') {
console.warn(` repair: attach a payment method first, then pay invoice ` +
`${sub.latest_invoice?.id ?? 'in_...'}`);
}
}
const ratio = (100 * pastDue.length) / Math.max(1, pastDue.length + active.length);
console.log(`${pastDue.length} past_due against ${active.length} active ` +
`(${ratio.toFixed(1)}%), ${counts.get('parked') ?? 0} parked, ` +
`${counts.get('never-attempted') ?? 0} never attempted`);
console.warn('entitlement check: gate on status in (active, trialing), not on ' +
'status != canceled');
console.warn('billing setting: Billing > Revenue recovery > Retries, set the ' +
'post-retry action to cancel or mark unpaid');
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
The case these tests exist for is the one where attempt_count is zero. It looks like the worst kind of past-due subscription and it is actually a different fault with a different repair, so folding it into the parked bucket would send someone to change a retry setting that was never going to fire.
from stripe_past_due_subs import verdict
NOW = 1_800_000_000
DAY = 86400
def inv(days_old, attempts):
return {"id": "in_1", "created": NOW - days_old * DAY, "attempt_count": attempts}
def test_a_fresh_invoice_with_attempts_is_live_dunning():
state, detail = verdict({"latest_invoice": inv(3, 2)}, NOW)
assert state == "dunning"
assert "may recover" in detail
def test_an_old_invoice_is_parked_not_dunning():
state, detail = verdict({"latest_invoice": inv(75, 4)}, NOW)
assert state == "parked"
assert "nothing further will happen" in detail
def test_zero_attempts_is_its_own_fault_not_a_retry_problem():
# No attempt means nothing to retry: this is a missing payment method, and
# changing the retry configuration would not touch it.
state, detail = verdict({"latest_invoice": inv(40, 0)}, NOW)
assert state == "never-attempted"
assert "no payment method" in detail
def test_unexpanded_invoice_is_not_classified():
state, detail = verdict({"latest_invoice": "in_1"}, NOW)
assert state == "unknown"
assert "expand" in detail
def test_invoice_without_a_timestamp_is_not_classified():
state, _ = verdict({"latest_invoice": {"attempt_count": 3}}, NOW)
assert state == "unknown"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict } from './stripe-past-due-subs.mjs';
const NOW = 1_800_000_000;
const DAY = 86400;
const inv = (daysOld, attempts) => ({
id: 'in_1', created: NOW - daysOld * DAY, attempt_count: attempts,
});
test('a fresh invoice with attempts is live dunning', () => {
const [state, detail] = verdict({ latest_invoice: inv(3, 2) }, NOW);
assert.equal(state, 'dunning');
assert.match(detail, /may recover/);
});
test('an old invoice is parked not dunning', () => {
const [state, detail] = verdict({ latest_invoice: inv(75, 4) }, NOW);
assert.equal(state, 'parked');
assert.match(detail, /nothing further will happen/);
});
test('zero attempts is its own fault not a retry problem', () => {
const [state, detail] = verdict({ latest_invoice: inv(40, 0) }, NOW);
assert.equal(state, 'never-attempted');
assert.match(detail, /no payment method/);
});
test('unexpanded invoice is not classified', () => {
const [state, detail] = verdict({ latest_invoice: 'in_1' }, NOW);
assert.equal(state, 'unknown');
assert.match(detail, /expand/);
});
test('invoice without a timestamp is not classified', () => {
assert.equal(verdict({ latest_invoice: { attempt_count: 3 } }, NOW)[0], 'unknown');
});
FAQ
Does past_due mean the customer still has access?
That is entirely your decision, and it is the point of this note. Stripe reports the status; your application decides what it grants. If the entitlement check asks whether the status is canceled, then yes, past_due keeps full access indefinitely.
Will Stripe cancel a past_due subscription on its own?
Only if you have told it to. Under Billing, Revenue recovery, Retries, the action after the retries finish can be cancel, mark unpaid, or leave the subscription past due. The last is a valid setting and produces subscriptions that sit there permanently.
What is the difference between past_due and unpaid?
past_due means the renewal failed and Stripe may still be retrying. unpaid is one of the end states: Stripe still creates invoices but closes them immediately and attempts no payment. Both keep access if your check only excludes canceled.
Why would attempt_count be zero on a past-due invoice?
Because no charge was ever attempted, which almost always means no payment method resolved for the subscription. Smart Retries do not run when there is nothing to charge, so the subscription is stuck without ever having been declined.
How do I decide which past-due subscriptions to cancel?
By the age of the latest invoice. Inside a retry window there is a real chance of recovery and cancelling early throws away revenue. Past it, nothing more will happen automatically, and the only thing keeping the subscription open is that nobody closed it.
Related field notes
- Active subscriptions with nothing to charge
- Trials ending with no card on file
- Dunning stops before its attempts
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
- How subscriptions work — Stripe Docs
- Smart Retries — Stripe Docs
- Collection methods — Stripe Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.