Skip to content

Diagnostic Stripe

enabled_events lists event types that are dead or rejected

Card-expiry reminder emails stopped going out. Nobody can say when, because nothing failed: the handler branch simply never runs, and a branch that never runs produces no logs. Separately, and apparently unrelatedly, an attempt to add one new event type to the endpoint comes back with You do not have access to the event types, naming a type that has been in the list for years.

Read-only key Python and Node.js Tests included
Woman in black headphones holding black and silver headphones
Photo by Charanjeet Dhiman on Unsplash
The short answer

Read enabled_events from GET /v1/webhook_endpoints and check each entry two ways. First: anything under source. or customer.source. belongs to the Sources API, and stops firing entirely once the integration moves to PaymentMethods. Confirm with GET /v1/events?types[]=... over the retained window — zero results across 30 days on a busy account is your answer.

Second: diff every entry against the live enum of event types. A type Stripe has removed stays happily in your enabled_events array until the next update, which then fails wholesale — including the change you actually wanted to make.

The problem in plain words

These are two different failures that look like one because they live in the same array. The first is a branch that has quietly stopped executing. customer.source.expiring does not fire for an integration using PaymentMethods, so the card-expiry warning it drove has not been sent since the migration. Nothing errors. The subscription is still there, the handler code is still there, the emails are not.

The second only shows up when you try to change something else. enabled_events is replaced wholesale on update, so the array you send back includes every existing entry, including one the API no longer accepts. The rejection names a type you were not touching, on a request you made for an unrelated reason, and the obvious reading — a permissions problem on the key — is wrong.

Sources toPaymentMethodsintegrationmigratedType stopsfiringstill valid, neveroccursBranch neverrunsno logs, no errorsReminders stopnobody noticesNext updatefailsthe array isre-sent whole
Nothing errors at any point. A branch that never runs writes no logs, so the feature it drove disappears without a trace.

Why it happens

Deprecated types stay configurable long after they stop firing. Stripe does not reject customer.source.expiring; it is a valid type that simply will not occur if you use the PaymentMethod API. So the subscription looks correct in the Dashboard, in the API response, and in your infrastructure code. The only evidence is an absence, and absences are not alerted on.

SDK constant lists outlive the API. A type removed from the API stays in the enum of an SDK version you have pinned, so your code compiles, your infrastructure definition validates, and the rejection arrives at runtime from Stripe rather than at build time from your tooling.

Updates replace the whole array. There is no add-one-type operation. Every update re-sends the full list, which means one dead entry poisons every future change to that endpoint until somebody reads the error message carefully enough to notice it names something they did not touch.

A type that has not fired is not necessarily dead. This is the trap in the other direction. charge.dispute.created firing zero times in 30 days is good news, not decay. Only the legacy families can be judged by silence; for everything else, silence is just low volume, and a check that conflates the two will tell you to unsubscribe from disputes.

The fix, as a flow

The script tallies what actually fires before it judges any subscription, because silence proves decay only for the legacy families. Everywhere else silence is just low volume.

enabled_eventsdiffed against a tally of /v1/eventsSeen firinglive, leave itQuiet, current typelow volume, not decaySources type, silentdead branch, remove itRemoved from the APIpoisons every update
Zero disputes in thirty days is a good month. Only the Sources families can be judged by an absence.

How to fix it

List every subscribed type across every endpoint

GET /v1/webhook_endpoints?limit=100, then flatten enabled_events. A literal "*" short-circuits this entirely: a wildcard subscription has nothing to diff, and is its own separate problem.

Flag the legacy families by prefix

source. and customer.source. are the Sources API. If your integration creates PaymentIntents and PaymentMethods, these types do not fire, and any handler branch behind them is dead code that looks live.

Confirm with the event stream rather than assuming

Tally GET /v1/events across the retained window and check whether each flagged type appears. A legacy type that is still firing means something in the integration is still creating Sources, which is a migration finding rather than a cleanup one.

Diff the rest against the accepted enum

The authoritative list is the enabled_events enum documented on endpoint creation. Anything in your array that is absent from it will be rejected the next time the endpoint is updated, whatever else that update was for.

Rewrite the array without the dead entries

POST /v1/webhook_endpoints/{id} with the full corrected list. Replace card-expiry logic with payment_method.automatically_updated plus a periodic sweep of card.exp_month and card.exp_year; replace source.chargeable with the PaymentIntent lifecycle events.

How to check it worked

Re-run the script. Every subscribed type should be one the API still accepts, and every legacy entry should be gone or explained.

python3 stripe_dead_event_types.py
# live       payment_intent.succeeded  seen firing in the retained window

The full code

Two GETs and no writes — a restricted key with read access to Webhook Endpoints and Events is enough. The classifier takes one subscribed type and the set of types actually observed, because the difference between dead and quiet is the whole point of the check and it is decided by rules that should be visible rather than buried in a request loop.

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_dead_event_types.py
"""Report Stripe webhook subscriptions to event types that are dead or rejected.

Read only. Two GETs and no writes: give this a RESTRICTED key with read access
to Webhook Endpoints and Events. 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_dead_event_types")

API = "https://api.stripe.com/v1"

# The Sources API families. These stay configurable but stop occurring once the
# integration moves to PaymentIntents and PaymentMethods.
LEGACY_PREFIXES = ("source.", "customer.source.")

# Types the API no longer accepts on an update, so one of these poisons every
# future change to the endpoint. Keep in step with the enabled_events enum in
# the create-endpoint reference; this is the short list seen in the wild.
REJECTED = frozenset({"invoiceitem.updated"})


def verdict(event_type, fired):
    """Classify one subscribed event type. Pure, so the rules can be tested.

    `fired` is the set of event types actually seen in the retained window.
    Returns (state, detail).
    """
    if event_type == "*":
        return ("wildcard",
                "subscribed to every type: there is no list here to diff")
    seen = set(fired or [])
    if event_type in REJECTED:
        return ("rejected",
                "the API no longer accepts this type. The next update to this "
                "endpoint fails on it, whatever the update was for.")
    if event_type.startswith(LEGACY_PREFIXES):
        if event_type in seen:
            return ("legacy",
                    "a Sources API type that is still firing: something in the "
                    "integration still creates Sources")
        return ("dead",
                "a Sources API type with no occurrences in the retained window. "
                "It does not fire for a PaymentMethod integration, so any "
                "handler branch behind it is dead code.")
    if event_type in seen:
        return ("live", "seen firing in the retained window")
    return ("quiet",
            "no occurrences in the retained window. That is low volume, not "
            "proof of decay: disputes and failures are supposed to be rare.")


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")
    r.raise_for_status()
    return r.json()


def fired_types(session, limit):
    """The set of event types seen in the retained window."""
    seen = set()
    total = 0
    params = {"limit": 100}
    while True:
        page = get(session, "/events", **params)
        data = page.get("data", [])
        for ev in data:
            total += 1
            seen.add(ev.get("type"))
        if not data or not page.get("has_more") or total >= limit:
            break
        params["starting_after"] = data[-1]["id"]
    return seen, total


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--max-events", type=int, default=5000,
                    help="stop sampling event types after this many events")
    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})

    eps = get(s, "/webhook_endpoints", limit=100).get("data", [])
    if not eps:
        log.info("no webhook endpoints configured for this key's mode")
        return 0

    seen, total = fired_types(s, args.max_events)
    log.info("sampled %d event(s) across %d distinct type(s)", total, len(seen))

    bad = 0
    for ep in eps:
        keep = []
        drop = []
        for t in ep.get("enabled_events") or []:
            state, detail = verdict(t, seen)
            line = "%-9s %-32s %s" % (state, t, detail)
            if state in ("dead", "rejected"):
                bad += 1
                drop.append(t)
                log.warning("%s  %s", ep.get("url", "?"), line)
            else:
                keep.append(t)
                log.info("%s  %s", ep.get("url", "?"), line)
        if drop:
            log.warning("  enabled_events is replaced wholesale on update, so "
                        "re-send the full corrected list:")
            log.warning("  repair: POST %s/webhook_endpoints/%s %s",
                        API, ep["id"],
                        " ".join("-d enabled_events[]=%s" % t for t in keep[:6])
                        + (" ..." if len(keep) > 6 else ""))
            log.warning("  dropping: %s", ", ".join(drop))

    log.info("%d endpoint(s), %d dead or rejected subscription(s)", len(eps), bad)
    return 1 if bad else 0


if __name__ == "__main__":
    sys.exit(main())
stripe-dead-event-types.mjs
/**
 * Report Stripe webhook subscriptions to event types that are dead or rejected.
 *
 * Read only. Two GETs and no writes: give this a RESTRICTED key with read
 * access to Webhook Endpoints and Events. The repair is printed, never performed.
 */
const API = 'https://api.stripe.com/v1';

// The Sources API families. These stay configurable but stop occurring once the
// integration moves to PaymentIntents and PaymentMethods.
const LEGACY_PREFIXES = ['source.', 'customer.source.'];

// Types the API no longer accepts on an update, so one of these poisons every
// future change to the endpoint.
export const REJECTED = new Set(['invoiceitem.updated']);

/**
 * Classify one subscribed event type. Pure, so the rules can be tested.
 * `fired` is the set of event types actually seen in the retained window.
 */
export function verdict(eventType, fired) {
  if (eventType === '*') {
    return ['wildcard', 'subscribed to every type: there is no list here to diff'];
  }
  const seen = new Set(fired ?? []);
  if (REJECTED.has(eventType)) {
    return ['rejected',
      'the API no longer accepts this type. The next update to this endpoint ' +
      'fails on it, whatever the update was for.'];
  }
  if (LEGACY_PREFIXES.some((p) => eventType.startsWith(p))) {
    if (seen.has(eventType)) {
      return ['legacy',
        'a Sources API type that is still firing: something in the integration ' +
        'still creates Sources'];
    }
    return ['dead',
      'a Sources API type with no occurrences in the retained window. It does ' +
      'not fire for a PaymentMethod integration, so any handler branch behind ' +
      'it is dead code.'];
  }
  if (seen.has(eventType)) return ['live', 'seen firing in the retained window'];
  return ['quiet',
    'no occurrences in the retained window. That is low volume, not proof of ' +
    'decay: disputes and failures are supposed to be rare.'];
}

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.ok) throw new Error(`${res.status} from ${url.pathname}`);
  return res.json();
}

export async function firedTypes(key, limit = 5000) {
  const seen = new Set();
  let total = 0;
  const params = { limit: 100 };
  for (;;) {
    const page = await get(key, '/events', params);
    const data = page.data ?? [];
    for (const ev of data) { total += 1; seen.add(ev.type); }
    if (data.length === 0 || !page.has_more || total >= limit) break;
    params.starting_after = data[data.length - 1].id;
  }
  return { seen, total };
}

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: eps = [] } = await get(key, '/webhook_endpoints', { limit: 100 });
  if (eps.length === 0) {
    console.log("no webhook endpoints configured for this key's mode");
    return;
  }

  const { seen, total } = await firedTypes(key);
  console.log(`sampled ${total} event(s) across ${seen.size} distinct type(s)`);

  let bad = 0;
  for (const ep of eps) {
    const keep = [];
    const drop = [];
    for (const t of ep.enabled_events ?? []) {
      const [state, detail] = verdict(t, seen);
      const line = `${state.padEnd(9)} ${t.padEnd(32)} ${detail}`;
      if (state === 'dead' || state === 'rejected') {
        bad += 1;
        drop.push(t);
        console.warn(`${ep.url ?? '?'}  ${line}`);
      } else {
        keep.push(t);
        console.log(`${ep.url ?? '?'}  ${line}`);
      }
    }
    if (drop.length) {
      console.warn('  enabled_events is replaced wholesale on update, so ' +
                   're-send the full corrected list:');
      const args = keep.slice(0, 6).map((t) => `-d enabled_events[]=${t}`).join(' ');
      console.warn(`  repair: POST ${API}/webhook_endpoints/${ep.id} ${args}` +
                   (keep.length > 6 ? ' ...' : ''));
      console.warn(`  dropping: ${drop.join(', ')}`);
    }
  }

  console.log(`${eps.length} endpoint(s), ${bad} dead or rejected subscription(s)`);
  process.exitCode = bad ? 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 test that stops this check being harmful is the one about charge.dispute.created. Silence over 30 days means a dead subscription for a Sources type and a good month for a dispute type, and a classifier that cannot tell them apart will confidently recommend unsubscribing from disputes. The legacy prefixes are the only place where an absence is evidence.

test_stripe_dead_event_types.py
from stripe_dead_event_types import verdict

FIRED = {"payment_intent.succeeded", "invoice.paid", "customer.source.expiring"}


def test_a_removed_type_is_rejected():
    state, detail = verdict("invoiceitem.updated", FIRED)
    assert state == "rejected"
    assert "update" in detail


def test_a_silent_sources_type_is_dead():
    state, _ = verdict("source.chargeable", FIRED)
    assert state == "dead"


def test_a_sources_type_that_still_fires_is_not_dead():
    # Still firing means something still creates Sources. That is a migration
    # finding, not a subscription to delete.
    state, _ = verdict("customer.source.expiring", FIRED)
    assert state == "legacy"


def test_silence_on_a_current_type_is_not_decay():
    # Zero disputes in 30 days is a good month. Calling this dead would have the
    # script recommend unsubscribing from disputes.
    state, detail = verdict("charge.dispute.created", FIRED)
    assert state == "quiet"
    assert "low volume" in detail


def test_a_wildcard_has_nothing_to_diff():
    assert verdict("*", FIRED)[0] == "wildcard"
stripe-dead-event-types.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict } from './stripe-dead-event-types.mjs';

const FIRED = ['payment_intent.succeeded', 'invoice.paid',
               'customer.source.expiring'];

test('a removed type is rejected', () => {
  const [state, detail] = verdict('invoiceitem.updated', FIRED);
  assert.equal(state, 'rejected');
  assert.match(detail, /update/);
});

test('a silent sources type is dead', () => {
  assert.equal(verdict('source.chargeable', FIRED)[0], 'dead');
});

test('a sources type that still fires is not dead', () => {
  assert.equal(verdict('customer.source.expiring', FIRED)[0], 'legacy');
});

test('silence on a current type is not decay', () => {
  const [state, detail] = verdict('charge.dispute.created', FIRED);
  assert.equal(state, 'quiet');
  assert.match(detail, /low volume/);
});

test('a wildcard has nothing to diff', () => {
  assert.equal(verdict('*', FIRED)[0], 'wildcard');
});

FAQ

Why does customer.source.expiring never fire for us?

Because it belongs to the Sources API. Stripe documents that it will not occur for an integration using the PaymentMethod API, and most integrations moved to PaymentMethods years ago. The subscription stays valid and configurable; the event just never happens, so the handler branch behind it is unreachable.

What replaces the card-expiry warning it used to drive?

Two things together. Subscribe to payment_method.automatically_updated so network-driven card refreshes update your local copy, and run a periodic sweep over card.exp_month and card.exp_year on each customer's saved PaymentMethods to find the ones the updater does not cover.

Why did adding one event type fail on a different type?

Because enabled_events is replaced wholesale rather than appended to. Your update re-sent the entire existing array, including an entry the API no longer accepts, and the request is rejected as a unit. The error names the dead type, not the one you were adding, which is why it reads like a permissions problem.

Is a type that never fires always a problem?

No, and this is the easiest way to make the check worse than useless. Dispute and failure events are supposed to be rare, so 30 days of silence on charge.dispute.created is good news. Only the legacy families can be judged by absence; everything else needs a positive reason to be called dead.

Does removing a dead type change my signing secret?

No. Updating an endpoint preserves the secret. Deleting and recreating it does not, which is why the repair here is an update with a corrected list rather than a rebuild.

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.