Skip to content

Diagnostic Stripe

recent events carry two different api_version values

Nothing here is a configuration field you can go and correct. The drift note is about endpoints disagreeing; this is about the stored events themselves. Event objects are immutable and rendered at whatever the account default was when they occurred, so an upgrade cuts a hard line across the 30-day window. Everything after it is one shape, everything before it is another, and a backfill walks straight through the boundary.

Read-only key Python and Node.js Tests included
Paying with a card
Photo by Nathan Dumlao on Unsplash
The short answer

Page GET /v1/events?limit=100 across the retained window and collect the distinct api_version on the events themselves. More than one value means the stream has a boundary in it. Because Stripe returns events newest first, the created on the last event before the value changes is the timestamp of the transition.

Count the transitions, not just the versions. One is an upgrade. Two or more is an upgrade followed by a rollback inside the 72-hour window, which means the window contains a shape sandwich rather than a clean cut. Then read api_version on your endpoints: if any of them is unpinned, the boundary reached your handler; if all of them are pinned, it only shows in replays and in anything reading /v1/events directly.

The problem in plain words

The tell is that the same code succeeds and fails on the same event type depending on when the event happened. A handler that ran clean all week starts throwing at a precise moment, and reprocessing an event from the day before through the identical code path works. That combination rules out a deploy, rules out data, and points at something nobody thinks of as data at all: the version the payload was frozen at.

It bites hardest in the two places that read old events. A backfill after an outage walks the whole retained window and hits both shapes in one loop. A replay of a specific event, done to debug something else, quietly exercises the old shape and produces a result that does not match production. The historical example everyone remembers is request changing from a bare string ID into an object with id and idempotency_key; every handler that read it as a string broke at exactly one timestamp.

Accountupgradeddefault movesEvents splitold shape staysoldHandler finenew events onlyBackfill runsreads 30 daysThrows mid loopat one timestamp
Stored events are rendered once and never re-rendered, so the API hands back two payload shapes for one event type and both are correct.

Why it happens

Events are immutable, including their version. Stripe renders an event once, at the account default in force at that moment, and stores it. Nothing re-renders it later. So the API can hand you two payload shapes for the same event type, from the same account, inside one paginated response, and both are correct.

The boundary can come from three different actions. An account upgrade, a rollback inside the 72-hour window, or a re-pin. Each leaves the same evidence in the stream, and only the number of transitions distinguishes an upgrade from an upgrade-then-rollback.

Whether it reaches your handler depends on the endpoint pin, not on the event. A pinned endpoint renders delivered payloads at its own version regardless of what the event object says, so a pinned integration can be sitting on a boundary and never see it in production — right up until someone writes a backfill that reads /v1/events and gets the raw shapes.

There is no Stripe-side repair. You cannot re-render stored events. The fix is in your code: branch on event.api_version for the overlap, or stop trusting data.object during the transition and re-fetch the object by ID, which always comes back at the version your request asks for.

The fix, as a flow

The script counts transitions rather than versions, because one change of shape is an upgrade you can date and three changes are an upgrade that was rolled back inside the 72 hour window.

GET /v1/events paginatedplus the endpoint pinsOne versionno boundaryOne transitionbranch for 30 daysSeveral transitionsupgrade then rollbackAny endpoint unpinnedit reached the handler
Whether the boundary reached a handler depends on the endpoint pin, not on the events, so the two reads answer different questions.

How to fix it

Page the whole window, not the first page

GET /v1/events?limit=100 with starting_after until the retention limit. A boundary two days ago is invisible on page one if page one is the last hour of traffic.

Group by api_version and keep nulls separate

An event that reports no api_version goes in its own bucket rather than being dropped. Dropping it means the transition that produced it disappears from the count, and the check reports one clean shape on a window that has two.

Count transitions in timestamp order

Walking the newest-first list, every position where the version differs from the next one is a transition. One transition is an upgrade with a clean cut. More than one is a rollback, and it means the shape alternates rather than changing once.

Ask whether any endpoint is unpinned

GET /v1/webhook_endpoints. Any endpoint with null or "" for api_version follows the account default, so the boundary in the stream is also a boundary in what was delivered. If every endpoint is pinned, the exposure is limited to code that reads the events API itself.

Make the handler survive the overlap

Branch on event.api_version for the 30 days the two shapes coexist, or prefer re-fetching the object by ID over trusting data.object. After 30 days the old shape ages out of retention and the branch can go.

How to check it worked

Re-run the script. Once the boundary has aged past the retention window, everything in the sample should be one version.

python3 stripe_event_version_boundary.py
# single  every one of the 4120 event(s) sampled rendered at 2025-09-30.clover

The full code

One paginated GET against /v1/events, plus one against /v1/webhook_endpoints to say whether the boundary reached anything. A restricted key with read access to both is enough. Two pure functions, because there are two questions: how many shapes are in the window, and whether they were delivered. The first has to be given the events newest first, which is the order Stripe returns them in.

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_event_version_boundary.py
"""Report a version boundary inside the retained Stripe event stream.

Read only. Two paginated GETs and no writes: give this a RESTRICTED key with
read access to Events and Webhook Endpoints. The repair is a code change and 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_event_version_boundary")

API = "https://api.stripe.com/v1"

# An event with no api_version gets its own bucket rather than being dropped.
# Dropping it hides the transition that produced it.
UNREPORTED = "unreported"


def label(api_version):
    """One event's version, with a bucket for the absent case. Pure."""
    if api_version is None or api_version == "":
        return UNREPORTED
    return str(api_version)


def verdict(events):
    """Classify the window. Pure, so the ordering logic can be tested offline.

    `events` is a list of dicts with `api_version` and `created`, NEWEST FIRST,
    which is the order the events API returns. Returns (state, detail).
    """
    if not events:
        return ("empty", "no events in the window: nothing to compare")

    seq = [label(e.get("api_version")) for e in events]
    distinct = sorted(set(seq))
    if len(distinct) == 1:
        return ("single",
                "every one of the %d event(s) sampled rendered at %s"
                % (len(seq), distinct[0]))

    transitions = []
    for i in range(len(seq) - 1):
        if seq[i] != seq[i + 1]:
            transitions.append((events[i].get("created"), seq[i + 1], seq[i]))

    if len(transitions) == 1:
        at, older, newer = transitions[0]
        return ("boundary",
                "two shapes in the window: %s up to created=%s, %s from there "
                "on. Any backfill across this window walks through both."
                % (older, at, newer))
    return ("churn",
            "%d transitions between %d versions (%s). That is an upgrade "
            "followed by a rollback inside the 72 hour window: the shape "
            "alternates rather than changing once."
            % (len(transitions), len(distinct), ", ".join(distinct)))


def exposure(endpoint_versions):
    """Did the boundary reach a handler? Pure.

    `endpoint_versions` is the raw api_version of each enabled endpoint. An
    unpinned endpoint follows the account default, so it moved with the stream.
    """
    if not endpoint_versions:
        return ("no-endpoints",
                "no enabled endpoints: the boundary only affects code reading "
                "the events API directly")
    unpinned = [v for v in endpoint_versions if v is None or v == ""]
    if unpinned:
        return ("inherited",
                "%d of %d enabled endpoint(s) are unpinned and follow the "
                "account default, so the boundary was delivered to your handler"
                % (len(unpinned), len(endpoint_versions)))
    return ("pinned",
            "all %d enabled endpoint(s) are pinned, so delivered payloads keep "
            "one shape. The boundary shows up in replays and backfills."
            % len(endpoint_versions))


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 sample_events(session, limit):
    """Events newest first, paginated, up to `limit`."""
    out = []
    params = {"limit": 100}
    while True:
        page = get(session, "/events", **params)
        data = page.get("data", [])
        out.extend(data)
        if not data or not page.get("has_more") or len(out) >= limit:
            break
        params["starting_after"] = data[-1]["id"]
    return out


def enabled_endpoint_versions(session):
    out = []
    params = {"limit": 100}
    while True:
        page = get(session, "/webhook_endpoints", **params)
        data = page.get("data", [])
        out.extend(e.get("api_version") for e in data if e.get("status") == "enabled")
        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("--max-events", type=int, default=5000,
                    help="stop paginating the event stream after this many")
    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})

    events = sample_events(s, args.max_events)
    state, detail = verdict(events)
    reach, reach_detail = exposure(enabled_endpoint_versions(s))

    log.info("  sampled %d event(s)", len(events))
    log.info("  %-12s %s", reach, reach_detail)

    if state in ("single", "empty"):
        log.info("%s  %s", state, detail)
        return 0

    log.warning("%s  %s", state, detail)
    log.warning("  there is no Stripe-side repair: stored events are immutable "
                "and are never re-rendered")
    log.warning("  branch on event.api_version for the 30 days the two shapes "
                "coexist, then delete the branch")
    log.warning("  or stop trusting data.object during the overlap and re-fetch "
                "the object by id, which is rendered at your request's version")
    return 1


if __name__ == "__main__":
    sys.exit(main())
stripe-event-version-boundary.mjs
/**
 * Report a version boundary inside the retained Stripe event stream.
 *
 * Read only. Two paginated GETs and no writes: give this a RESTRICTED key with
 * read access to Events and Webhook Endpoints. The repair is a code change and
 * is printed, never performed.
 */
const API = 'https://api.stripe.com/v1';

// An event with no api_version gets its own bucket rather than being dropped.
// Dropping it hides the transition that produced it.
export const UNREPORTED = 'unreported';

/** One event's version, with a bucket for the absent case. Pure. */
export function label(apiVersion) {
  if (apiVersion === null || apiVersion === undefined || apiVersion === '') {
    return UNREPORTED;
  }
  return String(apiVersion);
}

/**
 * Classify the window. Pure, so the ordering logic can be tested offline.
 * `events` is an array of objects with api_version and created, NEWEST FIRST,
 * which is the order the events API returns.
 */
export function verdict(events) {
  if (!events || events.length === 0) {
    return ['empty', 'no events in the window: nothing to compare'];
  }

  const seq = events.map((e) => label(e.api_version));
  const distinct = [...new Set(seq)].sort();
  if (distinct.length === 1) {
    return ['single',
      `every one of the ${seq.length} event(s) sampled rendered at ${distinct[0]}`];
  }

  const transitions = [];
  for (let i = 0; i < seq.length - 1; i += 1) {
    if (seq[i] !== seq[i + 1]) transitions.push([events[i].created, seq[i + 1], seq[i]]);
  }

  if (transitions.length === 1) {
    const [at, older, newer] = transitions[0];
    return ['boundary',
      `two shapes in the window: ${older} up to created=${at}, ${newer} from ` +
      'there on. Any backfill across this window walks through both.'];
  }
  return ['churn',
    `${transitions.length} transitions between ${distinct.length} versions ` +
    `(${distinct.join(', ')}). That is an upgrade followed by a rollback inside ` +
    'the 72 hour window: the shape alternates rather than changing once.'];
}

/**
 * Did the boundary reach a handler? Pure. `endpointVersions` is the raw
 * api_version of each enabled endpoint.
 */
export function exposure(endpointVersions) {
  if (!endpointVersions || endpointVersions.length === 0) {
    return ['no-endpoints',
      'no enabled endpoints: the boundary only affects code reading the events ' +
      'API directly'];
  }
  const unpinned = endpointVersions.filter(
    (v) => v === null || v === undefined || v === '');
  if (unpinned.length > 0) {
    return ['inherited',
      `${unpinned.length} of ${endpointVersions.length} enabled endpoint(s) are ` +
      'unpinned and follow the account default, so the boundary was delivered ' +
      'to your handler'];
  }
  return ['pinned',
    `all ${endpointVersions.length} enabled endpoint(s) are pinned, so delivered ` +
    'payloads keep one shape. The boundary shows up in replays and backfills.'];
}

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 sampleEvents(key, limit = 5000) {
  const out = [];
  const params = { limit: 100 };
  for (;;) {
    const page = await get(key, '/events', params);
    const data = page.data ?? [];
    out.push(...data);
    if (data.length === 0 || !page.has_more || out.length >= limit) break;
    params.starting_after = data[data.length - 1].id;
  }
  return out;
}

export async function enabledEndpointVersions(key) {
  const out = [];
  const params = { limit: 100 };
  for (;;) {
    const page = await get(key, '/webhook_endpoints', params);
    const data = page.data ?? [];
    for (const e of data) if (e.status === 'enabled') out.push(e.api_version);
    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 events = await sampleEvents(key);
  const [state, detail] = verdict(events);
  const [reach, reachDetail] = exposure(await enabledEndpointVersions(key));

  console.log(`  sampled ${events.length} event(s)`);
  console.log(`  ${reach.padEnd(12)} ${reachDetail}`);

  if (state === 'single' || state === 'empty') {
    console.log(`${state}  ${detail}`);
    return;
  }

  console.warn(`${state}  ${detail}`);
  console.warn('  there is no Stripe-side repair: stored events are immutable ' +
               'and are never re-rendered');
  console.warn('  branch on event.api_version for the 30 days the two shapes ' +
               'coexist, then delete the branch');
  console.warn('  or stop trusting data.object during the overlap and re-fetch ' +
               "the object by id, which is rendered at your request's version");
  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 interesting case is the third one. Two versions with one transition is an upgrade, and the fix is a branch you can delete in thirty days. Two versions with three transitions is an upgrade that was rolled back, so the shape alternates and a handler written for a single cut-over date gets it wrong for a slice of the window. Counting distinct versions cannot tell those apart; counting transitions can.

test_stripe_event_version_boundary.py
from stripe_event_version_boundary import exposure, label, verdict

NEW = "2025-09-30.clover"
OLD = "2024-09-30.acacia"


def ev(version, created):
    return {"api_version": version, "created": created}


def test_one_version_across_the_window_is_single():
    state, _ = verdict([ev(NEW, 300), ev(NEW, 200), ev(NEW, 100)])
    assert state == "single"


def test_a_missing_version_is_bucketed_not_dropped():
    # Dropping it removes the transition it produced, and the window reports
    # one clean shape when it has two.
    assert label(None) == label("") == "unreported"
    state, _ = verdict([ev(NEW, 300), ev(None, 200), ev(None, 100)])
    assert state == "boundary"


def test_a_clean_cut_reports_the_transition_timestamp():
    # Newest first, so the transition is the created of the oldest new-shape
    # event: the first moment the new version was in force.
    state, detail = verdict([ev(NEW, 300), ev(NEW, 200), ev(OLD, 100)])
    assert state == "boundary"
    assert "created=200" in detail
    assert OLD in detail and NEW in detail


def test_an_upgrade_that_was_rolled_back_is_not_a_clean_cut():
    state, detail = verdict([ev(OLD, 400), ev(NEW, 300), ev(NEW, 200), ev(OLD, 100)])
    assert state == "churn"
    assert "72 hour" in detail


def test_one_unpinned_endpoint_means_the_boundary_was_delivered():
    assert exposure([NEW, None])[0] == "inherited"
    assert exposure([NEW, ""])[0] == "inherited"
    assert exposure([NEW, OLD])[0] == "pinned"
    assert exposure([])[0] == "no-endpoints"
stripe-event-version-boundary.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { exposure, label, verdict } from './stripe-event-version-boundary.mjs';

const NEW = '2025-09-30.clover';
const OLD = '2024-09-30.acacia';
const ev = (api_version, created) => ({ api_version, created });

test('one version across the window is single', () => {
  assert.equal(verdict([ev(NEW, 300), ev(NEW, 200), ev(NEW, 100)])[0], 'single');
});

test('a missing version is bucketed not dropped', () => {
  assert.equal(label(null), 'unreported');
  assert.equal(label(''), 'unreported');
  assert.equal(verdict([ev(NEW, 300), ev(null, 200), ev(null, 100)])[0], 'boundary');
});

test('a clean cut reports the transition timestamp', () => {
  const [state, detail] = verdict([ev(NEW, 300), ev(NEW, 200), ev(OLD, 100)]);
  assert.equal(state, 'boundary');
  assert.match(detail, /created=200/);
  assert.ok(detail.includes(OLD) && detail.includes(NEW));
});

test('an upgrade that was rolled back is not a clean cut', () => {
  const [state, detail] = verdict([ev(OLD, 400), ev(NEW, 300), ev(NEW, 200), ev(OLD, 100)]);
  assert.equal(state, 'churn');
  assert.match(detail, /72 hour/);
});

test('one unpinned endpoint means the boundary was delivered', () => {
  assert.equal(exposure([NEW, null])[0], 'inherited');
  assert.equal(exposure([NEW, ''])[0], 'inherited');
  assert.equal(exposure([NEW, OLD])[0], 'pinned');
  assert.equal(exposure([])[0], 'no-endpoints');
});

FAQ

Can I re-render an old event at the current version?

No. Event objects are immutable, including their api_version, and Stripe never re-renders them. If you need the current shape of the underlying object, fetch it by ID instead: that request is served at whatever version you ask for.

Why did replaying an old event succeed when the live one failed?

Because they were rendered at different versions. The replayed one predates the boundary and carries the shape your handler was written for; the live one is on the other side of it. Same code, same event type, different payload.

Does a pinned endpoint protect me from this?

In production, largely yes: a pinned endpoint renders delivered payloads at its own version regardless of the event's. It does not protect a backfill, because code reading GET /v1/events sees the events as stored, boundary and all.

How many api_version values should a healthy account show?

One, for any window that does not contain an upgrade. Two is an upgrade you can date. More than two transitions between the same versions is a rollback, and it means the shape alternates rather than changing once.

How long do I have to keep the compatibility branch?

Thirty days from the boundary. Events are retained for 30 days, so once the last pre-boundary event ages out there is nothing left in the API that carries the old shape, and the branch can go.

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.