Diagnostic Stripe
unpaid subscriptions keep access and stop billing entirely
A handful of customers are still logged in and still using everything, and the last money any of them sent you was months ago. Their subscriptions are not canceled and they are not past_due either. They are unpaid, which is the state Stripe parks a subscription in when it has finished trying and been told not to cancel.
Read GET /v1/subscriptions?status=unpaid&limit=100. Every row is a customer whose dunning is over. Then, per subscription, GET /v1/invoices?subscription={sub_id}&status=draft&limit=100: a stack of drafts is Stripe still generating the invoice each period and immediately closing it, because on an unpaid subscription payment is not attempted.
Two fixes, and you need both. Gate provisioning on status being active or trialing, so unpaid revokes access. And change the end-of-retries behaviour under Billing, Revenue recovery, Retries to cancel instead, so nothing else lands here.
The problem in plain words
unpaid is a terminal state that looks like a live one. The subscription object is still there, the items are still there, ended_at is null, and every report built on "not canceled" counts it as a subscriber. Meanwhile Stripe has stopped collecting: the docs are explicit that once a subscription is unpaid, subsequent invoices are created but immediately closed and payments are not attempted.
So the meter keeps running on your side and the till is switched off on Stripe's. The customer accrues an invoice every period that nobody will ever send, ever finalise, or ever charge. Nothing errors, because nothing is being tried.
It is also the one dunning outcome that produces no further events. A subscription that cancels fires customer.subscription.deleted and your revocation code runs. A subscription that goes unpaid fires customer.subscription.updated once, months ago, and then goes quiet forever.
Why it happens
Somebody chose "mark unpaid" instead of "cancel". The end-of-retries action is a Dashboard setting with three options: cancel, mark unpaid, or leave past due. "Mark unpaid" sounds like the cautious one, the choice that keeps the record around in case the customer comes back. It is cautious about the record and reckless about the access.
The entitlement check was written against the statuses that existed in the demo. active, trialing, canceled. unpaid is reachable only after a full dunning cycle has run out, so it never appears in testing, and a check written as status != "canceled" passes it straight through.
The draft invoices hide the size of it. Draft invoices do not appear in revenue reports, do not appear in accounts receivable, and do not appear in the customer's invoice history in the portal. The amount owed is real and it is completely absent from every screen anyone looks at.
It is not the same failure as past_due. A past_due subscription is one Stripe is still working on, with a scheduled retry you can read. An unpaid one has no next attempt because attempts have stopped being a thing that happens. Detecting one does not detect the other, and the past-due note covers the earlier half.
The fix, as a flow
The script reads the unpaid list, then the draft invoices behind each row, because the drafts are the only place the growing balance is recorded and auto_advance is what says whether anyone can still collect it.
How to fix it
List the unpaid subscriptions
GET /v1/subscriptions?status=unpaid&limit=100. Do it in both modes. Any result at all is a finding: unpaid is not a state a healthy account passes through, it is a state a subscription is left in.
Count the drafts behind each one
GET /v1/invoices?subscription={sub_id}&status=draft&limit=100. Read auto_advance on each: false is Stripe telling you this invoice will never finalise on its own. Sum amount_due across them and you have the number that is missing from your AR.
Fix the entitlement check first
This is the part that is costing you money right now. Gate on status in ("active", "trialing"). Every other status, including unpaid, past_due and paused, is a customer who should not be served.
Decide what to do with the balance owed
For a customer worth chasing, restart collection on the stranded drafts: POST /v1/invoices/{inv} with auto_advance=true, or POST /v1/invoices/{inv}/send to mail the hosted invoice. For one that is gone, cancel the subscription so it stops generating invoices nobody will ever look at.
Change the end-of-dunning action
Dashboard, then Billing, Revenue recovery, Retries. Set the final action to cancel the subscription. That makes customer.subscription.deleted fire, which is the event your revocation code is already listening for.
How to check it worked
Re-run the script. The unpaid query should come back empty, and the count of stranded drafts with it.
python3 stripe_unpaid_subscriptions.py
# 0 unpaid subscription(s), 0 stranded draft invoice(s)
The full code
Two GETs per subscription and no writes — the subscription list, then the draft invoices behind each row. A restricted key with read access to Subscriptions and Invoices covers it. The classifier is pure and takes the subscription plus its drafts, because the distinction that matters is not whether drafts exist but whether auto_advance is off on them: that flag is the difference between invoices somebody has already restarted and invoices Stripe closed the moment it made them.
"""Report unpaid subscriptions and the draft invoices stranded behind them.
Read only. GETs 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 requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("stripe_unpaid_subscriptions")
API = "https://api.stripe.com/v1"
def verdict(sub, drafts):
"""Classify one subscription and the draft invoices behind it.
`drafts` is this subscription's invoices already filtered to status draft.
Pure, so the rules are visible and testable without a network.
Returns (state, detail).
"""
status = sub.get("status")
if status != "unpaid":
return ("not-unpaid",
"status is %r, which is a different problem than this one"
% (status,))
# auto_advance false is Stripe saying this invoice will never finalise by
# itself. On an unpaid subscription that is every invoice it generates.
closed = [d for d in drafts if not d.get("auto_advance")]
if closed:
owed = sum(d.get("amount_due") or 0 for d in closed)
return ("stranded",
"%d draft invoice(s) worth %d (minor units) were created and "
"closed without a payment attempt" % (len(closed), owed))
if drafts:
return ("collecting",
"%d draft invoice(s) still carry auto_advance, so somebody has "
"already restarted collection here" % len(drafts))
return ("silent",
"no invoices since dunning ended. Billing stopped at the last "
"past_due invoice and access is whatever your app still grants.")
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 page_all(session, path, limit, **params):
"""Walk a Stripe list endpoint, stopping at `limit` objects."""
out = []
params = dict(params, limit=100)
while True:
page = get(session, path, **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=500,
help="stop after this many unpaid 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_all(s, "/subscriptions", args.max_subscriptions, status="unpaid")
if not subs:
log.info("0 unpaid subscription(s), 0 stranded draft invoice(s)")
return 0
stranded = 0
for sub in subs:
drafts = page_all(s, "/invoices", 100, subscription=sub["id"], status="draft")
state, detail = verdict(sub, drafts)
log.warning("%-11s %s %s", state, sub["id"], detail)
if state == "stranded":
stranded += len(drafts)
log.warning(" repair: for each draft, POST %s/invoices/{inv} "
"-d auto_advance=true (or /send to mail it)", API)
log.warning(" repair: gate provisioning on status in (active, trialing); "
"unpaid must revoke")
log.warning(" repair: Billing, Revenue recovery, Retries: set the final "
"action to cancel instead of mark unpaid")
log.info("%d unpaid subscription(s), %d stranded draft invoice(s)",
len(subs), stranded)
return 1
if __name__ == "__main__":
sys.exit(main())
/**
* Report unpaid subscriptions and the draft invoices stranded behind them.
*
* Read only. GETs 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';
/**
* Classify one subscription and the draft invoices behind it. Pure, so the
* rules are visible and testable without a network.
*/
export function verdict(sub, drafts) {
const status = sub.status;
if (status !== 'unpaid') {
return ['not-unpaid',
`status is ${JSON.stringify(status)}, which is a different problem than this one`];
}
// auto_advance false is Stripe saying this invoice will never finalise by
// itself. On an unpaid subscription that is every invoice it generates.
const closed = (drafts ?? []).filter((d) => !d.auto_advance);
if (closed.length > 0) {
const owed = closed.reduce((t, d) => t + (d.amount_due ?? 0), 0);
return ['stranded',
`${closed.length} draft invoice(s) worth ${owed} (minor units) were ` +
'created and closed without a payment attempt'];
}
if ((drafts ?? []).length > 0) {
return ['collecting',
`${drafts.length} draft invoice(s) still carry auto_advance, so somebody ` +
'has already restarted collection here'];
}
return ['silent',
'no invoices since dunning ended. Billing stopped at the last past_due ' +
'invoice and access is whatever your app still grants.'];
}
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 pageAll(key, path, limit, params = {}) {
const out = [];
const q = { ...params, limit: 100 };
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) break;
q.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 pageAll(key, '/subscriptions', 500, { status: 'unpaid' });
if (subs.length === 0) {
console.log('0 unpaid subscription(s), 0 stranded draft invoice(s)');
return;
}
let stranded = 0;
for (const sub of subs) {
const drafts = await pageAll(key, '/invoices', 100,
{ subscription: sub.id, status: 'draft' });
const [state, detail] = verdict(sub, drafts);
console.warn(`${state.padEnd(11)} ${sub.id} ${detail}`);
if (state === 'stranded') {
stranded += drafts.length;
console.warn(` repair: for each draft, POST ${API}/invoices/{inv} ` +
'-d auto_advance=true (or /send to mail it)');
}
console.warn(' repair: gate provisioning on status in (active, trialing); ' +
'unpaid must revoke');
console.warn(' repair: Billing, Revenue recovery, Retries: set the final ' +
'action to cancel instead of mark unpaid');
}
console.log(`${subs.length} unpaid subscription(s), ${stranded} stranded ` +
'draft invoice(s)');
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 worth pinning is a draft invoice with no auto_advance key at all. Absent is not the same as true, and treating it as such would report a subscription somebody is actively collecting on when in fact Stripe closed the invoice on creation. The tests also keep stranded and silent apart, because one has a balance to chase and the other only has access to revoke.
from stripe_unpaid_subscriptions import verdict
UNPAID = {"id": "sub_1", "status": "unpaid"}
def test_unpaid_with_closed_drafts_reports_the_balance_owed():
state, detail = verdict(UNPAID, [{"auto_advance": False, "amount_due": 2500},
{"auto_advance": False, "amount_due": 2500}])
assert state == "stranded"
assert "5000" in detail
def test_missing_auto_advance_counts_as_closed():
# Absent is not true. A draft Stripe closed on creation carries no flag at
# all, and reading that as collecting hides the whole finding.
state, _ = verdict(UNPAID, [{"amount_due": 900}])
assert state == "stranded"
def test_drafts_with_auto_advance_mean_somebody_restarted_collection():
state, _ = verdict(UNPAID, [{"auto_advance": True, "amount_due": 900}])
assert state == "collecting"
def test_unpaid_with_no_invoices_is_its_own_finding():
# Nothing to chase, but the access is still granted.
state, detail = verdict(UNPAID, [])
assert state == "silent"
assert "past_due" in detail
def test_a_past_due_subscription_is_not_this_problem():
state, _ = verdict({"id": "sub_2", "status": "past_due"}, [])
assert state == "not-unpaid"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict } from './stripe-unpaid-subscriptions.mjs';
const UNPAID = { id: 'sub_1', status: 'unpaid' };
test('unpaid with closed drafts reports the balance owed', () => {
const [state, detail] = verdict(UNPAID, [
{ auto_advance: false, amount_due: 2500 },
{ auto_advance: false, amount_due: 2500 },
]);
assert.equal(state, 'stranded');
assert.match(detail, /5000/);
});
test('missing auto_advance counts as closed', () => {
// Absent is not true. A draft Stripe closed on creation has no flag at all.
assert.equal(verdict(UNPAID, [{ amount_due: 900 }])[0], 'stranded');
});
test('drafts with auto_advance mean somebody restarted collection', () => {
assert.equal(verdict(UNPAID, [{ auto_advance: true, amount_due: 900 }])[0],
'collecting');
});
test('unpaid with no invoices is its own finding', () => {
const [state, detail] = verdict(UNPAID, []);
assert.equal(state, 'silent');
assert.match(detail, /past_due/);
});
test('a past_due subscription is not this problem', () => {
assert.equal(verdict({ id: 'sub_2', status: 'past_due' }, [])[0], 'not-unpaid');
});
FAQ
What does the unpaid subscription status actually mean in Stripe?
It is one of the three things Stripe can do when dunning finishes: cancel, leave past due, or mark unpaid. On an unpaid subscription Stripe keeps generating an invoice each billing period, but those invoices are created and immediately closed and no payment is attempted. The subscription is alive as a record and dead as a source of revenue.
Does an unpaid subscription still count as active in my app?
It does if your entitlement check asks whether the status is canceled. It is not canceled, so the answer is yes and the customer keeps everything. Gate on status being active or trialing instead; that one change turns unpaid, past_due and paused into revocations without needing to enumerate them.
Can I collect the money from the draft invoices?
Yes, one at a time. POST /v1/invoices/{id} with auto_advance=true restarts Stripe's collection on a draft, and POST /v1/invoices/{id}/send mails the hosted invoice link. Neither happens on its own while the subscription stays unpaid, which is exactly why the drafts piled up.
How is this different from past_due?
past_due means Stripe is still trying: there is an attempt_count and usually a next_payment_attempt you can read. unpaid means it has stopped trying, permanently, and no further attempt will ever be scheduled. A check that finds one will not find the other.
Which webhook tells me a subscription went unpaid?
customer.subscription.updated, once, with the status change in the payload. There is no dedicated event and nothing fires afterwards, so if that one update was missed the subscription simply goes quiet. This is the main practical argument for setting the end-of-retries action to cancel, which fires customer.subscription.deleted instead.
Related field notes
- past_due subscriptions keep their access forever
- Dunning ran out of retries and no attempt is scheduled
- Open invoices past their due date
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
- Collection methods — Stripe Docs
- How subscriptions work — Stripe Docs
- The invoice object — Stripe API reference
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.