Skip to content

Diagnostic Stripe

event types are firing that no endpoint subscribes to

A whole class of business event is invisible to the application, and there is no error anywhere to explain it. The events exist. They are in GET /v1/events with the right data on them. They were never delivered, because no endpoint asked for them, and an event nobody asked for is not a failure — it is a non-event.

Read-only key Python and Node.js Tests included
A grocery store filled with lots of bags of food
Photo by Bernd 📷 Dittrich on Unsplash
The short answer

Union the enabled_events arrays across every endpoint from GET /v1/webhook_endpoints, treating a literal "*" as everything. Then paginate GET /v1/events across the retained window and collect the distinct type values that actually fired.

Subtract the first from the second and rank what is left by occurrence count. That list is what your account generates and your application has never seen.

The problem in plain words

Every other webhook check reports healthy while this is happening, and they are all correct. The endpoint is enabled. pending_webhooks is zero. delivery_success=false returns nothing. There is no failed delivery because there was no delivery: Stripe consulted the allowlist, found the type absent, and moved on.

What it feels like from the inside is a missing feature rather than a bug. Disputes are noticed when the balance moves. Renewal failures are noticed when a customer writes in. Nobody looks for a webhook problem, because the webhooks that exist are working fine.

Product enabledRadar, Tax,BillingNew type firesvisible in/v1/eventsAllowlistcheckedtype not listedNothing queuedno deliveryattemptNo erroranywherefound months later
No delivery is attempted, so nothing counts as a failure and every webhook health check keeps reporting green.

Why it happens

The allowlist is written once, when the account did less. enabled_events is set the day the endpoint is created, against whatever the integration handled that week. Every product enabled afterwards — Billing, Radar, Tax, Connect, Issuing — starts generating types that nobody went back to add, and there is no prompt anywhere that suggests they should.

Matching is exact, and only one string is a wildcard. enabled_events holds literal type names plus the single special value "*". A namespace-looking entry such as payment_intent.* is not a subscription to that namespace; it is a string that will never equal a real event type. Subscribing to payment_intent.succeeded gets you exactly that one type and nothing adjacent to it.

Coverage is a property of the account, not of an endpoint. Splitting types across several endpoints is normal and fine. It also means no single endpoint's configuration answers the question, and reading one of them in the Dashboard is how people conclude they are covered when they are not.

The inverse problem is a different note. Types that are subscribed and never fire are dead branches and rejected subscriptions; the gap here runs the other way, and a check for one will not find the other. Both are worth running, and the traffic tally you build here is the input to both.

The fix, as a flow

The script unions the subscribed types across every endpoint, then tallies what actually fired over the retained window, because coverage is a property of the account and not of any one endpoint.

Union of enabled_eventsagainst a tally of /v1/eventsType is subscribedcoveredStar subscriptioncovered, but floodingSibling subscribed onlya near miss, add the typeNamespace unhandlednothing here reaches you
Only the literal star is a wildcard. A namespace pattern is a string that never equals a real type.

How to fix it

Union enabled_events across every endpoint, in both modes

Merge the arrays rather than reading them one at a time. If any endpoint holds "*" the union is everything, and the finding for that account is the wildcard rather than the gap.

Tally what actually fires over the full retained window

Paginate GET /v1/events and count distinct type values. Use the whole 30 days: a monthly cadence means a renewal failure or a payout event can be entirely absent from a seven-day sample and perfectly regular over a month.

Subtract, then rank by count

The ranking is the triage. A type that fired 400 times and reached nothing is a system nobody built; a type that fired once is usually a product you turned on and are not using yet.

Subscribe to what you have a branch for, and nothing else

The output is a list of candidates, not a list of instructions. Each type earns a subscription by having a handler branch that does something with it. Switching to "*" to close the gap in one move trades this problem for a flooded handler.

Add the types to an existing endpoint and re-run

Updating enabled_events on the endpoint you already have preserves the signing secret. Re-running the check afterwards is the only confirmation that matters: the subtraction should now come back empty for everything your handler branches on.

How to check it worked

Re-run the script. The types you decided to handle should no longer appear in the unsubscribed list.

python3 stripe_unsubscribed_events.py
# 38 type(s) fired, 0 unsubscribed in the retained window

The full code

Two GETs and no writes — a restricted key with read access to Webhook Endpoints and Events. The classifier decides one type at a time against the subscription union, which is what makes the near-miss case visible: a type whose siblings are subscribed is a different kind of finding from one whose whole namespace is unhandled, and the ranking by count is what turns the list into a plan.

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_unsubscribed_events.py
"""Report Stripe event types that fire but reach no webhook endpoint.

Read only. Two GETs, 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_unsubscribed_events")

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


def classify(event_type, count, subscribed):
    """Classify one fired event type against the subscription union.

    Pure, so the rules can be tested without a network. `subscribed` is the union
    of enabled_events across every endpoint. Returns (state, detail).
    """
    events = set(subscribed or [])
    if count <= 0:
        return ("unseen", "%s did not fire in the retained window" % event_type)
    if "*" in events:
        return ("wildcard",
                "%s is delivered by a wildcard subscription, along with every "
                "other type the account generates." % event_type)
    if event_type in events:
        return ("covered", "%s is subscribed on at least one endpoint" % event_type)

    namespace = event_type.split(".")[0]
    siblings = sorted(e for e in events if e.split(".")[0] == namespace)
    if siblings:
        return ("near-miss",
                "%s fired %d time(s) and is not subscribed, though %s is. "
                "enabled_events matches type names exactly: only the literal * "
                "is a wildcard, so a namespace is never covered by a sibling."
                % (event_type, count, siblings[0]))
    return ("missed",
            "%s fired %d time(s) and reached no endpoint. Nothing in the %s "
            "namespace is subscribed anywhere on this account."
            % (event_type, count, namespace))


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 subscribed_union(session):
    """Every event type any endpoint on this account asks for."""
    union = set()
    for ep in get(session, "/webhook_endpoints", limit=100).get("data", []):
        union.update(ep.get("enabled_events") or [])
    return union


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


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

    union = subscribed_union(s)
    if not union:
        log.warning("no endpoint subscribes to anything in this mode: "
                    "every event below is undelivered")

    counts = fired_counts(s, args.max_events)
    log.info("sampled %d event(s) across %d distinct type(s), %d subscribed type(s)",
             sum(counts.values()), len(counts), len(union))

    gaps = 0
    for event_type, count in sorted(counts.items(), key=lambda kv: -kv[1]):
        state, detail = classify(event_type, count, union)
        if state in ("covered", "wildcard", "unseen"):
            continue
        gaps += 1
        log.warning("%-9s %s", state, detail)

    if gaps:
        log.warning("repair: add the types your handler branches on to an existing "
                    "endpoint's enabled_events[] at %s/webhook_endpoints/{id}. "
                    "Adding * instead trades this for a flooded handler", API)
    log.info("%d type(s) fired, %d unsubscribed", len(counts), gaps)
    return 1 if gaps else 0


if __name__ == "__main__":
    sys.exit(main())
stripe-unsubscribed-events.mjs
/**
 * Report Stripe event types that fire but reach no webhook endpoint.
 *
 * Read only. Two GETs, 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';

/**
 * Classify one fired event type against the subscription union. Pure, so the
 * rules can be tested without a network.
 */
export function classify(eventType, count, subscribed) {
  const events = new Set(subscribed ?? []);
  if (count <= 0) return ['unseen', `${eventType} did not fire in the retained window`];
  if (events.has('*')) {
    return ['wildcard',
      `${eventType} is delivered by a wildcard subscription, along with every ` +
      'other type the account generates.'];
  }
  if (events.has(eventType)) {
    return ['covered', `${eventType} is subscribed on at least one endpoint`];
  }

  const namespace = eventType.split('.')[0];
  const siblings = [...events].filter((e) => e.split('.')[0] === namespace).sort();
  if (siblings.length > 0) {
    return ['near-miss',
      `${eventType} fired ${count} time(s) and is not subscribed, though ` +
      `${siblings[0]} is. enabled_events matches type names exactly: only the ` +
      'literal * is a wildcard, so a namespace is never covered by a sibling.'];
  }
  return ['missed',
    `${eventType} fired ${count} time(s) and reached no endpoint. Nothing in ` +
    `the ${namespace} namespace is subscribed anywhere on this account.`];
}

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 subscribedUnion(key) {
  const union = new Set();
  const { data = [] } = await get(key, '/webhook_endpoints', { limit: 100 });
  for (const ep of data) for (const t of ep.enabled_events ?? []) union.add(t);
  return union;
}

export async function firedCounts(key, limit = 2000) {
  const counts = new Map();
  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;
      counts.set(ev.type, (counts.get(ev.type) ?? 0) + 1);
    }
    if (data.length === 0 || !page.has_more || total >= limit) break;
    params.starting_after = data[data.length - 1].id;
  }
  return counts;
}

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 union = await subscribedUnion(key);
  if (union.size === 0) {
    console.warn('no endpoint subscribes to anything in this mode: ' +
                 'every event below is undelivered');
  }

  const counts = await firedCounts(key);
  const sampled = [...counts.values()].reduce((a, b) => a + b, 0);
  console.log(`sampled ${sampled} event(s) across ${counts.size} distinct type(s), ` +
              `${union.size} subscribed type(s)`);

  let gaps = 0;
  for (const [eventType, count] of [...counts.entries()].sort((a, b) => b[1] - a[1])) {
    const [state, detail] = classify(eventType, count, union);
    if (state === 'covered' || state === 'wildcard' || state === 'unseen') continue;
    gaps += 1;
    console.warn(`${state.padEnd(9)} ${detail}`);
  }

  if (gaps > 0) {
    console.warn('repair: add the types your handler branches on to an existing ' +
                 `endpoint's enabled_events[] at ${API}/webhook_endpoints/{id}. ` +
                 'Adding * instead trades this for a flooded handler');
  }
  console.log(`${counts.size} type(s) fired, ${gaps} unsubscribed`);
  process.exitCode = gaps ? 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 case that has to hold is the namespace one. People write payment_intent.* into enabled_events and believe they are covered, and they subscribe to payment_intent.succeeded and believe the failure is implied. Neither is true, and both produce a subscription list that reads as complete. The tests pin the distinction between a sibling being subscribed and the type itself being subscribed.

test_stripe_unsubscribed_events.py
from stripe_unsubscribed_events import classify

SUBSCRIBED = ["payment_intent.succeeded", "invoice.paid"]


def test_a_subscribed_type_is_covered():
    state, _ = classify("invoice.paid", 12, SUBSCRIBED)
    assert state == "covered"


def test_a_type_with_no_sibling_subscribed_is_missed():
    state, detail = classify("charge.dispute.created", 7, SUBSCRIBED)
    assert state == "missed"
    assert "7 time(s)" in detail
    assert "charge" in detail


def test_a_sibling_subscription_does_not_cover_the_type():
    # payment_intent.succeeded is subscribed; the failure is not implied by it.
    state, detail = classify("payment_intent.payment_failed", 31, SUBSCRIBED)
    assert state == "near-miss"
    assert "payment_intent.succeeded" in detail


def test_a_namespace_pattern_is_not_a_subscription():
    # Only the literal * is a wildcard. "payment_intent.*" matches nothing.
    state, _ = classify("payment_intent.succeeded", 5, ["payment_intent.*"])
    assert state != "covered"
    assert state == "near-miss"


def test_a_wildcard_covers_everything():
    state, _ = classify("radar.early_fraud_warning.created", 2, ["*"])
    assert state == "wildcard"


def test_a_type_that_never_fired_is_not_a_gap():
    state, _ = classify("invoice.paid", 0, [])
    assert state == "unseen"
stripe-unsubscribed-events.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { classify } from './stripe-unsubscribed-events.mjs';

const SUBSCRIBED = ['payment_intent.succeeded', 'invoice.paid'];

test('a subscribed type is covered', () => {
  assert.equal(classify('invoice.paid', 12, SUBSCRIBED)[0], 'covered');
});

test('a type with no sibling subscribed is missed', () => {
  const [state, detail] = classify('charge.dispute.created', 7, SUBSCRIBED);
  assert.equal(state, 'missed');
  assert.match(detail, /7 time\(s\)/);
});

test('a sibling subscription does not cover the type', () => {
  const [state, detail] = classify('payment_intent.payment_failed', 31, SUBSCRIBED);
  assert.equal(state, 'near-miss');
  assert.match(detail, /payment_intent\.succeeded/);
});

test('a namespace pattern is not a subscription', () => {
  const [state] = classify('payment_intent.succeeded', 5, ['payment_intent.*']);
  assert.notEqual(state, 'covered');
  assert.equal(state, 'near-miss');
});

test('a wildcard covers everything', () => {
  assert.equal(classify('radar.early_fraud_warning.created', 2, ['*'])[0], 'wildcard');
});

test('a type that never fired is not a gap', () => {
  assert.equal(classify('invoice.paid', 0, [])[0], 'unseen');
});

FAQ

Why don't unsubscribed events show up as failed deliveries?

Because no delivery was attempted. enabled_events is an allowlist consulted before Stripe queues anything, so an unsubscribed type never becomes a delivery, never increments pending_webhooks, and never appears under delivery_success=false. Every failure metric stays clean.

Can I subscribe to a whole namespace, like charge.* ?

No. enabled_events matches type names exactly, and the only wildcard is the single literal "*". A value like charge.* is stored as a string that never equals a real type, which is worse than the gap it was meant to close because it looks like coverage.

Should I just set enabled_events to "*" and be done?

It closes this gap and opens another: every type the account generates gets delivered, verified and parsed before your handler decides it has no branch for it. Stripe recommends against it for that reason. Subscribe to what you handle and re-run this check when you enable a new product.

Does this cover connected accounts on a Connect platform?

Not by itself. GET /v1/events returns the events of the account the key belongs to, and connected-account events are delivered to a Connect-scoped destination. Run the check per connected account with the Stripe-Account header to see their traffic.

How far back does the comparison look?

30 days, which is the event retention window. Anything with a monthly rhythm, such as a renewal failure or a payout, can be entirely absent from a shorter sample, so run the tally across the full window before concluding that a type never fires.

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.