Diagnostic Stripe
customer.subscription.deleted is missing, so access never ends
A support ticket arrives from somebody trying to be helpful: they cancelled two months ago and can still log in and use everything. You check Stripe and they really did cancel, on time, and the subscription really is canceled. The revenue reporting is correct. It is only the entitlement in your own database that has never been told.
Union the enabled_events arrays from GET /v1/webhook_endpoints and check for customer.subscription.deleted. It is the only event that fires when a subscription actually ends, including the delayed end of a cancel_at_period_end cancellation and the point where Smart Retries give up.
Then size the damage with GET /v1/subscriptions?status=canceled. Every one of those is a customer whose access your application was never asked to revoke.
The problem in plain words
Subscription handlers are written from the top down: created, then paid, then maybe updated. Each of those is a moment where something visible happens and somebody is watching. The ending is different. It happens on a date nobody chose, often weeks after the customer clicked cancel, and frequently with no human involved at all — a card expired, the retries ran out, and Stripe closed the subscription on a schedule.
Because the money side is entirely correct, none of your reporting disagrees with reality. MRR drops on cancellation, the invoices stop, the dashboards are accurate. The only wrong number is the one nobody looks at, which is how many accounts have an active flag they should not. It surfaces as an honest user writing in, or as an audit, or not at all.
Why it happens
Cancellation and ending are different moments. cancel_at_period_end sets a flag now and ends the subscription in three weeks. The update that sets the flag is customer.subscription.updated; the actual end, three weeks later, is customer.subscription.deleted. A handler wired only to the update either revokes access far too early or, more commonly, records the intent and never gets told when it takes effect.
Dunning ends the same way, with nobody present. When retries are exhausted the subscription can be set to cancel automatically. There is no click, no session, no request from the customer. If the deleted event is not subscribed, the only trace is a status change on an object nobody is polling.
The event is not implied by the ones you have. enabled_events is an allowlist. Subscribing to customer.subscription.created and invoice.paid gives you exactly those two. Nothing about a subscription family subscription includes its terminal event.
Nothing complains, because the error is a permission that is too generous. Bugs that deny access get reported within minutes. Bugs that grant it are reported by whoever happens to have a conscience, and the rest is quiet revenue leakage that looks like nothing at all.
The fix, as a flow
The script unions the subscribed types across every endpoint, then counts active and cancelled subscriptions, because the same missing type is noise on one account and a list of over entitled customers on another.
How to fix it
Establish that this account actually uses subscriptions
GET /v1/subscriptions?limit=100 for active and for canceled. On an account that has never had a subscription, a missing subscription event is not a finding, and reporting it is how a check trains people to ignore it.
Union enabled_events across every endpoint
Coverage belongs to the account, not to one endpoint. Billing events commonly live on a different destination from payment ones. A literal "*" covers this type, and brings its own problems.
Check for the companion event as well
customer.subscription.updated is what tells you a cancellation has been scheduled, along with plan changes and trials converting. Deleted without updated means you learn about every ending on the day it happens and nothing before it, which is enough for correctness and not enough for a retention email.
Count the customers who are already over-entitled
Every subscription in canceled is a candidate. Reconcile that list against your own entitlement table; the rows that disagree are the ones to fix, and the count is what makes the case for doing it this week.
Subscribe, then reconcile rather than waiting
POST /v1/webhook_endpoints/{id} adding customer.subscription.deleted and customer.subscription.updated. Subscribing fixes the future only: the existing over-entitled accounts have to be swept from the canceled list, because their events are long gone.
How to check it worked
Re-run the script. The union should contain the terminal event and the state should be covered.
python3 stripe_subscription_deleted_events.py
# covered customer.subscription.deleted is subscribed on at least one endpoint
The full code
Three GETs and no writes — endpoints, active subscriptions and canceled ones. A restricted key with read access to Webhook Endpoints and Subscriptions covers it. The classifier takes the subscription union plus both counts, because the same missing event type is noise on an account with no subscriptions, a gap on one with no cancellations yet, and a list of specific over-entitled customers on one that has them.
"""Report whether customer.subscription.deleted is subscribed, and who is over-entitled.
Read only. Three GETs, no writes: give this a RESTRICTED key with read access to
Webhook Endpoints and 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 requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("stripe_subscription_deleted_events")
API = "https://api.stripe.com/v1"
TARGET = "customer.subscription.deleted"
COMPANION = "customer.subscription.updated"
def verdict(subscribed, canceled, active):
"""Classify entitlement-revocation coverage. Pure, so the rules can be tested.
`subscribed` is the union of enabled_events across every endpoint, `canceled`
the number of subscriptions already ended, `active` the number still running.
Returns (state, detail).
"""
events = set(subscribed or [])
if not canceled and not active:
return ("not-billing",
"no subscriptions on this account at all, so %s is not a gap "
"worth reporting yet" % TARGET)
if "*" in events:
return ("wildcard",
"a wildcard subscription covers %s, but it also delivers every "
"other event type to the same handler." % TARGET)
if TARGET in events:
if COMPANION not in events:
return ("partial",
"%s is subscribed but %s is not. You learn that a "
"subscription ended, never that a cancellation was scheduled."
% (TARGET, COMPANION))
return ("covered", "%s is subscribed on at least one endpoint" % TARGET)
if canceled:
return ("over-entitled",
"%d canceled subscription(s) and nothing subscribes to %s. Each "
"one is an account your application was never asked to revoke."
% (canceled, TARGET))
return ("unsubscribed",
"%d active subscription(s) and nothing subscribes to %s. Nothing has "
"ended yet, so this is a gap rather than a backlog."
% (active, TARGET))
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 subscribed_events(endpoints):
"""Union of enabled_events across endpoints. Pure, given the endpoint list."""
union = set()
for ep in endpoints:
union.update(ep.get("enabled_events") or [])
return union
def count_subscriptions(session, status, limit):
"""Count subscriptions in one status, keeping the first few ids for the report."""
count = 0
ids = []
params = {"limit": 100, "status": status}
while True:
page = get(session, "/subscriptions", **params)
data = page.get("data", [])
for sub in data:
count += 1
if len(ids) < 10:
ids.append(sub["id"])
if not data or not page.get("has_more") or count >= limit:
break
params["starting_after"] = data[-1]["id"]
return count, ids
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--max-subscriptions", type=int, default=1000,
help="stop counting each status after this many")
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})
endpoints = get(s, "/webhook_endpoints", limit=100).get("data", [])
union = subscribed_events(endpoints)
canceled, canceled_ids = count_subscriptions(s, "canceled", args.max_subscriptions)
active, _ = count_subscriptions(s, "active", args.max_subscriptions)
state, detail = verdict(union, canceled, active)
line = "%-14s %s" % (state, detail)
if state in ("covered", "not-billing"):
log.info(line)
return 0
log.warning(line)
if state == "over-entitled":
for sid in canceled_ids:
log.warning(" reconcile: %s", sid)
if state != "wildcard":
log.warning(" repair: POST %s/webhook_endpoints/%s", API,
endpoints[0]["id"] if endpoints else "<we_id>")
log.warning(" -d enabled_events[]=%s", TARGET)
log.warning(" -d enabled_events[]=%s", COMPANION)
log.warning(" (enabled_events is replaced wholesale: send the existing "
"types too)")
log.warning(" then sweep GET %s/subscriptions?status=canceled against your "
"own entitlement table: subscribing fixes the future only", API)
return 1
if __name__ == "__main__":
sys.exit(main())
/**
* Report whether customer.subscription.deleted is subscribed, and who is over-entitled.
*
* Read only. Three GETs, no writes: give this a RESTRICTED key with read access
* to Webhook Endpoints and Subscriptions. The repair is printed, never performed.
*/
const API = 'https://api.stripe.com/v1';
const TARGET = 'customer.subscription.deleted';
const COMPANION = 'customer.subscription.updated';
/**
* Classify entitlement-revocation coverage. Pure, so the rules can be tested.
*/
export function verdict(subscribed, canceled, active) {
const events = new Set(subscribed ?? []);
if (!canceled && !active) {
return ['not-billing',
`no subscriptions on this account at all, so ${TARGET} is not a gap worth ` +
'reporting yet'];
}
if (events.has('*')) {
return ['wildcard',
`a wildcard subscription covers ${TARGET}, but it also delivers every ` +
'other event type to the same handler.'];
}
if (events.has(TARGET)) {
if (!events.has(COMPANION)) {
return ['partial',
`${TARGET} is subscribed but ${COMPANION} is not. You learn that a ` +
'subscription ended, never that a cancellation was scheduled.'];
}
return ['covered', `${TARGET} is subscribed on at least one endpoint`];
}
if (canceled) {
return ['over-entitled',
`${canceled} canceled subscription(s) and nothing subscribes to ${TARGET}. ` +
'Each one is an account your application was never asked to revoke.'];
}
return ['unsubscribed',
`${active} active subscription(s) and nothing subscribes to ${TARGET}. ` +
'Nothing has ended yet, so this is a gap rather than a backlog.'];
}
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();
}
/** Union of enabled_events across endpoints. Pure, given the endpoint list. */
export function subscribedEvents(endpoints) {
const union = new Set();
for (const ep of endpoints ?? []) {
for (const t of ep.enabled_events ?? []) union.add(t);
}
return union;
}
async function countSubscriptions(key, status, limit = 1000) {
let count = 0;
const ids = [];
const params = { limit: 100, status };
for (;;) {
const page = await get(key, '/subscriptions', params);
const data = page.data ?? [];
for (const sub of data) {
count += 1;
if (ids.length < 10) ids.push(sub.id);
}
if (data.length === 0 || !page.has_more || count >= limit) break;
params.starting_after = data[data.length - 1].id;
}
return { count, ids };
}
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 { data: endpoints = [] } = await get(key, '/webhook_endpoints', { limit: 100 });
const union = subscribedEvents(endpoints);
const canceled = await countSubscriptions(key, 'canceled');
const active = await countSubscriptions(key, 'active');
const [state, detail] = verdict(union, canceled.count, active.count);
const line = `${state.padEnd(14)} ${detail}`;
if (state === 'covered' || state === 'not-billing') {
console.log(line);
return;
}
console.warn(line);
if (state === 'over-entitled') {
for (const sid of canceled.ids) console.warn(` reconcile: ${sid}`);
}
if (state !== 'wildcard') {
console.warn(` repair: POST ${API}/webhook_endpoints/${endpoints[0]?.id ?? '<we_id>'}`);
console.warn(` -d enabled_events[]=${TARGET}`);
console.warn(` -d enabled_events[]=${COMPANION}`);
console.warn(' (enabled_events is replaced wholesale: send the existing types too)');
}
console.warn(` then sweep GET ${API}/subscriptions?status=canceled against your ` +
'own entitlement table: subscribing fixes the future only');
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 states worth pinning are the two that look alike from a distance. A missing subscription on an account that has never cancelled anybody is a gap you close this quarter; the same missing subscription with two hundred cancellations behind it is a list of people using a product they stopped paying for, and the report has to say so in those words. The account with no subscriptions at all has to stay silent, or nobody will read the output twice.
from stripe_subscription_deleted_events import verdict, subscribed_events
def test_an_account_without_subscriptions_is_not_a_finding():
state, _ = verdict([], 0, 0)
assert state == "not-billing"
def test_missing_with_cancellations_behind_it_is_a_backlog():
state, detail = verdict(["invoice.paid"], 214, 900)
assert state == "over-entitled"
assert "214" in detail
def test_missing_with_nothing_ended_yet_is_only_a_gap():
state, detail = verdict(["invoice.paid"], 0, 40)
assert state == "unsubscribed"
assert "gap rather than a backlog" in detail
def test_deleted_without_updated_is_partial():
state, detail = verdict(["customer.subscription.deleted"], 5, 40)
assert state == "partial"
assert "customer.subscription.updated" in detail
def test_both_events_subscribed_is_covered():
state, _ = verdict(["customer.subscription.deleted",
"customer.subscription.updated"], 5, 40)
assert state == "covered"
def test_a_wildcard_covers_it_and_is_still_called_out():
state, _ = verdict(["*"], 5, 40)
assert state == "wildcard"
def test_the_union_flattens_every_endpoint():
union = subscribed_events([{"enabled_events": ["invoice.paid"]},
{"enabled_events": ["customer.subscription.deleted"]},
{}])
assert union == {"invoice.paid", "customer.subscription.deleted"}
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict, subscribedEvents } from './stripe-subscription-deleted-events.mjs';
test('an account without subscriptions is not a finding', () => {
assert.equal(verdict([], 0, 0)[0], 'not-billing');
});
test('missing with cancellations behind it is a backlog', () => {
const [state, detail] = verdict(['invoice.paid'], 214, 900);
assert.equal(state, 'over-entitled');
assert.match(detail, /214/);
});
test('missing with nothing ended yet is only a gap', () => {
const [state, detail] = verdict(['invoice.paid'], 0, 40);
assert.equal(state, 'unsubscribed');
assert.match(detail, /gap rather than a backlog/);
});
test('deleted without updated is partial', () => {
const [state, detail] = verdict(['customer.subscription.deleted'], 5, 40);
assert.equal(state, 'partial');
assert.match(detail, /customer\.subscription\.updated/);
});
test('both events subscribed is covered', () => {
const subs = ['customer.subscription.deleted', 'customer.subscription.updated'];
assert.equal(verdict(subs, 5, 40)[0], 'covered');
});
test('a wildcard covers it and is still called out', () => {
assert.equal(verdict(['*'], 5, 40)[0], 'wildcard');
});
test('the union flattens every endpoint', () => {
const union = subscribedEvents([{ enabled_events: ['invoice.paid'] },
{ enabled_events: ['customer.subscription.deleted'] }, {}]);
assert.deepEqual([...union].sort(),
['customer.subscription.deleted', 'invoice.paid']);
});
FAQ
Is customer.subscription.deleted not just the cancel button?
No. It fires when the subscription actually ends, which for a cancel_at_period_end cancellation is weeks after the customer clicked anything, and for an exhausted dunning sequence involves no customer action at all. It is the only event that marks the end itself.
Can I revoke access on customer.subscription.updated instead?
Not on its own. The update that sets cancel_at_period_end arrives while the customer is still fully paid up, so acting on it cuts off access they have bought. Use updated to know a cancellation is scheduled and deleted to act on it.
What about a subscription that ends because the card kept failing?
Same event. When retries are exhausted the subscription can be cancelled automatically, and the ending is reported the same way. That case is the one most likely to be missing from an entitlement system, because nobody was present for it.
Does subscribing now fix the customers who are already over-entitled?
No. Webhooks are not retroactive and the events for those cancellations are outside the 30 day retention window. Sweep GET /v1/subscriptions?status=canceled against your entitlement table once, then rely on the event going forward.
Why does the check care whether the account has subscriptions at all?
Because a missing subscription event on an account that only takes one-off payments is not a defect, and a report that flags it is a report people learn to skip. The count of active and canceled subscriptions is what makes the finding true rather than merely technically correct.
Related field notes
- payout.failed is unsubscribed so failures go unseen
- Dunning retries run out and the subscription ends
- past_due subscriptions accumulate unnoticed
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.
- Create a webhook endpoint — Stripe API reference
- The subscription object — Stripe API reference
- Cancel subscriptions — Stripe Docs
- Types of events — 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.