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.
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.
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.
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.
"""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())
/**
* 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.
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"
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
- enabled_events lists types that are dead or rejected
- An endpoint subscribes to every event and floods the handler
- customer.subscription.deleted is missing, so access never ends
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.
- List all events — Stripe API reference
- Types of events — Stripe API reference
- Update a webhook endpoint — Stripe API reference
- Receive Stripe events in your webhook endpoint — Stripe Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.