Diagnostic Stripe
pause_collection with no resumes_at silently bills nothing
One customer had a bad month, support paused their billing for a while, and everybody moved on. That was in February. The subscription still reads active in every report you have, it still shows up in the subscriber count, and it has not produced a single invoice since.
Read GET /v1/subscriptions?status=active&limit=100 and flag any row where pause_collection is not null and pause_collection.resumes_at is null. The status is deliberately unchanged by this field, so nothing else in your reporting will ever surface it.
Then read pause_collection.behavior, because it decides whether the missed periods are recoverable. keep_as_draft leaves invoices you can still finalise. void and mark_uncollectible throw each one away as it is created.
The problem in plain words
The Stripe docs are explicit that pausing collection does not change the subscription's status. That is the whole design: the customer keeps their access, the subscription keeps its schedule, and only the collection is suspended. It is exactly what you want for a two-week grace period.
It is also why nothing catches it when the two weeks never end. status is active, so it is in the active-subscriber count. Nothing is past_due, so it is in no dunning report. No payment failed, so no failure event fired. The only visible symptom is an absence: money that should be arriving and is not, spread thinly enough across one customer at a time to look like nothing.
resumes_at is optional, and omitting it means "until a human says otherwise". A support agent handling a refund request has no reason to think about a date three months out, and the API does not ask them to.
Why it happens
The grace period is granted by whoever is on shift and owned by nobody. The pause is a response to a conversation. There is no ticket for un-pausing it, because un-pausing was never an action anyone planned to take — it was assumed to be the default that time would restore.
The behaviour decides how much you lose, and it is chosen once and forgotten. With keep_as_draft, every skipped period leaves a draft invoice you can still finalise with auto_advance=true, so the money is merely delayed. With void or mark_uncollectible, Stripe disposes of each invoice as it is created and those periods are gone. Same pause, same duration, entirely different bill at the end.
It is invisible in the Dashboard list too. The subscriptions list shows the status column, and the status is active. You have to open the individual subscription to see the paused banner, which means you have to already suspect the subscription you are opening.
A resumes_at in the past is a different bug. When the date is set, Stripe resumes on it. A pause still in place with a resume date behind it means something is holding it there, and it deserves a look rather than being lumped in with the indefinite ones.
The fix, as a flow
The script never looks at the status, because the status is exactly what this field leaves alone. It reads pause_collection, then resumes_at, then the behaviour that decides what survives the pause.
How to fix it
Page the active subscriptions and read one field
GET /v1/subscriptions?status=active&limit=100. pause_collection is on the subscription object, so no expansion is needed. Page all of them: these are single customers scattered through a list, not a cluster.
Split on resumes_at
Null is the finding. A future timestamp is a pause with an end and needs nothing from you. A timestamp in the past with the pause still applied is its own oddity and worth surfacing separately rather than counting as healthy.
Read the behavior to know what is recoverable
keep_as_draft means the invoices exist and are collectable. void and mark_uncollectible mean they were disposed of on creation. Confirm with GET /v1/invoices?subscription={sub}&status=draft, or status=void if the behavior says so.
Resume collection
POST /v1/subscriptions/{sub} with pause_collection= and an empty value clears it. Then, for each stranded draft, POST /v1/invoices/{inv} with auto_advance=true so Stripe starts collecting on it again.
Make resumes_at mandatory in whatever tool support uses
Always send pause_collection[resumes_at] with a real timestamp. A pause that expires on its own is a grace period; a pause without one is a decision to stop billing a customer that nobody has written down anywhere.
How to check it worked
Re-run the script. Any remaining pauses should carry a future resumes_at.
python3 stripe_pause_collection.py
# 412 active subscription(s), 0 paused indefinitely, 2 scheduled to resume
The full code
One GET over the active subscriptions and no writes at all — a restricted key with read access to Subscriptions is the whole requirement. The classifier is pure and takes the subscription and the current time, so the four outcomes it separates are readable side by side: no pause, a pause with an end, a pause whose end has already gone by, and the indefinite one, split by whether its behaviour leaves anything to collect.
"""Report subscriptions left with pause_collection and no resumes_at.
Read only. One GET, no writes: give this a RESTRICTED key with read access to
Subscriptions. 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_pause_collection")
API = "https://api.stripe.com/v1"
DAY = 86400
# Behaviours that dispose of the invoice as it is created. keep_as_draft, the
# remaining one, leaves something you can still finalise.
DISCARDING = ("void", "mark_uncollectible")
def verdict(sub, now):
"""Classify one subscription's pause_collection. Pure, so it can be tested.
Note this never reads `status`: pause_collection leaves the status alone,
which is exactly why the field needs a check of its own.
Returns (state, detail).
"""
pause = sub.get("pause_collection")
if not pause:
return ("collecting", "no pause on this subscription")
behavior = pause.get("behavior") or "keep_as_draft"
resumes = pause.get("resumes_at")
if resumes is None:
if behavior in DISCARDING:
return ("unrecoverable",
"paused with no resumes_at and behavior %s: every invoice for "
"a paused period is disposed of as it is created" % behavior)
return ("indefinite",
"paused with no resumes_at and behavior %s: invoices accumulate "
"as drafts that nothing will finalise" % behavior)
if resumes <= now:
return ("overdue",
"resumes_at passed %d day(s) ago and collection is still paused"
% ((now - resumes) // DAY))
return ("scheduled",
"resumes in %d day(s); this pause has an end" % ((resumes - now) // DAY))
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 active_subscriptions(session, limit):
out = []
params = {"status": "active", "limit": 100}
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=5000,
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})
subs = active_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
if state in ("collecting", "scheduled"):
continue
log.warning("%-13s %s %s", state, sub["id"], detail)
log.warning(" repair: POST %s/subscriptions/%s -d pause_collection=",
API, sub["id"])
if state == "indefinite":
log.warning(" then per draft: POST %s/invoices/{inv} "
"-d auto_advance=true", API)
indefinite = counts.get("indefinite", 0) + counts.get("unrecoverable", 0)
log.info("%d active subscription(s), %d paused indefinitely, %d scheduled "
"to resume", len(subs), indefinite, counts.get("scheduled", 0))
if counts.get("overdue"):
log.info("%d still paused past their own resumes_at", counts["overdue"])
return 1 if indefinite or counts.get("overdue") else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Report subscriptions left with pause_collection and no resumes_at.
*
* Read only. One GET, no writes: give this a RESTRICTED key with read access to
* Subscriptions. The repair is printed, never performed.
*/
const API = 'https://api.stripe.com/v1';
const DAY = 86400;
// Behaviours that dispose of the invoice as it is created. keep_as_draft, the
// remaining one, leaves something you can still finalise.
const DISCARDING = new Set(['void', 'mark_uncollectible']);
/**
* Classify one subscription's pause_collection. Pure, so it can be tested.
* Never reads `status`: pause_collection leaves the status alone, which is
* exactly why the field needs a check of its own.
*/
export function verdict(sub, now) {
const pause = sub.pause_collection;
if (!pause) return ['collecting', 'no pause on this subscription'];
const behavior = pause.behavior ?? 'keep_as_draft';
const resumes = pause.resumes_at ?? null;
if (resumes === null) {
if (DISCARDING.has(behavior)) {
return ['unrecoverable',
`paused with no resumes_at and behavior ${behavior}: every invoice for ` +
'a paused period is disposed of as it is created'];
}
return ['indefinite',
`paused with no resumes_at and behavior ${behavior}: invoices accumulate ` +
'as drafts that nothing will finalise'];
}
if (resumes <= now) {
return ['overdue',
`resumes_at passed ${Math.floor((now - resumes) / DAY)} day(s) ago and ` +
'collection is still paused'];
}
return ['scheduled',
`resumes in ${Math.floor((resumes - now) / DAY)} day(s); this pause has an end`];
}
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 activeSubscriptions(key, limit = 5000) {
const out = [];
const params = { status: 'active', limit: 100 };
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 activeSubscriptions(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);
if (state === 'collecting' || state === 'scheduled') continue;
console.warn(`${state.padEnd(13)} ${sub.id} ${detail}`);
console.warn(` repair: POST ${API}/subscriptions/${sub.id} -d pause_collection=`);
if (state === 'indefinite') {
console.warn(` then per draft: POST ${API}/invoices/{inv} -d auto_advance=true`);
}
}
const indefinite = (counts.get('indefinite') ?? 0) + (counts.get('unrecoverable') ?? 0);
console.log(`${subs.length} active subscription(s), ${indefinite} paused ` +
`indefinitely, ${counts.get('scheduled') ?? 0} scheduled to resume`);
if (counts.get('overdue')) {
console.log(`${counts.get('overdue')} still paused past their own resumes_at`);
}
process.exitCode = (indefinite || counts.get('overdue')) ? 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
The split that earns its own state is behavior. Two subscriptions paused on the same day for the same length of time produce a recoverable pile of drafts under keep_as_draft and nothing at all under void, so collapsing them into one finding would tell you to go and collect invoices that no longer exist. The tests also cover the omitted behavior, which is keep_as_draft rather than a reason to skip the row.
from stripe_pause_collection import verdict
NOW = 1_800_000_000
DAY = 86400
def test_no_pause_is_collecting():
assert verdict({"id": "sub_1"}, NOW)[0] == "collecting"
assert verdict({"id": "sub_1", "pause_collection": None}, NOW)[0] == "collecting"
def test_indefinite_keep_as_draft_leaves_something_to_collect():
state, detail = verdict(
{"pause_collection": {"behavior": "keep_as_draft", "resumes_at": None}}, NOW)
assert state == "indefinite"
assert "drafts" in detail
def test_indefinite_void_throws_the_invoices_away():
# Same pause, same duration, nothing left to finalise at the end of it.
state, _ = verdict(
{"pause_collection": {"behavior": "void", "resumes_at": None}}, NOW)
assert state == "unrecoverable"
def test_a_future_resumes_at_is_a_pause_with_an_end():
state, _ = verdict(
{"pause_collection": {"behavior": "void", "resumes_at": NOW + 14 * DAY}}, NOW)
assert state == "scheduled"
def test_a_past_resumes_at_still_paused_is_its_own_oddity():
state, detail = verdict(
{"pause_collection": {"behavior": "keep_as_draft",
"resumes_at": NOW - 30 * DAY}}, NOW)
assert state == "overdue"
assert "30 day(s)" in detail
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict } from './stripe-pause-collection.mjs';
const NOW = 1800000000;
const DAY = 86400;
test('no pause is collecting', () => {
assert.equal(verdict({ id: 'sub_1' }, NOW)[0], 'collecting');
assert.equal(verdict({ id: 'sub_1', pause_collection: null }, NOW)[0], 'collecting');
});
test('indefinite keep_as_draft leaves something to collect', () => {
const [state, detail] = verdict(
{ pause_collection: { behavior: 'keep_as_draft', resumes_at: null } }, NOW);
assert.equal(state, 'indefinite');
assert.match(detail, /drafts/);
});
test('indefinite void throws the invoices away', () => {
// Same pause, same duration, nothing left to finalise at the end of it.
assert.equal(
verdict({ pause_collection: { behavior: 'void', resumes_at: null } }, NOW)[0],
'unrecoverable');
});
test('a future resumes_at is a pause with an end', () => {
assert.equal(
verdict({ pause_collection: { behavior: 'void', resumes_at: NOW + 14 * DAY } },
NOW)[0],
'scheduled');
});
test('a past resumes_at still paused is its own oddity', () => {
const [state, detail] = verdict(
{ pause_collection: { behavior: 'keep_as_draft', resumes_at: NOW - 30 * DAY } },
NOW);
assert.equal(state, 'overdue');
assert.match(detail, /30 day/);
});
FAQ
Does pause_collection change the subscription status?
No. The docs are explicit that the status is unchanged, which is the point of the feature and also why it goes unnoticed. A paused subscription usually still reads active, so it stays in the subscriber count and appears in no dunning or past-due report.
What happens to invoices while collection is paused?
It depends on pause_collection.behavior. keep_as_draft leaves a draft invoice for each period, which you can still finalise later. void and mark_uncollectible dispose of each invoice as it is created, so those periods cannot be billed afterwards.
How do I resume collection?
POST /v1/subscriptions/{id} with pause_collection set to an empty value clears it. If the behavior was keep_as_draft, the accumulated drafts stay drafts until you send each one auto_advance=true, so resuming the subscription alone does not collect the backlog.
Why should resumes_at always be set?
Because it turns a pause into a grace period. With a timestamp, Stripe resumes collection on that date without anyone remembering to. Without one, the pause lasts until a human notices, and the only signal that a human should notice is money quietly not arriving.
What does it mean if resumes_at is in the past and collection is still paused?
Something re-applied the pause or the update never took. It is not the same finding as an indefinite pause, so the script reports it separately rather than counting it as healthy just because a date is present.
Related field notes
- paused subscriptions never resume and never invoice again
- Draft invoices that never finalized
- past_due subscriptions keep their access forever
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.
- Pause payment collection — Stripe Docs
- The subscription object — Stripe API reference
- The invoice object — Stripe API reference
- How subscriptions work — Stripe Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.