Diagnostic Stripe
two endpoints share one URL, so every event is handled twice
Two order rows for one payment. Two fulfilment emails. A customer credited twice. The handler reads correctly, it passes its tests, and it does not misbehave locally — because locally there is one endpoint, and in production there are two pointing at the same URL.
Read GET /v1/webhook_endpoints, normalise each url by stripping the query string and any trailing slash, and group. Any normalised URL with more than one enabled endpoint at the same livemode is delivering every subscribed event to your handler once per endpoint.
Both deliveries verify, because each endpoint has its own signing secret and your handler checks against whichever one it holds — or against both. Corroborate with GET /v1/events: pending_webhooks counting up to the endpoint total rather than one.
The problem in plain words
The distinguishing feature is that nothing looks wrong in the code. A duplicate delivery is not a bug in the handler; it is the handler working correctly, twice. Every trace shows a valid signed request with a real event, and every log line is one you would expect. Reviewers looking for a race condition or a retry loop find neither.
It is also not reproducible in development, where there is one endpoint, one secret and one delivery. That combination — visible in production, invisible everywhere else, no error anywhere — is what turns a five-minute configuration check into a week of instrumenting the wrong layer.
Why it happens
Stripe's own upgrade procedure creates the second one. To move a webhook to a new API version you create a second endpoint on the same URL, usually with a query parameter to tell them apart, run both, then retire the old one. The creation step is memorable and the retirement step is a follow-up ticket. If it does not get done, both endpoints stay enabled and both keep delivering.
The query parameter hides the duplicate from a visual scan. /stripe/webhook?v=2025-09-30 and /stripe/webhook are different rows in the Dashboard and the same route in your application. Grouping only works after the URLs are normalised, which is why this check strips the query string before comparing.
Separate secrets mean the second delivery cannot fail verification. Each endpoint signs with its own secret, so there is no signature mismatch to raise the alarm. If your handler accepts either secret, both deliveries sail through; if it only knows one, half your traffic starts 400-ing instead, which is a different and equally confusing problem.
Idempotency is the actual fix, and duplication is only the trigger for it. Stripe guarantees at-least-once delivery. Even with exactly one endpoint you will eventually get the same event twice, so a handler that breaks on repeats was going to break regardless; the duplicate endpoint just made it happen every single time.
The fix, as a flow
The script normalises every endpoint URL before grouping, because the query parameter Stripe tells you to add during a version upgrade is exactly what makes the duplicate look like a different destination.
How to fix it
Group endpoints by normalised URL and mode
Strip the query string and any trailing slash, lowercase the host, then group by (livemode, url). Keeping the mode in the key matters: a test and a live endpoint on the same URL are not duplicates of each other and should not be reported as such.
Count only the enabled ones
A disabled sibling is residue, not a duplicate — worth mentioning, not worth paging anyone about. Two or more enabled endpoints on one normalised URL is the finding.
Corroborate against the events
GET /v1/events?limit=20 and read pending_webhooks on a fresh event. A value matching the number of endpoints subscribed to that type, rather than one, confirms Stripe really is fanning out rather than something in your infrastructure replaying.
Pick the canonical endpoint and disable the other
Keep whichever has the API version and enabled_events you actually want. POST /v1/webhook_endpoints/{id} with disabled=true on the other, or delete it once you are sure. Disabling first is reversible; deletion is not.
Make the handler idempotent anyway
Persist processed event.id values and short-circuit repeats. This is the fix that survives the next duplicate endpoint, the next retry storm, and the at-least-once guarantee that was always going to send you a repeat eventually.
How to check it worked
Re-run the script. Every normalised URL should hold exactly one enabled endpoint per mode.
python3 stripe_duplicate_endpoints.py
# unique live https://example.com/stripe/webhook 1 enabled endpoint
The full code
One GET against /v1/webhook_endpoints, one optional GET against /v1/events to corroborate, and no writes. Two pure functions carry the logic: the URL normaliser, because the whole finding depends on ?v=2025-09-30 not counting as a different destination, and the group classifier that separates a live duplicate from a disabled leftover.
"""Report Stripe webhook endpoints that share a URL and deliver every event twice.
Read only. GETs only, 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
from urllib.parse import urlsplit
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("stripe_duplicate_endpoints")
API = "https://api.stripe.com/v1"
def normalise(url):
"""Reduce a webhook URL to the destination it actually is. Pure.
Stripe's own API-version upgrade procedure tells you to create the second
endpoint with a query parameter, so the query string is exactly what makes a
duplicate look distinct. Strip it, strip a trailing slash, lowercase the host.
"""
parts = urlsplit((url or "").strip())
host = (parts.hostname or "").lower()
if parts.port:
host = "%s:%d" % (host, parts.port)
path = parts.path.rstrip("/")
return "%s://%s%s" % ((parts.scheme or "").lower(), host, path)
def verdict(group):
"""Classify one group of endpoints sharing a normalised URL and mode. Pure.
Returns (state, detail).
"""
if not group:
return ("unique", "no endpoints")
enabled = [e for e in group if e.get("status") == "enabled"]
if len(enabled) > 1:
return ("duplicate",
"%d enabled endpoints on one URL: every subscribed event is "
"delivered %d times and both signatures verify."
% (len(enabled), len(enabled)))
if len(group) > 1:
return ("residue",
"%d endpoint(s) on this URL, %d enabled. The disabled ones are "
"leftovers, not duplicates." % (len(group), len(enabled)))
return ("unique", "%d enabled endpoint" % len(enabled))
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 group_endpoints(endpoints):
"""Group by (livemode, normalised url). Pure, given the endpoint list."""
groups = {}
for ep in endpoints:
key = (bool(ep.get("livemode")), normalise(ep.get("url")))
groups.setdefault(key, []).append(ep)
return groups
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--corroborate", action="store_true",
help="also read recent events and report pending_webhooks")
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
bad = 0
for (livemode, url), group in sorted(group_endpoints(endpoints).items()):
state, detail = verdict(group)
mode = "live" if livemode else "test"
line = "%-10s %s %s %s" % (state, mode, url, detail)
if state == "unique":
log.info(line)
continue
if state == "residue":
log.info(line)
continue
bad += 1
log.warning(line)
for ep in group:
log.warning(" %s %s version=%s %d event type(s)",
ep["id"], ep.get("status"),
ep.get("api_version") or "account default",
len(ep.get("enabled_events") or []))
keep = group[0]["id"]
for ep in group[1:]:
log.warning(" repair: keep %s, then "
"POST %s/webhook_endpoints/%s -d disabled=true",
keep, API, ep["id"])
log.warning(" then make the handler idempotent on event.id, which is "
"required regardless: Stripe delivers at least once.")
if args.corroborate:
recent = get(s, "/events", limit=20).get("data", [])
pending = [e.get("pending_webhooks", 0) for e in recent]
if pending:
log.info("recent events: pending_webhooks max=%d (1 per subscribed "
"destination while in flight)", max(pending))
log.info("%d endpoint(s), %d duplicated URL group(s)", len(endpoints), bad)
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Report Stripe webhook endpoints that share a URL and deliver every event twice.
*
* Read only. GETs only, 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';
/**
* Reduce a webhook URL to the destination it actually is. Pure.
*
* Stripe's own API-version upgrade procedure tells you to create the second
* endpoint with a query parameter, so the query string is exactly what makes a
* duplicate look distinct. Strip it, strip a trailing slash, lowercase the host.
*/
export function normalise(url) {
let parsed;
try {
parsed = new URL(String(url ?? '').trim());
} catch {
return String(url ?? '').trim();
}
const path = parsed.pathname.replace(/\/+$/, '');
return `${parsed.protocol.replace(':', '').toLowerCase()}://${parsed.host.toLowerCase()}${path}`;
}
/**
* Classify one group of endpoints sharing a normalised URL and mode. Pure.
*/
export function verdict(group) {
const items = group ?? [];
if (items.length === 0) return ['unique', 'no endpoints'];
const enabled = items.filter((e) => e.status === 'enabled');
if (enabled.length > 1) {
return ['duplicate',
`${enabled.length} enabled endpoints on one URL: every subscribed event is ` +
`delivered ${enabled.length} times and both signatures verify.`];
}
if (items.length > 1) {
return ['residue',
`${items.length} endpoint(s) on this URL, ${enabled.length} enabled. ` +
'The disabled ones are leftovers, not duplicates.'];
}
return ['unique', `${enabled.length} enabled endpoint`];
}
export function groupEndpoints(endpoints) {
const groups = new Map();
for (const ep of endpoints) {
const key = `${ep.livemode ? 'live' : 'test'} ${normalise(ep.url)}`;
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(ep);
}
return groups;
}
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();
}
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;
}
let bad = 0;
for (const [label, group] of [...groupEndpoints(endpoints).entries()].sort()) {
const [state, detail] = verdict(group);
const line = `${state.padEnd(10)} ${label} ${detail}`;
if (state !== 'duplicate') { console.log(line); continue; }
bad += 1;
console.warn(line);
for (const ep of group) {
console.warn(` ${ep.id} ${ep.status} version=` +
`${ep.api_version ?? 'account default'} ` +
`${(ep.enabled_events ?? []).length} event type(s)`);
}
const keep = group[0].id;
for (const ep of group.slice(1)) {
console.warn(` repair: keep ${keep}, then ` +
`POST ${API}/webhook_endpoints/${ep.id} -d disabled=true`);
}
console.warn(' then make the handler idempotent on event.id, which is ' +
'required regardless: Stripe delivers at least once.');
}
console.log(`${endpoints.length} endpoint(s), ${bad} duplicated URL group(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 normaliser carries the finding, so it is tested harder than the classifier: ?v=2025-09-30 is precisely the difference Stripe's upgrade guide tells you to introduce, and a check that treats it as a different destination reports nothing. The classifier's own edge is one enabled endpoint beside a disabled one, which is untidy rather than broken.
from stripe_duplicate_endpoints import normalise, verdict
def test_query_string_does_not_make_a_new_destination():
# Stripe's version-upgrade guide tells you to add exactly this parameter.
a = normalise("https://example.com/stripe/webhook?v=2025-09-30")
b = normalise("https://example.com/stripe/webhook")
assert a == b
def test_trailing_slash_and_host_case_are_ignored():
a = normalise("https://Example.COM/stripe/webhook/")
b = normalise("https://example.com/stripe/webhook")
assert a == b
def test_different_paths_stay_different():
assert normalise("https://example.com/a") != normalise("https://example.com/b")
def test_two_enabled_endpoints_on_one_url_is_the_finding():
state, detail = verdict([{"status": "enabled"}, {"status": "enabled"}])
assert state == "duplicate"
assert "2 times" in detail
def test_one_enabled_beside_a_disabled_one_is_only_residue():
state, _ = verdict([{"status": "enabled"}, {"status": "disabled"}])
assert state == "residue"
def test_a_single_endpoint_is_unique():
assert verdict([{"status": "enabled"}])[0] == "unique"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { normalise, verdict } from './stripe-duplicate-endpoints.mjs';
test('query string does not make a new destination', () => {
// Stripe's version-upgrade guide tells you to add exactly this parameter.
assert.equal(
normalise('https://example.com/stripe/webhook?v=2025-09-30'),
normalise('https://example.com/stripe/webhook'));
});
test('trailing slash and host case are ignored', () => {
assert.equal(
normalise('https://Example.COM/stripe/webhook/'),
normalise('https://example.com/stripe/webhook'));
});
test('different paths stay different', () => {
assert.notEqual(normalise('https://example.com/a'),
normalise('https://example.com/b'));
});
test('two enabled endpoints on one url is the finding', () => {
const [state, detail] = verdict([{ status: 'enabled' }, { status: 'enabled' }]);
assert.equal(state, 'duplicate');
assert.match(detail, /2 times/);
});
test('one enabled beside a disabled one is only residue', () => {
assert.equal(verdict([{ status: 'enabled' }, { status: 'disabled' }])[0], 'residue');
});
test('a single endpoint is unique', () => {
assert.equal(verdict([{ status: 'enabled' }])[0], 'unique');
});
FAQ
Why does the second delivery pass signature verification?
Because each endpoint has its own signing secret and signs its own delivery. There is no mismatch to catch. If your handler is configured with both secrets, or tries each in turn, both deliveries verify perfectly and neither looks unusual.
How did a second endpoint on the same URL get created?
Most often during an API-version upgrade. Stripe's documented procedure is to create a second endpoint on the same URL pinned to the new version, run both, and retire the old one. The retirement is a separate step and is frequently skipped.
Should I delete the extra endpoint or disable it?
Disable first. POST /v1/webhook_endpoints/{id} with disabled=true is reversible, so if you picked the wrong one you find out without having lost the object and its configuration. Delete later, once deliveries look right.
If I fix the duplicate, do I still need idempotency?
Yes. Stripe guarantees at-least-once delivery, so repeats happen with a single endpoint too, particularly around retries. Key your side effects on event.id and the duplicate endpoint becomes a performance issue rather than a data-integrity one.
Can this be detected without a live secret key?
Yes. A restricted key with read access to Webhook Endpoints lists the URLs, statuses and modes, which is everything the grouping needs. Read access to Events adds the pending_webhooks corroboration.
Related field notes
- A webhook endpoint sits disabled after days of retries
- Undelivered events are aging out of the 30-day window
- Duplicate webhook events run the handler twice
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.
- Webhook versioning — Stripe Docs
- The webhook endpoint object — Stripe API reference
- The event object — 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.