Diagnostic Stripe
paused subscriptions never resume and never invoice again
Somebody set trials to pause instead of dunning when no card was attached, which was the right call. Nobody built the other half. Two years of trials that ended without a card are sitting in paused, generating nothing, appearing in no past-due report, and waiting for a resume that is not coming.
Read GET /v1/subscriptions?status=paused&limit=100. There is no timeout on this status and no automatic exit from it, so every row is inventory that has been sitting still since the day its trial ended.
Sort them by two things. Whether a payment method has since appeared — those need only the resume call. And age against one billing interval, computed from items.data[0].price.recurring; anything older than that is not a pause, it is churn that was never recorded as churn.
The problem in plain words
paused is the narrowest status Stripe has. There is exactly one way in: a trial ends, the subscription has no payment method, and trial_settings.end_behavior.missing_payment_method is set to pause. Stripe then stops creating invoices entirely and holds the subscription there indefinitely.
That is the correct behaviour and it is much better than the default, which is to cut an invoice that instantly fails. The problem is that it is a state with no clock. Nothing expires it, nothing escalates it, and nothing fires from it after the initial customer.subscription.paused. It is a queue with no consumer.
And it hides from the two reports people actually read. It is not in the past-due list, because nothing is due. It is not in the churn number, because nothing was canceled. A cohort of customers who wanted the product enough to start a trial simply stops existing, in a status that nobody has a dashboard for.
Why it happens
Configuring the pause is one line and handling it is a project. Setting missing_payment_method=pause takes a minute. Reacting to it means an event handler, a win-back sequence, a billing-portal link, and someone to own the list. The first half ships and the second half becomes a ticket.
Resuming is not a single flag. Attaching a card does not un-pause anything. The subscription has to be updated with a valid default_payment_method and pause_collection cleared, and doing so can generate an invoice that must be paid before the status actually leaves paused. So a customer who added a card through the portal months ago can still be sitting in the list — and that is the most recoverable row in it.
It is confused with the field of a similar name. pause_collection is a completely different mechanism that leaves the status active; the note on that one is separate for exactly this reason. Searching your code for "pause" finds one and not the other.
The age is invisible without doing arithmetic. The object carries trial_end and start_date, not a "paused for" number. Until someone bucket-sorts by the billing interval, a subscription paused last week and one paused in 2024 look identical in a list.
The fix, as a flow
The script expands the customer on every paused subscription, because a card that turned up after the pause makes the row the easiest recovery on the list rather than the oldest entry on it.
How to fix it
List everything in paused
GET /v1/subscriptions?status=paused&limit=100, expanding data.customer so you can see whether a payment method has turned up since. Page it all — the interesting rows are the oldest ones and Stripe returns newest first.
Separate the ones that only need the resume call
Check default_payment_method, default_source, and the customer's invoice_settings.default_payment_method. If any is set, this customer gave you a card and is still being served nothing. That is the top of the list, not the bottom.
Age the rest against one billing interval
Read the interval from items.data[0].price.recurring.interval and interval_count. Paused for less than one interval is a win-back you can still run. Paused for longer is dead inventory, and pretending otherwise inflates whatever you are calling your subscriber count.
Resume the recoverable ones
POST /v1/subscriptions/{sub} with pause_collection= (empty) and a valid default_payment_method. Expect an invoice to be generated; the status does not leave paused until it is paid.
Give the status an owner
Handle customer.subscription.paused: revoke access and start the win-back sequence with a billing-portal link in it. Once something consumes the queue, this note stops applying to you.
How to check it worked
Re-run the script. Nothing should be older than one billing interval, and nothing should be paused with a usable payment method already on file.
python3 stripe_paused_subscriptions.py
# 0 paused subscription(s): 0 resumable, 0 stale
The full code
One GET with the customer expanded, and no writes. A restricted key with read access to Subscriptions and Customers is enough. The classifier is pure and takes the subscription and the current time, because both facts it sorts on — whether a payment method exists anywhere, and how the age compares to this subscription's own billing interval — are computed from the object rather than assumed.
"""Report paused subscriptions, sorted by whether they can still be resumed.
Read only. One GET, 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_paused_subscriptions")
API = "https://api.stripe.com/v1"
DAY = 86400
INTERVALS = {"day": DAY, "week": 7 * DAY, "month": 30 * DAY, "year": 365 * DAY}
def interval_seconds(sub):
"""Length of one billing interval, from the first item's recurring price.
Falls back to 30 days when the price is not expanded or the interval is one
we do not recognise: a paused subscription with an unknown interval is still
worth ageing, and guessing monthly is the conservative guess.
"""
items = (sub.get("items") or {}).get("data") or []
if not items:
return 30 * DAY
price = items[0].get("price") or items[0].get("plan") or {}
recurring = price.get("recurring") or price
unit = INTERVALS.get(recurring.get("interval"))
if not unit:
return 30 * DAY
return unit * (recurring.get("interval_count") or 1)
def has_payment_method(sub):
"""True when Stripe has something to charge the moment this is resumed.
Same four places Stripe itself looks, in the same order.
"""
customer = sub.get("customer")
if not isinstance(customer, dict):
customer = {}
return bool(sub.get("default_payment_method")
or sub.get("default_source")
or (customer.get("invoice_settings") or {}).get("default_payment_method")
or customer.get("default_source"))
def verdict(sub, now):
"""Classify one paused subscription. Pure, so the rules can be tested.
Returns (state, detail).
"""
if sub.get("status") != "paused":
return ("not-paused",
"status is %r; paused is only reachable from a trial that ended "
"with no payment method" % (sub.get("status"),))
if has_payment_method(sub):
return ("resumable",
"a payment method is already on file. The only thing keeping this "
"paused is the resume nobody performed.")
days = int((now - (sub.get("trial_end") or sub.get("start_date") or now)) // DAY)
if now - (sub.get("trial_end") or sub.get("start_date") or now) > interval_seconds(sub):
return ("stale",
"paused %d day(s), longer than one billing interval. This is "
"churn that was never recorded as churn." % days)
return ("recent",
"paused %d day(s), inside one billing interval. The win-back window "
"is still open." % 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")
if r.status_code == 403:
raise SystemExit("403 from Stripe: the restricted key lacks read access to "
+ path)
r.raise_for_status()
return r.json()
def paused_subscriptions(session, limit):
"""Page every paused subscription, customer expanded so cards are visible."""
out = []
params = {"status": "paused", "limit": 100, "expand[]": "data.customer"}
while True:
page = get(session, "/subscriptions", **params)
data = page.get("data", [])
out.extend(data)
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-subscriptions", type=int, default=1000,
help="stop after this many paused 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 = paused_subscriptions(s, args.max_subscriptions)
now = int(time.time())
counts = {}
for sub in subs:
state, detail = verdict(sub, now)
counts[state] = counts.get(state, 0) + 1
log.warning("%-10s %s %s", state, sub["id"], detail)
if state == "resumable":
log.warning(" repair: POST %s/subscriptions/%s -d pause_collection= "
"-d default_payment_method={pm}", API, sub["id"])
elif state == "stale":
log.warning(" repair: count it as churn, or send a billing portal "
"link before you do")
log.info("%d paused subscription(s): %d resumable, %d stale",
len(subs), counts.get("resumable", 0), counts.get("stale", 0))
if subs:
log.info("handle customer.subscription.paused so this list has an owner")
return 1 if subs else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Report paused subscriptions, sorted by whether they can still be resumed.
*
* Read only. One GET, 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';
const DAY = 86400;
const INTERVALS = { day: DAY, week: 7 * DAY, month: 30 * DAY, year: 365 * DAY };
/**
* Length of one billing interval, from the first item's recurring price.
* Falls back to 30 days when the price is not expanded.
*/
export function intervalSeconds(sub) {
const items = sub.items?.data ?? [];
if (items.length === 0) return 30 * DAY;
const price = items[0].price ?? items[0].plan ?? {};
const recurring = price.recurring ?? price;
const unit = INTERVALS[recurring.interval];
if (!unit) return 30 * DAY;
return unit * (recurring.interval_count ?? 1);
}
/** True when Stripe has something to charge the moment this is resumed. */
export function hasPaymentMethod(sub) {
const customer = typeof sub.customer === 'object' && sub.customer !== null
? sub.customer : {};
return Boolean(sub.default_payment_method
|| sub.default_source
|| customer.invoice_settings?.default_payment_method
|| customer.default_source);
}
/** Classify one paused subscription. Pure, so the rules can be tested. */
export function verdict(sub, now) {
if (sub.status !== 'paused') {
return ['not-paused',
`status is ${JSON.stringify(sub.status)}; paused is only reachable from a ` +
'trial that ended with no payment method'];
}
if (hasPaymentMethod(sub)) {
return ['resumable',
'a payment method is already on file. The only thing keeping this paused ' +
'is the resume nobody performed.'];
}
const since = sub.trial_end ?? sub.start_date ?? now;
const age = now - since;
const days = Math.floor(age / DAY);
if (age > intervalSeconds(sub)) {
return ['stale',
`paused ${days} day(s), longer than one billing interval. This is churn ` +
'that was never recorded as churn.'];
}
return ['recent',
`paused ${days} day(s), inside one billing interval. The win-back window is ` +
'still open.'];
}
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.status === 403) {
throw new Error(`403 from Stripe: the restricted key lacks read access to ${path}`);
}
if (!res.ok) throw new Error(`${res.status} from ${url.pathname}`);
return res.json();
}
export async function pausedSubscriptions(key, limit = 1000) {
const out = [];
const params = { status: 'paused', limit: 100, 'expand[]': 'data.customer' };
for (;;) {
const page = await get(key, '/subscriptions', params);
const data = page.data ?? [];
out.push(...data);
if (data.length === 0 || !page.has_more || out.length >= 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 subs = await pausedSubscriptions(key);
const now = Math.floor(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);
console.warn(`${state.padEnd(10)} ${sub.id} ${detail}`);
if (state === 'resumable') {
console.warn(` repair: POST ${API}/subscriptions/${sub.id} ` +
'-d pause_collection= -d default_payment_method={pm}');
} else if (state === 'stale') {
console.warn(' repair: count it as churn, or send a billing portal link ' +
'before you do');
}
}
console.log(`${subs.length} paused subscription(s): ` +
`${counts.get('resumable') ?? 0} resumable, ` +
`${counts.get('stale') ?? 0} stale`);
if (subs.length > 0) {
console.log('handle customer.subscription.paused so this list has an owner');
}
process.exitCode = subs.length > 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 the tests hold in place. A subscription with a card on file is resumable however old it is, because age is a priority signal and a usable payment method is a certainty; sorting it by age first would bury the easiest recovery in the list. And the interval comes from the subscription's own price, so a yearly plan paused for two months is recent while a monthly one paused for the same two months is stale.
from stripe_paused_subscriptions import verdict
NOW = 1_800_000_000
DAY = 86400
def paused(days_ago, interval="month", count=1, **extra):
body = {
"id": "sub_1",
"status": "paused",
"trial_end": NOW - days_ago * DAY,
"items": {"data": [{"price": {"recurring": {"interval": interval,
"interval_count": count}}}]},
}
body.update(extra)
return body
def test_a_card_on_file_beats_age():
state, _ = verdict(paused(400, default_payment_method="pm_1"), NOW)
assert state == "resumable"
def test_a_customer_default_counts_as_a_card():
customer = {"invoice_settings": {"default_payment_method": "pm_2"}}
state, _ = verdict(paused(400, customer=customer), NOW)
assert state == "resumable"
def test_past_one_billing_interval_is_dead_inventory():
state, detail = verdict(paused(90), NOW)
assert state == "stale"
assert "90 day(s)" in detail
def test_the_interval_comes_from_this_subscriptions_own_price():
# Two months is stale on a monthly plan and recent on a yearly one.
assert verdict(paused(60, interval="year"), NOW)[0] == "recent"
assert verdict(paused(60, interval="month"), NOW)[0] == "stale"
def test_only_paused_is_this_problem():
state, _ = verdict(paused(90, status="active"), NOW)
assert state == "not-paused"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict } from './stripe-paused-subscriptions.mjs';
const NOW = 1800000000;
const DAY = 86400;
function sub(daysAgo, { interval = 'month', count = 1, ...extra } = {}) {
return {
id: 'sub_1',
status: 'paused',
trial_end: NOW - daysAgo * DAY,
items: { data: [{ price: { recurring: { interval, interval_count: count } } }] },
...extra,
};
}
test('a card on file beats age', () => {
assert.equal(verdict(sub(400, { default_payment_method: 'pm_1' }), NOW)[0],
'resumable');
});
test('a customer default counts as a card', () => {
const customer = { invoice_settings: { default_payment_method: 'pm_2' } };
assert.equal(verdict(sub(400, { customer }), NOW)[0], 'resumable');
});
test('past one billing interval is dead inventory', () => {
const [state, detail] = verdict(sub(90), NOW);
assert.equal(state, 'stale');
assert.match(detail, /90 day/);
});
test('the interval comes from this subscription own price', () => {
// Two months is stale on a monthly plan and recent on a yearly one.
assert.equal(verdict(sub(60, { interval: 'year' }), NOW)[0], 'recent');
assert.equal(verdict(sub(60, { interval: 'month' }), NOW)[0], 'stale');
});
test('only paused is this problem', () => {
assert.equal(verdict(sub(90, { status: 'active' }), NOW)[0], 'not-paused');
});
FAQ
How does a Stripe subscription end up in the paused status?
One way only: a trial ends, the subscription has no payment method, and trial_settings.end_behavior.missing_payment_method is set to pause. Stripe then stops creating invoices and holds the subscription there. The other two settings for that field, create_invoice and cancel, produce dunning and a cancellation instead.
Does a paused subscription resume by itself?
No. There is no timeout and no automatic exit. It stays paused until something updates it with a valid default payment method and clears pause_collection, and even then the status only changes once the invoice that update generates has been paid.
Is paused the same as setting pause_collection?
No, and the confusion is the reason both get missed. pause_collection is a field you set on any subscription and it leaves the status untouched, usually active. The paused status is reachable only from a trial ending without a card. A check for one finds nothing about the other.
Should a paused subscription keep its product access?
No. Nothing is being invoiced, so nothing is being paid for. Handle customer.subscription.paused by revoking access, and gate your entitlement check on status being active or trialing rather than on it not being canceled.
Can I find these with a read-only key?
Yes. Read access to Subscriptions lists them and read access to Customers lets you expand data.customer to see whether a card turned up since. Neither permission can move money or change a subscription.
Related field notes
- Trials ending in days with no card on file
- pause_collection with no resumes_at silently bills nothing
- Active subscriptions with nothing to charge on 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.
- How subscriptions work — Stripe Docs
- Subscription trials — Stripe Docs
- The subscription object — Stripe API reference
- Pause payment collection — Stripe Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.