Diagnostic Stripe
active subscriptions already committed to cancel at period end
MRR is flat, the active-subscriber count is flat, and nothing in the billing data looks wrong. A month from now a fifth of it disappears in a week. The cancellations already happened; they just have not taken effect yet, and no report you have distinguishes a subscription that will renew from one that will not.
Page GET /v1/subscriptions?status=active&limit=100 and count the rows where cancel_at_period_end is true or cancel_at is set. Divide by the total and you have a pending-churn rate, which is a number nobody has because the status column does not carry it.
Then bucket by items.data[0].current_period_end to find the cliff, and read cancellation_details.feedback for why. Do not use canceled_at for the date: it records when the flag was set, not when service ends.
The problem in plain words
cancel_at_period_end=true is a scheduled deletion that leaves everything else alone. The status stays active until the period boundary, the invoice for the current period is already paid, and the customer keeps full access. Every property a dashboard queries on says this is a healthy subscription right up until the day it is gone.
So the churn is fully committed and entirely unrecorded. The decision was made when the customer clicked cancel; the number moves weeks later. In the gap, the growth chart is describing a company that no longer exists.
The date arithmetic makes it worse. canceled_at is populated the moment the flag is set, which reads like a cancellation date and is not one. An analysis that groups by canceled_at puts the churn in the wrong month in one direction and an analysis that ignores the flag entirely puts it in the wrong month in the other.
Why it happens
The billing portal makes cancelling easy and makes it invisible. A customer cancels in the portal, the subscription is flagged, and unless something is listening for the flag flipping on customer.subscription.updated, the only record is a boolean on an object nobody re-reads.
Everything downstream is keyed on status. Entitlement checks, seat counts, revenue reports, the sales team's account list. All of them ask what the status is, all of them get active, and all of them are correct about today and wrong about next month.
The reason for the cancellation is captured only if it was configured. cancellation_details.feedback and comment are populated by the billing portal when subscription_cancel.cancellation_reason is enabled in the portal configuration. If it was never turned on, the backlog tells you how much you are losing and nothing at all about why.
It is reversible until it is not. Setting cancel_at_period_end=false before the boundary restores the subscription completely, with no new signup and no re-entry of card details. After the boundary the subscription is canceled and the customer has to start again. The window is the whole opportunity, and it closes on a date the flag does not advertise.
The fix, as a flow
The script counts the flag against the whole active list to get a rate, and takes the end date from the subscription item rather than from canceled_at, which records the click and not the departure.
How to fix it
Count the flag across every active subscription
Page the whole list. cancel_at_period_end is on the subscription object, and cancel_at catches the ones scheduled for a specific date rather than a period boundary. Both are pending churn.
Express it as a rate, not a count
Scheduled divided by total active. A count means nothing without the denominator; a rate is comparable week to week and is the version of this number that belongs on a dashboard.
Find the nearest cliff
Bucket by items.data[0].current_period_end, falling back to cancel_at. Fifty cancellations spread over a year is attrition. Fifty landing in the same week is an incident, usually a price change or an outage, and it needs an answer before that week.
Read the reasons if you have them
cancellation_details.feedback takes values like too_expensive, missing_features and switched_service. If every row is null, enable subscription_cancel.cancellation_reason on the portal configuration so the next cohort tells you something.
Reverse the ones worth reversing, and watch the flag from now on
POST /v1/subscriptions/{sub} with cancel_at_period_end=false reinstates a subscription with no re-signup. Structurally, trigger a save offer from customer.subscription.updated when the flag flips, on the day it flips, rather than finding it in a quarterly review.
How to check it worked
Re-run the script. The pending-churn rate is the number you want on a dashboard, and the nearest cliff is the one you want on a calendar.
python3 stripe_pending_churn.py
# backlog 7 of 412 active subscriptions (1.7%) are scheduled to cancel
The full code
One paged GET over the active subscriptions and no writes; read access to Subscriptions is all the key needs. The classifier is pure and takes three numbers — how many are scheduled, how many are active in total, and how many days until the soonest one ends — because a low rate with a cliff next week and a high rate spread over a year are different findings and the count alone cannot tell them apart.
"""Report active subscriptions already scheduled to cancel, as a rate and a date.
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_pending_churn")
API = "https://api.stripe.com/v1"
DAY = 86400
ELEVATED_RATE = 0.10 # scheduled / active above this is a trend, not attrition
CLIFF_DAYS = 7 # a cancellation this close needs an answer this week
def scheduled_end(sub):
"""When service actually ends, or None if it is not scheduled to.
Deliberately not `canceled_at`: that is populated when the flag is set, not
when the subscription stops, and grouping by it dates the churn wrongly.
"""
if sub.get("cancel_at"):
return sub["cancel_at"]
if not sub.get("cancel_at_period_end"):
return None
items = (sub.get("items") or {}).get("data") or []
if items and items[0].get("current_period_end"):
return items[0]["current_period_end"]
return sub.get("current_period_end")
def verdict(scheduled, active_total, soonest_days):
"""Classify a pending-churn backlog. Pure, so the rules can be tested.
`soonest_days` is days until the nearest scheduled end, or None if nothing
is scheduled. Returns (state, detail).
"""
if not active_total:
return ("empty", "no active subscriptions in this account and mode")
if not scheduled:
return ("clear", "%d active subscription(s), none scheduled to cancel"
% active_total)
rate = scheduled / active_total
summary = ("%d of %d active subscription(s) (%.1f%%) are scheduled to cancel"
% (scheduled, active_total, rate * 100))
if soonest_days is not None and soonest_days <= CLIFF_DAYS:
return ("imminent",
"%s, the first in %d day(s)" % (summary, soonest_days))
if rate >= ELEVATED_RATE:
return ("elevated",
"%s. Above %d%% this is a trend with a cause, not attrition."
% (summary, int(ELEVATED_RATE * 100)))
return ("backlog", summary)
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())
ends = []
reasons = {}
for sub in subs:
end = scheduled_end(sub)
if end is None:
continue
ends.append(end)
why = (sub.get("cancellation_details") or {}).get("feedback") or "not captured"
reasons[why] = reasons.get(why, 0) + 1
soonest = int((min(ends) - now) // DAY) if ends else None
state, detail = verdict(len(ends), len(subs), soonest)
if state in ("clear", "empty"):
log.info("%-9s %s", state, detail)
return 0
log.warning("%-9s %s", state, detail)
log.warning(" reasons: %s",
", ".join("%s x%d" % (k, v) for k, v in sorted(reasons.items())))
if reasons.get("not captured"):
log.warning(" repair: enable subscription_cancel.cancellation_reason on the "
"billing portal configuration so reasons are recorded")
log.warning(" repair: per salvageable subscription, POST %s/subscriptions/{sub} "
"-d cancel_at_period_end=false", API)
log.warning(" repair: trigger the save offer from customer.subscription.updated "
"on the day the flag flips")
return 1
if __name__ == "__main__":
sys.exit(main())
/**
* Report active subscriptions already scheduled to cancel, as a rate and a date.
*
* 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;
const ELEVATED_RATE = 0.10; // scheduled / active above this is a trend
const CLIFF_DAYS = 7; // a cancellation this close needs an answer this week
/**
* When service actually ends, or null if it is not scheduled to.
* Deliberately not `canceled_at`: that is populated when the flag is set.
*/
export function scheduledEnd(sub) {
if (sub.cancel_at) return sub.cancel_at;
if (!sub.cancel_at_period_end) return null;
const items = sub.items?.data ?? [];
if (items.length > 0 && items[0].current_period_end) {
return items[0].current_period_end;
}
return sub.current_period_end ?? null;
}
/** Classify a pending-churn backlog. Pure, so the rules can be tested. */
export function verdict(scheduled, activeTotal, soonestDays) {
if (!activeTotal) return ['empty', 'no active subscriptions in this account and mode'];
if (!scheduled) {
return ['clear', `${activeTotal} active subscription(s), none scheduled to cancel`];
}
const rate = scheduled / activeTotal;
const summary = `${scheduled} of ${activeTotal} active subscription(s) ` +
`(${(rate * 100).toFixed(1)}%) are scheduled to cancel`;
if (soonestDays !== null && soonestDays !== undefined && soonestDays <= CLIFF_DAYS) {
return ['imminent', `${summary}, the first in ${soonestDays} day(s)`];
}
if (rate >= ELEVATED_RATE) {
return ['elevated',
`${summary}. Above ${Math.round(ELEVATED_RATE * 100)}% this is a trend ` +
'with a cause, not attrition.'];
}
return ['backlog', summary];
}
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 ends = [];
const reasons = new Map();
for (const sub of subs) {
const end = scheduledEnd(sub);
if (end === null) continue;
ends.push(end);
const why = sub.cancellation_details?.feedback ?? 'not captured';
reasons.set(why, (reasons.get(why) ?? 0) + 1);
}
const soonest = ends.length > 0
? Math.floor((Math.min(...ends) - now) / DAY) : null;
const [state, detail] = verdict(ends.length, subs.length, soonest);
if (state === 'clear' || state === 'empty') {
console.log(`${state.padEnd(9)} ${detail}`);
return;
}
console.warn(`${state.padEnd(9)} ${detail}`);
const listed = [...reasons.entries()].sort().map(([k, v]) => `${k} x${v}`).join(', ');
console.warn(` reasons: ${listed}`);
if (reasons.get('not captured')) {
console.warn(' repair: enable subscription_cancel.cancellation_reason on the ' +
'billing portal configuration so reasons are recorded');
}
console.warn(` repair: per salvageable subscription, POST ${API}/subscriptions/{sub} ` +
'-d cancel_at_period_end=false');
console.warn(' repair: trigger the save offer from customer.subscription.updated ' +
'on the day the flag flips');
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 ordering is the thing under test. A single cancellation landing in three days is a more urgent finding than fifty spread across the next year, so the date has to be checked before the rate; reverse the two and the one subscription you could still have saved is reported as a rounding error. The rest pins the empty account, which is not a clean bill of health, and the rate boundary itself.
from stripe_pending_churn import scheduled_end, verdict
def test_an_imminent_cliff_outranks_a_low_rate():
# One cancellation in three days beats sixty spread over a year.
state, detail = verdict(1, 400, 3)
assert state == "imminent"
assert "3 day(s)" in detail
def test_a_high_rate_far_out_is_a_trend():
state, _ = verdict(60, 400, 200)
assert state == "elevated"
def test_a_handful_far_out_is_just_a_backlog():
state, detail = verdict(8, 400, 200)
assert state == "backlog"
assert "2.0%" in detail
def test_no_active_subscriptions_is_not_a_clean_bill_of_health():
state, _ = verdict(0, 0, None)
assert state == "empty"
def test_the_end_date_comes_from_the_item_not_from_canceled_at():
sub = {"cancel_at_period_end": True, "canceled_at": 1,
"items": {"data": [{"current_period_end": 999}]}}
assert scheduled_end(sub) == 999
assert scheduled_end({"cancel_at_period_end": False, "canceled_at": 1}) is None
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { scheduledEnd, verdict } from './stripe-pending-churn.mjs';
test('an imminent cliff outranks a low rate', () => {
// One cancellation in three days beats fifty spread over a year.
const [state, detail] = verdict(1, 400, 3);
assert.equal(state, 'imminent');
assert.match(detail, /3 day/);
});
test('a high rate far out is a trend', () => {
assert.equal(verdict(60, 400, 200)[0], 'elevated');
});
test('a handful far out is just a backlog', () => {
const [state, detail] = verdict(8, 400, 200);
assert.equal(state, 'backlog');
assert.match(detail, /2.0%/);
});
test('no active subscriptions is not a clean bill of health', () => {
assert.equal(verdict(0, 0, null)[0], 'empty');
});
test('the end date comes from the item not from canceled_at', () => {
const sub = {
cancel_at_period_end: true,
canceled_at: 1,
items: { data: [{ current_period_end: 999 }] },
};
assert.equal(scheduledEnd(sub), 999);
assert.equal(scheduledEnd({ cancel_at_period_end: false, canceled_at: 1 }), null);
});
FAQ
Why do subscriptions scheduled to cancel still show as active?
Because cancel_at_period_end is a schedule, not a state change. The customer has paid for the current period and keeps access until it ends, so Stripe leaves the status as active until the boundary. The status becomes canceled only when service actually stops.
Can I undo cancel_at_period_end?
Yes, at any point before the period ends: POST /v1/subscriptions/{id} with cancel_at_period_end=false restores it completely, with no new signup and no card re-entry. Once the boundary passes the subscription is canceled and the customer has to subscribe again.
Is canceled_at the date the subscription ends?
No, and this trips up a lot of churn analysis. canceled_at is when the cancellation was requested, which is when the flag was set. The end of service is cancel_at, or the current period end on the subscription item. Grouping by canceled_at dates the churn to the wrong month.
Why is cancellation_details.feedback null on all my subscriptions?
It is populated by the billing portal, and only when subscription_cancel.cancellation_reason is enabled on the portal configuration. Cancellations made through the API or the Dashboard without a reason leave it null. Turn it on and the next cohort explains itself.
What counts as a high pending-churn rate?
There is no universal figure, which is why the rate matters more than the threshold: track it weekly and watch it move. As a starting point, a script that flags anything at or above ten percent of active subscriptions, or any cancellation landing within a week, catches both the trend and the cliff.
Related field notes
- customer.subscription.deleted is missing so access never ends
- The billing portal has no configuration
- unpaid subscriptions keep access and stop billing entirely
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
- List subscriptions — Stripe API reference
- The portal configuration object — Stripe API reference
- 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.