Skip to content

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-only key Python and Node.js Tests included
Man with beard and tattoos pushing a bicycle
Photo by Caden Norcott on Unsplash
The short answer

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.

Endpointcreatedapi_version pinnedAccountupgradedthe pin does notmoveEvent deliveredrendered at theold versionSignatureverifiesraw bytes arevalidObject emptyno exceptionthrown
Verification passes first, so every log line looks healthy right up to the point where the object turns out to be empty.

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.

GET /v1/webhook_endpointsapi_version, date prefix onlyOn the current linematches the SDKNull or empty stringunpinned, follows the accountBehind the current lineread the changelogBefore 2024-09-30migrate to a new endpoint
Unpinned is a different risk, not a smaller one. It follows the account default, so an upgrade changes these payloads too.

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.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 114 Stripe fixes, free and open source.
stripe_endpoint_api_version.py
"""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())
stripe-endpoint-api-version.mjs
/**
 * 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.

test_stripe_endpoint_api_version.py
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"
stripe-endpoint-api-version.test.mjs
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

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.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.