Diagnostic Stripe
a webhook endpoint is pinned to an ancient api_version
The signature verifies. The event arrives. And then the object inside it is missing half the fields the current SDK expects, so the deserializer hands back an empty Optional or a null cast, and nobody throws anything. Fetching the same object straight from the API returns it complete, which makes the whole thing look like Stripe sending empty payloads.
Read GET /v1/webhook_endpoints and look at api_version on each one. Treat both null and the empty string as unpinned; an is not None test quietly reports every unpinned endpoint as pinned. For the rest, compare the YYYY-MM-DD prefix against the current release line and hard-flag anything before 2024-09-30, the Acacia boundary.
Then note the part that catches people out: api_version is not updatable. POST /v1/webhook_endpoints/{id} accepts url, enabled_events, description, metadata and disabled, and nothing else. Fixing this means creating a second endpoint and retiring the first.
The problem in plain words
What makes this expensive is the order the failure happens in. Signature verification passes, because the signature covers the raw bytes and has nothing to do with the payload shape. The handler is entered. Only then does the typed deserializer look at a JSON body rendered two years ago, fail to find the fields it was generated against, and return an empty object rather than an error. In stripe-java that is getDataObjectDeserializer().getObject() giving you an empty Optional; in stripe-dotnet it is Data.Object as PaymentIntent evaluating to null without throwing.
So the log shows a received, verified, 200-returning webhook that did nothing. The first debugging move anyone makes is to re-fetch the object by ID, which works perfectly, because that request is rendered at the SDK's own version. Every piece of evidence points at the payload rather than at a configuration field nobody remembers setting.
Why it happens
The version is frozen at creation and there is no field to move it. When an endpoint is created with an explicit api_version, every event it is ever sent is rendered at that version, forever, regardless of what the account default does afterwards. Unlike almost everything else on the object, this one cannot be edited, so the natural repair — open the endpoint, change the version, save — does not exist.
Statically typed SDKs degrade instead of failing. stripe-node hands you a plain object and your code reads whatever is there. stripe-java, stripe-dotnet and stripe-go deserialize against the schema they were generated for, and when a field has moved or been renamed the safe behaviour they chose is an empty result. That is the right call for resilience and the wrong one for discovery: an exception would have named the problem in the first stack trace.
The account default moves and the pin does not. Upgrading the account version is a deliberate act with a changelog and a 72-hour rollback window, and it is the thing people plan for. Pinned endpoints sit outside that upgrade entirely. An account can be fully current while an endpoint created in 2022 keeps rendering 2022 payloads into current code.
Unpinned reads as null or as an empty string, depending on the endpoint. Both mean the same thing — inherit the account default — and a check that only tests one of them classifies half the unpinned endpoints as pinned to nothing, then compares that against a date and reports nonsense.
The fix, as a flow
The script reads one field per endpoint and trims it to its date prefix before comparing, because the two ways to get this wrong are both string handling: an empty version read as a pin, and a release name compared as though it were part of the date.
How to fix it
List the endpoints and read api_version on each
One GET. GET /v1/webhook_endpoints?limit=100 returns api_version per endpoint. Normalise null and "" to a single sentinel before you compare anything, because they are the same state written two ways.
Compare the date prefix, not the full string
A pinned version looks like 2024-09-30.acacia: an ISO date, then the release-line name. Only the date part is orderable, and because it is ISO you can compare it as a string. Anything before 2024-09-30 predates Acacia, and every release line since has carried breaking changes.
Check what the SDK is actually generated for
The pin is only a problem relative to the code reading it. An endpoint pinned to the same version your SDK targets is correct and deliberate. Print both numbers next to each other; the gap is the finding, not the pin.
Migrate with a second endpoint, because the field is immutable
POST /v1/webhook_endpoints with the same url plus a distinguishing query parameter, the same enabled_events, and the target api_version. Both endpoints deliver for a while. Have the route ignore-and-200 the new shape until the code that understands it ships.
Disable the old endpoint once, and only once, the new one is live
POST /v1/webhook_endpoints/{old_id} with disabled=true. Do this deliberately and soon: two enabled endpoints on one URL is a different failure that delivers every event twice, and it is the usual residue of a migration that stopped halfway.
How to check it worked
Re-run the script. Every endpoint should report either the current line or a pin you chose on purpose.
python3 stripe_endpoint_api_version.py
# current https://example.com/stripe/webhook pinned to 2025-09-30, on the current line
The full code
One GET and nothing else — 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 single string, because the two ways to get this wrong are both string handling: treating "" as a pinned version, and comparing 2024-09-30.acacia to 2025-09-30.clover without trimming to the date first.
"""Report Stripe webhook endpoints pinned to an outdated api_version.
Read only. One GET and no writes: give this a RESTRICTED key with read access
to Webhook Endpoints. The migration is printed, never performed, because this
script holds a credential to a live payments account.
"""
import argparse
import logging
import os
import re
import sys
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("stripe_endpoint_api_version")
API = "https://api.stripe.com/v1"
CURRENT_LINE = "2025-09-30" # Clover
ACACIA = "2024-09-30" # every line from here on carried breaking changes
DATE = re.compile(r"^(\d{4}-\d{2}-\d{2})")
def verdict(api_version, current_line=CURRENT_LINE):
"""Classify one endpoint's pin. Pure, so the string handling can be tested.
`api_version` is the raw field: None or "" for an unpinned endpoint, else
something like "2024-09-30.acacia". Returns (state, detail).
"""
if api_version is None or api_version == "":
return ("unpinned",
"no api_version: events render at the account default, which "
"moves under this endpoint whenever the account is upgraded")
m = DATE.match(str(api_version))
if not m:
return ("unreadable",
"api_version %s has no YYYY-MM-DD prefix to compare"
% str(api_version))
date = m.group(1)
if date < ACACIA:
return ("ancient",
"pinned to %s, before the %s Acacia line. Typed SDKs deserialize "
"this into empty objects without throwing." % (date, ACACIA))
if date < current_line:
return ("stale",
"pinned to %s, behind the current %s line. Check the changelog "
"for the fields your handler reads." % (date, current_line))
return ("current", "pinned to %s, on the current line" % date)
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("--current-line", default=CURRENT_LINE,
help="the release line your SDK targets, as YYYY-MM-DD")
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
bad = 0
for ep in eps:
state, detail = verdict(ep.get("api_version"), args.current_line)
line = "%-10s %s %s" % (state, ep.get("url", "?"), detail)
if state == "current":
log.info(line)
continue
if state == "unpinned":
log.info(line)
continue
bad += 1
log.warning(line)
log.warning(" api_version is not updatable: POST %s/webhook_endpoints/%s "
"accepts only url, enabled_events, description, metadata, disabled",
API, ep["id"])
log.warning(" migrate instead: create a second endpoint on the same url "
"with a distinguishing query param and api_version=%s, keeping "
"enabled_events identical", args.current_line)
log.warning(" then, once the new shape is handled: POST %s/"
"webhook_endpoints/%s -d disabled=true", API, ep["id"])
log.info("%d endpoint(s), %d on an outdated pin", len(eps), bad)
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Report Stripe webhook endpoints pinned to an outdated api_version.
*
* Read only. One GET and no writes: give this a RESTRICTED key with read access
* to Webhook Endpoints. The migration is printed, never performed.
*/
const API = 'https://api.stripe.com/v1';
export const CURRENT_LINE = '2025-09-30'; // Clover
const ACACIA = '2024-09-30'; // breaking changes from here on
const DATE = /^(\d{4}-\d{2}-\d{2})/;
/**
* Classify one endpoint's pin. Pure, so the string handling can be tested.
* `apiVersion` is the raw field: null or '' when the endpoint is unpinned.
*/
export function verdict(apiVersion, currentLine = CURRENT_LINE) {
if (apiVersion === null || apiVersion === undefined || apiVersion === '') {
return ['unpinned',
'no api_version: events render at the account default, which moves under ' +
'this endpoint whenever the account is upgraded'];
}
const m = DATE.exec(String(apiVersion));
if (!m) {
return ['unreadable',
`api_version ${apiVersion} has no YYYY-MM-DD prefix to compare`];
}
const date = m[1];
if (date < ACACIA) {
return ['ancient',
`pinned to ${date}, before the ${ACACIA} Acacia line. Typed SDKs ` +
'deserialize this into empty objects without throwing.'];
}
if (date < currentLine) {
return ['stale',
`pinned to ${date}, behind the current ${currentLine} line. Check the ` +
'changelog for the fields your handler reads.'];
}
return ['current', `pinned to ${date}, on the current line`];
}
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;
}
let bad = 0;
for (const ep of eps) {
const [state, detail] = verdict(ep.api_version);
const line = `${state.padEnd(10)} ${ep.url ?? '?'} ${detail}`;
if (state === 'current' || state === 'unpinned') { console.log(line); continue; }
bad += 1;
console.warn(line);
console.warn(` api_version is not updatable: POST ${API}/webhook_endpoints/` +
`${ep.id} accepts only url, enabled_events, description, ` +
'metadata, disabled');
console.warn(' migrate instead: create a second endpoint on the same url ' +
`with a distinguishing query param and api_version=${CURRENT_LINE}, ` +
'keeping enabled_events identical');
console.warn(` then, once the new shape is handled: POST ${API}/` +
`webhook_endpoints/${ep.id} -d disabled=true`);
}
console.log(`${eps.length} endpoint(s), ${bad} on an outdated pin`);
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
Two of these tests exist because of one line of research. An empty string and a null both mean unpinned, and a check written with is not None reports the empty-string case as a pinned version, then compares "" against a date and calls it ancient. The third pins the date comparison, which has to trim the release-line suffix before it can order anything.
from stripe_endpoint_api_version import verdict
def test_null_is_unpinned():
state, _ = verdict(None)
assert state == "unpinned"
def test_empty_string_is_also_unpinned():
# The trap: `if ep.api_version is not None` calls this pinned, then compares
# an empty string against a date and reports it as the oldest pin on record.
state, _ = verdict("")
assert state == "unpinned"
def test_pre_acacia_is_hard_flagged():
state, detail = verdict("2022-11-15")
assert state == "ancient"
assert "2024-09-30" in detail
def test_the_suffix_is_trimmed_before_comparing():
# "2024-09-30.acacia" > "2025-09-30" as a naive string compare is False, but
# "2025-09-30.clover" > "2025-09-30" is True, so the suffix has to go first.
assert verdict("2024-09-30.acacia")[0] == "stale"
assert verdict("2025-09-30.clover")[0] == "current"
def test_a_version_with_no_date_is_not_silently_current():
state, _ = verdict("beta")
assert state == "unreadable"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict } from './stripe-endpoint-api-version.mjs';
test('null is unpinned', () => {
assert.equal(verdict(null)[0], 'unpinned');
});
test('empty string is also unpinned', () => {
assert.equal(verdict('')[0], 'unpinned');
});
test('pre acacia is hard flagged', () => {
const [state, detail] = verdict('2022-11-15');
assert.equal(state, 'ancient');
assert.match(detail, /2024-09-30/);
});
test('the suffix is trimmed before comparing', () => {
assert.equal(verdict('2024-09-30.acacia')[0], 'stale');
assert.equal(verdict('2025-09-30.clover')[0], 'current');
});
test('a version with no date is not silently current', () => {
assert.equal(verdict('beta')[0], 'unreadable');
});
FAQ
Can I just change api_version on the existing endpoint?
No. Update accepts url, enabled_events, description, metadata and disabled. api_version is fixed when the endpoint is created and there is no parameter to move it, which is why the documented upgrade path is to create a second endpoint and retire the first.
Why does the signature still verify if the payload is wrong?
Because the signature is computed over the raw request bytes, which are perfectly valid. It proves the body came from Stripe unmodified. It says nothing about which API version rendered the object inside, so verification passing tells you nothing about whether your SDK can read it.
Is an unpinned endpoint better or worse than a pinned one?
Neither, but it is a different risk. Unpinned means the endpoint follows the account default, so an account upgrade changes your webhook payloads at the same time as everything else. Pinned means the payload shape is stable until you migrate it deliberately. The failure here is a pin nobody chose and nobody revisited.
How do I tell what version my SDK expects?
Every Stripe SDK release targets one API version and pins it on outgoing requests. Read it from the library's changelog or its version constant, then compare it with the endpoint pin. The gap between the two is the finding; the pin on its own is not.
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 cannot move money if it leaks.
Related field notes
- Two endpoints share one URL, so every event is handled twice
- enabled_events lists event types that are dead or rejected
- A webhook endpoint sits disabled after days of retries
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
- Update a webhook endpoint — 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.