Diagnostic Stripe
an endpoint subscribes to every event and floods the handler
The webhook route is slow, and it is slow at the worst possible time: the end of the month, when renewals run. It handles four event types. It is being sent everything Stripe generates, and the other ninety-odd types still cost a request, a signature verification, and a database round trip before the handler decides it does not care.
Read GET /v1/webhook_endpoints and look at enabled_events. A literal "*" subscribes the endpoint to every event type. An array longer than about forty is the same thing written out by hand, usually by somebody who ticked most of the boxes in the Dashboard.
Then tally what actually fires with GET /v1/events over the retained window and compare. The gap between what you are subscribed to and what your handler branches on is the traffic you are paying for and discarding.
The problem in plain words
Nothing is broken here, which is why it survives so long. Every event verifies, the handler returns 200, and the endpoint stays enabled. The symptom is a latency graph with a spike at the end of each month, and the cause looks like your own code because the requests really are arriving at your route.
It becomes a real failure when the volume is enough to push a handler past Stripe's timeout on some requests. Those get retried, which adds load, which causes more timeouts. The events that fail are not the noisy ones you do not care about; they are whichever ones happened to be in flight, which will eventually include a payment.
Why it happens
The wildcard is the path of least resistance when setting an endpoint up. You do not yet know which events you need, "*" means you never have to come back, and it works immediately. The intention to narrow it later is genuine and almost never acted on, because nothing ever complains.
The cost is invisible until volume arrives. On a test account with four payments a day, a wildcard endpoint and a precise one are indistinguishable. The difference only appears once billing has a renewal cohort, and by then the endpoint has been configured that way for two years and nobody remembers choosing it.
Every event still costs full price before it is discarded. The handler cannot know an event is irrelevant until it has received the body, verified the signature against the raw bytes, and parsed the JSON. A switch with no matching branch is the cheapest part of the whole operation; the expensive work has already happened.
Stripe says not to do it. The docs recommend subscribing only to what you handle, precisely because listening for extra events puts undue strain on your server. The wildcard also enrols you automatically in event types that did not exist when you configured it.
The fix, as a flow
The script reads what each endpoint is subscribed to, then tallies what actually fires, so the traffic you are paying for and discarding becomes a number rather than a suspicion.
How to fix it
Read enabled_events on every endpoint, in both modes
Look for the literal "*" first. Then look at length: an array of sixty specific types is a wildcard that somebody typed out, and it has the same problem.
Tally the types that actually fire
Paginate GET /v1/events across the retained window and count distinct type values. This is the real traffic profile of the account, and it is usually a much shorter list than people expect — and a very differently weighted one.
Derive the subscription list from your code, not from the tally
The events you should subscribe to are the ones your handler has a branch for. Read that switch or dispatch table and write the list from it. The API tally tells you the volume you are shedding; the code tells you what to keep.
Narrow the endpoint, do not delete and recreate it
POST /v1/webhook_endpoints/{id} with an explicit enabled_events[] list. Updating preserves the signing secret; deleting and recreating gives you a new one and a deploy you did not plan.
Re-run the tally afterwards
Confirm that nothing your handler branches on has dropped out of the subscription. This is the one way narrowing can hurt you, and it is easy to check.
How to check it worked
Re-run the script. Every endpoint should report a focused subscription with no unused types left in it.
python3 stripe_wildcard_events.py
# focused https://example.com/stripe/webhook 6 type(s), all seen firing
The full code
Two GETs and no writes — a restricted key with read access to Webhook Endpoints and Events is enough. The classification is pure and takes the subscription list plus the set of types actually observed, so the difference between a wildcard, a hand-typed wildcard and an honestly wide subscription is decided by visible rules rather than inside a request loop.
"""Report Stripe webhook endpoints subscribed to far more events than they handle.
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_wildcard_events")
API = "https://api.stripe.com/v1"
# Above this an explicit list is a wildcard somebody typed out by hand.
WIDE = 40
def verdict(enabled_events, fired_types):
"""Classify one endpoint's subscription. Pure, so the rules can be tested.
`enabled_events` is the endpoint's array; `fired_types` is the set of event
types actually seen in the retained window. Returns (state, detail).
"""
events = list(enabled_events or [])
if not events:
return ("empty", "no enabled_events at all: this endpoint receives nothing")
if "*" in events:
return ("wildcard",
"subscribed to every event type. %d distinct type(s) fired in the "
"retained window, and all of them are being delivered."
% len(set(fired_types or [])))
if len(events) > WIDE:
return ("overbroad",
"%d explicit types subscribed. That is a wildcard written out by "
"hand and carries the same load." % len(events))
unused = sorted(e for e in set(events) if e not in set(fired_types or []))
if unused:
return ("padded",
"%d of %d subscribed type(s) never fired in the retained window: %s"
% (len(unused), len(set(events)), ", ".join(unused[:5])))
return ("focused", "%d type(s), all seen firing" % len(set(events)))
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):
"""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
t = ev.get("type")
counts[t] = counts.get(t, 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 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})
endpoints = get(s, "/webhook_endpoints", limit=100).get("data", [])
if not endpoints:
log.info("no webhook endpoints configured for this key's mode")
return 0
counts = fired_types(s, args.max_events)
log.info("sampled %d event(s) across %d distinct type(s)",
sum(counts.values()), len(counts))
bad = 0
for ep in endpoints:
state, detail = verdict(ep.get("enabled_events"), counts.keys())
line = "%-10s %s %s" % (state, ep.get("url", "?"), detail)
if state == "focused":
log.info(line)
continue
bad += 1
log.warning(line)
if state in ("wildcard", "overbroad", "padded"):
top = sorted(counts.items(), key=lambda kv: -kv[1])[:8]
log.warning(" busiest types seen: %s",
", ".join("%s x%d" % (t, n) for t, n in top))
log.warning(" repair: POST %s/webhook_endpoints/%s "
"-d enabled_events[]=<type> ... (one per branch in your handler)",
API, ep["id"])
log.info("%d endpoint(s), %d needing attention", len(endpoints), bad)
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Report Stripe webhook endpoints subscribed to far more events than they handle.
*
* 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';
// Above this an explicit list is a wildcard somebody typed out by hand.
const WIDE = 40;
/**
* Classify one endpoint's subscription. Pure, so the rules can be tested.
* `firedTypes` is the set of event types actually seen in the retained window.
*/
export function verdict(enabledEvents, firedTypes) {
const events = [...(enabledEvents ?? [])];
const fired = new Set(firedTypes ?? []);
if (events.length === 0) {
return ['empty', 'no enabled_events at all: this endpoint receives nothing'];
}
if (events.includes('*')) {
return ['wildcard',
`subscribed to every event type. ${fired.size} distinct type(s) fired in ` +
'the retained window, and all of them are being delivered.'];
}
if (events.length > WIDE) {
return ['overbroad',
`${events.length} explicit types subscribed. That is a wildcard written ` +
'out by hand and carries the same load.'];
}
const distinct = new Set(events);
const unused = [...distinct].filter((e) => !fired.has(e)).sort();
if (unused.length > 0) {
return ['padded',
`${unused.length} of ${distinct.size} subscribed type(s) never fired in ` +
`the retained window: ${unused.slice(0, 5).join(', ')}`];
}
return ['focused', `${distinct.size} type(s), all seen firing`];
}
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 = 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 { data: endpoints = [] } = await get(key, '/webhook_endpoints', { limit: 100 });
if (endpoints.length === 0) {
console.log("no webhook endpoints configured for this key's mode");
return;
}
const counts = await firedTypes(key);
const sampled = [...counts.values()].reduce((a, b) => a + b, 0);
console.log(`sampled ${sampled} event(s) across ${counts.size} distinct type(s)`);
let bad = 0;
for (const ep of endpoints) {
const [state, detail] = verdict(ep.enabled_events, counts.keys());
const line = `${state.padEnd(10)} ${ep.url ?? '?'} ${detail}`;
if (state === 'focused') { console.log(line); continue; }
bad += 1;
console.warn(line);
const top = [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8);
console.warn(` busiest types seen: ${top.map(([t, n]) => `${t} x${n}`).join(', ')}`);
console.warn(` repair: POST ${API}/webhook_endpoints/${ep.id} ` +
'-d enabled_events[]=<type> ... (one per branch in your handler)');
}
console.log(`${endpoints.length} endpoint(s), ${bad} needing attention`);
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 case that matters is the hand-typed wildcard. A list of sixty named types passes any check that only looks for "*", and delivers exactly the same load. The other one worth pinning is an empty enabled_events, which is not a tidy subscription — it is an endpoint that receives nothing.
from stripe_wildcard_events import verdict
FIRED = ["payment_intent.succeeded", "charge.refunded", "invoice.paid"]
def test_literal_star_is_a_wildcard():
state, detail = verdict(["*"], FIRED)
assert state == "wildcard"
assert "3" in detail
def test_a_long_explicit_list_is_a_wildcard_typed_out():
# The case a naive check misses: no star anywhere, same load.
state, _ = verdict(["evt.%d" % i for i in range(60)], FIRED)
assert state == "overbroad"
def test_subscribed_types_that_never_fire_are_reported():
state, detail = verdict(["payment_intent.succeeded", "issuing_card.created"],
FIRED)
assert state == "padded"
assert "issuing_card.created" in detail
def test_a_list_matching_real_traffic_is_focused():
state, _ = verdict(FIRED, FIRED)
assert state == "focused"
def test_empty_enabled_events_is_not_focused():
state, _ = verdict([], FIRED)
assert state == "empty"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict } from './stripe-wildcard-events.mjs';
const FIRED = ['payment_intent.succeeded', 'charge.refunded', 'invoice.paid'];
test('literal star is a wildcard', () => {
const [state, detail] = verdict(['*'], FIRED);
assert.equal(state, 'wildcard');
assert.match(detail, /3 distinct/);
});
test('a long explicit list is a wildcard typed out', () => {
const many = Array.from({ length: 60 }, (_, i) => `evt.${i}`);
assert.equal(verdict(many, FIRED)[0], 'overbroad');
});
test('subscribed types that never fire are reported', () => {
const [state, detail] = verdict(
['payment_intent.succeeded', 'issuing_card.created'], FIRED);
assert.equal(state, 'padded');
assert.match(detail, /issuing_card\.created/);
});
test('a list matching real traffic is focused', () => {
assert.equal(verdict(FIRED, FIRED)[0], 'focused');
});
test('empty enabled_events is not focused', () => {
assert.equal(verdict([], FIRED)[0], 'empty');
});
FAQ
Is enabled_events: ["*"] actually harmful?
It is not incorrect, but Stripe recommends against it because listening for extra events puts undue strain on your server. Every delivery costs a request, a signature verification against the raw body, and a parse before your handler can decide it does not care. At renewal peaks that is the difference between a fast route and a timing-out one.
Does the wildcard include every event type there is?
Every type except the ones that require explicit selection. It also enrols the endpoint in types Stripe adds later, which is occasionally what people want and more often a surprise.
How do I know which types to subscribe to instead?
From your handler's dispatch, not from a traffic tally. The tally tells you what volume you are shedding; the code tells you what you must keep. Anything your handler has no branch for is load you are paying to discard.
Will narrowing enabled_events change my signing secret?
No, as long as you update the endpoint rather than deleting and recreating it. POST /v1/webhook_endpoints/{id} preserves the secret. A delete-and-recreate gives you a new one and an unplanned deploy.
Why flag lists longer than forty types?
Because a subscription that large is a wildcard somebody clicked their way to in the Dashboard. It carries the same load as a star and passes any check that only greps for one.
Related field notes
- payout.failed is unsubscribed so failures go unseen
- A webhook endpoint sits disabled after days of retries
- A webhook domain blocklisted for a low success ratio
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.
- Receive Stripe events in your webhook endpoint — Stripe Docs
- The webhook endpoint object — Stripe API reference
- Update a webhook endpoint — Stripe API reference
- 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.