Diagnostic Stripe
endpoints render events at different pinned API versions
This is not one endpoint pinned to an ancient version, where every consumer at least agrees on the shape. This is two endpoints that disagree with each other. The same logical event goes to both, one service reads invoice.subscription and the other reads invoice.parent, and only one of them falls over. Both are configured exactly as somebody intended, six months apart.
Read GET /v1/webhook_endpoints?limit=100 and collect the distinct api_version across every endpoint whose status is "enabled". Normalise both null and the empty string to one sentinel meaning account default before you deduplicate; skip that and two unpinned endpoints look like two different versions, and the script reports drift on a perfectly consistent account.
More than one member in that set is the finding. Then check whether any two of the URLs are identical once the query string is stripped: that is the documented dual-endpoint upgrade shape, and finding it still running means the migration stopped halfway.
The problem in plain words
The damage here is proportional to how many services you have. A single pin that is out of date is wrong everywhere at once, which at least means one bug report and one fix. Drift is wrong in one place and right in another, so the evidence contradicts itself. The billing service handles renewals correctly all week. The fulfilment service, subscribed to the same event types, throws on a field that is definitely present in the payload the other service received. Comparing the two payloads is the only way to see it, and nobody compares payloads across services.
The half-finished migration variant is worse, because the two endpoints usually sit on the same URL with a query parameter distinguishing them, which is exactly what the upgrade guide tells you to do. Your route handler sees both, at two versions, and whichever branch it takes depends on which endpoint delivered. Two enabled endpoints on one URL is also its own separate problem: every event is handled twice.
Why it happens
Each endpoint pins independently, and one of them usually is not pinned at all. An endpoint created through the Dashboard inherits the account default. One created through the API during an upgrade carries an explicit api_version. Both are ordinary ways to make an endpoint, and nothing anywhere shows you the two values side by side.
The documented upgrade deliberately creates this state. Because api_version cannot be edited, the way to move an endpoint forward is to create a second one at the new version, run both, then disable the old one. The procedure is correct. The failure is the last step never happening, and there is no deadline or reminder attached to it.
No SDK can decode two API versions at once. Each Stripe library is generated against exactly one version and deserializes against that schema. So there is no code-side workaround: you cannot write a handler that is simultaneously correct for both shapes using the typed objects, only one that reads raw JSON and branches by hand.
Unpinned is written two ways. The field comes back as null on some endpoints and as "" on others, and both mean follow the account default. Deduplicating the raw values counts those as two versions and produces a drift report on an account with no drift, which is the fastest way to get the whole check ignored.
The fix, as a flow
The script collapses both spellings of unpinned onto one sentinel before it deduplicates, because two endpoints that simply follow the account default are one shape and not two.
How to fix it
List the endpoints and keep only the enabled ones
GET /v1/webhook_endpoints?limit=100, paginated. A disabled endpoint delivers nothing, so its pin cannot cause a shape mismatch. Including it in the comparison invents drift out of residue somebody already retired.
Normalise before you deduplicate
null and "" collapse to one sentinel. Do this first, on the raw field, before anything else looks at the value. It is one line, and it is the difference between a report people act on and a report people mute.
Count the distinct versions
One member is consistent, whatever that member is. Two or more is the finding, and the list of them tells you how many shapes your handlers have to cope with today.
Strip the query string and look for repeats
If two enabled endpoints share a URL once ? onwards is removed, you are looking at an unfinished dual-endpoint upgrade rather than two independent consumers. That distinction changes the repair completely: one is a cutover to finish, the other is a decision about which version each service should be on.
Finish the migration, or abandon it deliberately
Keep exactly one endpoint per logical consumer. Disable the loser with disabled=true, confirm nothing depends on it, then remove it. Pin the survivor on purpose rather than leaving it inheriting a default that will move under it at the next account upgrade.
How to check it worked
Re-run the script. Every enabled endpoint should report the same version, and it should be one you chose.
python3 stripe_endpoint_version_drift.py
# consistent all 3 enabled endpoint(s) render at 2025-09-30.clover
The full code
One paginated GET against /v1/webhook_endpoints — a restricted key with read access to Webhook Endpoints is enough, and is what you should give it. The classification is a pure function over a list of endpoints, because both ways of getting this wrong are decisions the function makes and not requests it makes: counting a disabled endpoint, and counting null and "" as two versions.
"""Report Stripe webhook endpoints rendering events at different api_versions.
Read only. One paginated GET and no writes: give this a RESTRICTED key with read
access to Webhook Endpoints. 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_endpoint_version_drift")
API = "https://api.stripe.com/v1"
# Both spellings of "this endpoint has no pin" collapse here, before anything
# deduplicates. An account with two unpinned endpoints has one shape, not two.
ACCOUNT_DEFAULT = "account default"
def normalise(api_version):
"""Map an endpoint's raw api_version onto the shape it actually renders.
Pure. Stripe returns None on some unpinned endpoints and "" on others; both
mean "follow the account default", so both become one sentinel.
"""
if api_version is None or api_version == "":
return ACCOUNT_DEFAULT
return str(api_version)
def base_url(url):
"""The URL without its query string or fragment. Pure.
The documented dual-endpoint upgrade puts two endpoints on one URL,
distinguished only by a query parameter, so the query string is exactly what
has to come off before two endpoints can be recognised as the same one.
"""
return str(url or "").split("?", 1)[0].split("#", 1)[0]
def verdict(endpoints):
"""Classify a whole account's endpoints. Pure, so both traps are testable.
`endpoints` is a list of dicts with `url`, `api_version` and `status`.
Returns (state, detail).
"""
live = [e for e in endpoints if e.get("status") == "enabled"]
if not live:
return ("none",
"no enabled endpoints in this mode: nothing is being delivered, "
"so nothing can disagree about a shape")
versions = sorted({normalise(e.get("api_version")) for e in live})
if len(versions) == 1:
return ("consistent",
"all %d enabled endpoint(s) render at %s" % (len(live), versions[0]))
by_url = {}
for e in live:
by_url.setdefault(base_url(e.get("url")), set()).add(
normalise(e.get("api_version")))
shared = sorted(u for u, v in by_url.items() if len(v) > 1)
if shared:
return ("migration",
"%d versions in use (%s), and %s is served at more than one of "
"them. That is the dual-endpoint upgrade shape, still running: "
"the handler is being sent every event twice, in two shapes."
% (len(versions), ", ".join(versions), shared[0]))
return ("drift",
"%d versions in use (%s) across %d endpoint(s) on different URLs. "
"The same event reaches your services in different shapes and only "
"the ones reading a moved field will fail."
% (len(versions), ", ".join(versions), len(live)))
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 endpoints(session):
"""Every webhook endpoint in this key's mode, paginated."""
out = []
params = {"limit": 100}
while True:
page = get(session, "/webhook_endpoints", **params)
data = page.get("data", [])
out.extend(data)
if not data or not page.get("has_more"):
break
params["starting_after"] = data[-1]["id"]
return out
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--show-disabled", action="store_true",
help="also list disabled endpoints, which are never counted")
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 = endpoints(s)
if not eps:
log.info("no webhook endpoints configured for this key's mode")
return 0
state, detail = verdict(eps)
for ep in eps:
if ep.get("status") != "enabled" and not args.show_disabled:
continue
log.info(" %-9s %-24s %s", ep.get("status", "?"),
normalise(ep.get("api_version")), ep.get("url", "?"))
if state in ("consistent", "none"):
log.info("%s %s", state, detail)
return 0
log.warning("%s %s", state, detail)
log.warning(" api_version cannot be edited, so the repair is a cutover, not "
"an update: pick the version every consumer should be on")
log.warning(" disable the losing endpoint: POST %s/webhook_endpoints/{id} "
"-d disabled=true", API)
log.warning(" once nothing depends on it, remove it: DELETE %s/"
"webhook_endpoints/{id}", API)
log.warning(" then pin the survivor deliberately rather than leaving it on "
"the account default, which moves at the next upgrade")
return 1
if __name__ == "__main__":
sys.exit(main())
/**
* Report Stripe webhook endpoints rendering events at different api_versions.
*
* Read only. One paginated GET and no writes: give this a RESTRICTED key with
* read access to Webhook Endpoints. The repair is printed, never performed.
*/
const API = 'https://api.stripe.com/v1';
// Both spellings of "this endpoint has no pin" collapse here, before anything
// deduplicates. An account with two unpinned endpoints has one shape, not two.
export const ACCOUNT_DEFAULT = 'account default';
/**
* Map an endpoint's raw api_version onto the shape it actually renders. Pure.
* Stripe returns null on some unpinned endpoints and '' on others.
*/
export function normalise(apiVersion) {
if (apiVersion === null || apiVersion === undefined || apiVersion === '') {
return ACCOUNT_DEFAULT;
}
return String(apiVersion);
}
/** The URL without its query string or fragment. Pure. */
export function baseUrl(url) {
return String(url ?? '').split('?')[0].split('#')[0];
}
/**
* Classify a whole account's endpoints. Pure, so both traps are testable.
* `endpoints` is an array of objects with url, api_version and status.
*/
export function verdict(endpoints) {
const live = endpoints.filter((e) => e.status === 'enabled');
if (live.length === 0) {
return ['none',
'no enabled endpoints in this mode: nothing is being delivered, so ' +
'nothing can disagree about a shape'];
}
const versions = [...new Set(live.map((e) => normalise(e.api_version)))].sort();
if (versions.length === 1) {
return ['consistent',
`all ${live.length} enabled endpoint(s) render at ${versions[0]}`];
}
const byUrl = new Map();
for (const e of live) {
const u = baseUrl(e.url);
if (!byUrl.has(u)) byUrl.set(u, new Set());
byUrl.get(u).add(normalise(e.api_version));
}
const shared = [...byUrl.entries()].filter(([, v]) => v.size > 1)
.map(([u]) => u).sort();
if (shared.length > 0) {
return ['migration',
`${versions.length} versions in use (${versions.join(', ')}), and ` +
`${shared[0]} is served at more than one of them. That is the ` +
'dual-endpoint upgrade shape, still running: the handler is being sent ' +
'every event twice, in two shapes.'];
}
return ['drift',
`${versions.length} versions in use (${versions.join(', ')}) across ` +
`${live.length} endpoint(s) on different URLs. The same event reaches your ` +
'services in different shapes and only the ones reading a moved field will fail.'];
}
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 endpoints(key) {
const out = [];
const params = { limit: 100 };
for (;;) {
const page = await get(key, '/webhook_endpoints', params);
const data = page.data ?? [];
out.push(...data);
if (data.length === 0 || !page.has_more) break;
params.starting_after = data[data.length - 1].id;
}
return out;
}
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 eps = await endpoints(key);
if (eps.length === 0) {
console.log("no webhook endpoints configured for this key's mode");
return;
}
const [state, detail] = verdict(eps);
for (const ep of eps) {
if (ep.status !== 'enabled') continue;
console.log(` ${String(ep.status).padEnd(9)} ` +
`${normalise(ep.api_version).padEnd(24)} ${ep.url ?? '?'}`);
}
if (state === 'consistent' || state === 'none') {
console.log(`${state} ${detail}`);
return;
}
console.warn(`${state} ${detail}`);
console.warn(' api_version cannot be edited, so the repair is a cutover, not ' +
'an update: pick the version every consumer should be on');
console.warn(` disable the losing endpoint: POST ${API}/webhook_endpoints/{id} ` +
'-d disabled=true');
console.warn(` once nothing depends on it, remove it: DELETE ${API}/` +
'webhook_endpoints/{id}');
console.warn(' then pin the survivor deliberately rather than leaving it on ' +
'the account default, which moves at the next upgrade');
process.exitCode = 1;
}
// 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 first test is the one that decides whether anybody trusts this script. Two endpoints that both inherit the account default are consistent, and an account can report one as null and the other as "". Deduplicating the raw field calls that drift, on an account with none, and a check that cries wolf on its first run does not get a second.
from stripe_endpoint_version_drift import base_url, normalise, verdict
def test_null_and_empty_string_are_one_version_not_two():
# The trap. Deduplicating the raw field reports drift on an account where
# both endpoints simply follow the account default.
assert normalise(None) == normalise("")
state, _ = verdict([
{"url": "https://a.example/hook", "api_version": None, "status": "enabled"},
{"url": "https://b.example/hook", "api_version": "", "status": "enabled"},
])
assert state == "consistent"
def test_a_disabled_endpoint_never_counts():
# It delivers nothing, so its pin cannot put a second shape on the wire.
state, _ = verdict([
{"url": "https://a.example/hook", "api_version": "2025-09-30.clover",
"status": "enabled"},
{"url": "https://old.example/hook", "api_version": "2019-12-03",
"status": "disabled"},
])
assert state == "consistent"
def test_one_pinned_and_one_unpinned_is_drift():
state, detail = verdict([
{"url": "https://a.example/hook", "api_version": "2025-09-30.clover",
"status": "enabled"},
{"url": "https://b.example/hook", "api_version": None, "status": "enabled"},
])
assert state == "drift"
assert "account default" in detail
def test_same_url_differing_only_by_query_is_an_unfinished_migration():
state, detail = verdict([
{"url": "https://a.example/hook", "api_version": "2024-09-30.acacia",
"status": "enabled"},
{"url": "https://a.example/hook?version=2025-09-30",
"api_version": "2025-09-30.clover", "status": "enabled"},
])
assert state == "migration"
assert "https://a.example/hook" in detail
assert base_url("https://a.example/hook?version=x") == "https://a.example/hook"
def test_no_enabled_endpoints_is_not_reported_as_consistent():
state, _ = verdict([{"url": "https://a.example/hook", "api_version": None,
"status": "disabled"}])
assert state == "none"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { baseUrl, normalise, verdict } from './stripe-endpoint-version-drift.mjs';
test('null and empty string are one version not two', () => {
assert.equal(normalise(null), normalise(''));
const [state] = verdict([
{ url: 'https://a.example/hook', api_version: null, status: 'enabled' },
{ url: 'https://b.example/hook', api_version: '', status: 'enabled' },
]);
assert.equal(state, 'consistent');
});
test('a disabled endpoint never counts', () => {
const [state] = verdict([
{ url: 'https://a.example/hook', api_version: '2025-09-30.clover', status: 'enabled' },
{ url: 'https://old.example/hook', api_version: '2019-12-03', status: 'disabled' },
]);
assert.equal(state, 'consistent');
});
test('one pinned and one unpinned is drift', () => {
const [state, detail] = verdict([
{ url: 'https://a.example/hook', api_version: '2025-09-30.clover', status: 'enabled' },
{ url: 'https://b.example/hook', api_version: null, status: 'enabled' },
]);
assert.equal(state, 'drift');
assert.match(detail, /account default/);
});
test('same url differing only by query is an unfinished migration', () => {
const [state, detail] = verdict([
{ url: 'https://a.example/hook', api_version: '2024-09-30.acacia', status: 'enabled' },
{ url: 'https://a.example/hook?version=2025-09-30',
api_version: '2025-09-30.clover', status: 'enabled' },
]);
assert.equal(state, 'migration');
assert.match(detail, /https:\/\/a\.example\/hook/);
assert.equal(baseUrl('https://a.example/hook?version=x'), 'https://a.example/hook');
});
test('no enabled endpoints is not reported as consistent', () => {
const [state] = verdict([
{ url: 'https://a.example/hook', api_version: null, status: 'disabled' },
]);
assert.equal(state, 'none');
});
FAQ
How is this different from one endpoint pinned to an old version?
A single stale pin is wrong consistently: every consumer of that endpoint sees the same old shape, so one bug report describes the whole problem. Drift is two endpoints disagreeing, so the same event is correct in one service and broken in another, and no single payload shows you both halves.
Is an endpoint pinned to an older version always wrong?
No. A deliberate pin is how you keep a payload shape stable while the code that reads it is still being written. What makes drift a finding is that the versions were not chosen together, so nobody can say which shape is the intended one.
Can I just edit api_version on the endpoint that is behind?
No. Update accepts url, enabled_events, description, metadata and disabled. api_version is fixed at creation, which is exactly why the upgrade path creates a second endpoint, and why an abandoned upgrade leaves two versions running.
Two endpoints on the same URL: is that the drift or a separate problem?
Both, and they need different repairs. The version difference means two payload shapes. The shared URL means every event is delivered twice, so an unfinished migration is also a duplicate-delivery incident until the old endpoint is disabled.
Does this need a live secret key?
No. A restricted key with read access to Webhook Endpoints is enough, and it is what this script should be given. It reads configuration and cannot move money if it leaks.
Related field notes
- A webhook endpoint is pinned to an ancient api_version
- Recent events carry two different api_version values
- Two endpoints share one URL, so every event is handled 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
- List all webhook endpoints — Stripe API reference
- The webhook endpoint object — Stripe API reference
- Upgrade your API version — Stripe Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.