Skip to content

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.

Read-only key Python and Node.js Tests included
Photo of library interior
Photo by Cameron Cress on Unsplash
The short answer

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.

Customercancelsat period endUpdatedeliveredflag recordedPeriod endssubscriptiondeletedNot subscribedevent never sentAccesscontinuesfound by a ticket
The money side is entirely correct, which is why no report disagrees with the entitlement that is wrong.

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.

Union of enabled_eventsplus subscription countsdeleted and updatedcovereddeleted onlyno notice of scheduled endsmissing, none endeda gap to closemissing, cancellationsaccounts still entitled
Cancellations already on the account turn a coverage gap into a backlog with names in it.

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.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 114 Stripe fixes, free and open source.
stripe_subscription_deleted_events.py
"""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())
stripe-subscription-deleted-events.mjs
/**
 * 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.

test_stripe_subscription_deleted_events.py
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"}
stripe-subscription-deleted-events.test.mjs
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

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.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.