Skip to content

Diagnostic Stripe

account default API version is years behind the current one

Nothing on a webhook endpoint explains this one. The pinned-endpoint note is about a value you can read straight off the endpoint object; this is the account-wide default sitting underneath it, and there is no API endpoint anywhere that returns it. It is why a parameter copied out of the current documentation comes back as no such parameter, and why renewal invoices Stripe generates for you arrive in a shape from two years ago.

Read-only key Python and Node.js Tests included
A pile of items sitting on top of a table
Photo by Growtika on Unsplash
The short answer

There is no GET that returns the account default, so read it two indirect ways and compare them. First, GET /v1/events?limit=1 and take data[0].api_version: an event is rendered at the account default in force when it was created, so the newest event tells you what the default was as of then.

Second, and more authoritative: issue any GET over raw HTTP without a Stripe-Version request header and read the Stripe-Version header off the response. That is the default right now. Use curl or a plain HTTP client for this — every official SDK sends its own Stripe-Version, and gets its own value echoed back.

The problem in plain words

The symptom does not look like a version problem. You copy a parameter out of the API reference, send it, and Stripe replies that it does not exist. The natural conclusion is a typo, or a feature not enabled on the account, or documentation that is ahead of the release. It is none of those: the documentation describes the current version and your requests are being served at whatever version your very first API call happened to land on, years ago.

The half that costs money is quieter. Automated Billing operations Stripe performs on your behalf — generating renewal invoices, advancing subscriptions — run at the account default. So the objects those operations produce, and the events announcing them, carry an old shape even though your own code is current. A field that moved is absent in exactly the places nobody is looking, and present everywhere they are.

First API callversion fixed in2021No auto advanceit never movesReleases stackupeach one breakingDocs examplefailsno such parameterRenewals oldshapeStripe bills foryou
No object reports the account default, so the one field that decides how Stripe renders everything is the one field nobody can read.

Why it happens

The version is set once, by accident, and never moves. It is fixed at the moment of your first API request and there is no automatic advance. An account created for a prototype in 2021 is still on the 2021 version in 2026, including after the entire integration has been rewritten twice.

No endpoint reports it. Unpinned webhook endpoints return null or "" for api_version, not the inherited value, so the obvious place to look is deliberately silent. This is why the check has to infer it from an event and corroborate it from a response header.

Every SDK hides the answer. Each library pins a Stripe-Version on its outgoing requests, and Stripe honours it. So a script written with the SDK reads back the SDK's version and reports it as the account default, which is confidently wrong. The corroborating read only works from a client that sends no version header at all.

The releases in between are not cosmetic. Acacia 2024-09-30, Basil 2025-03-31 and Clover 2025-09-30 each carried breaking changes, and they accumulate. Skipping four of them is four changelogs to read, not one, and that reading is the actual work of the upgrade.

The fix, as a flow

The script reads one response twice: the newest event for the version in force when it fired, and the Stripe-Version header for the version in force right now. Disagreement between them is itself the finding.

GET /v1/events?limit=1body and response headerBoth agree, currentnothing to doUnder a year behindone changelog to readHeader differsupgraded or rolled backOver a year behindseveral changelogs
An SDK sends its own Stripe-Version and gets it echoed back, so this reading only works from a client that sends none.

How to fix it

Read the newest event and take its api_version

GET /v1/events?limit=1. Events are immutable and rendered at the default in force when they occurred, so the newest one is the best evidence available through the API. On a dormant account there may be nothing in the 30-day window, and the check has to say so rather than guess.

Read the Stripe-Version response header on the same request

Send no Stripe-Version request header and Stripe answers with the account default in the response header. Both scripts here use a plain HTTP client for exactly this reason: an SDK would send its own version and the answer would be the SDK's, not the account's.

Compare the two before judging either

If the header and the newest event disagree, the default moved after that event was created — an upgrade, or a rollback inside the 72-hour window. The header is the current truth; the mismatch itself is worth reporting, because it means the event stream in the retained window spans two shapes.

Measure the gap in years, not in release names

Compare the YYYY-MM-DD prefix against today. More than about twelve months behind is the threshold worth acting on, because that is roughly two release lines and the changelog reading stops being a single afternoon.

Test with a per-request header before touching the account

Send Stripe-Version: <target> on individual requests and run your integration against it. Nothing about the account changes, and you find the breaking changes on your own schedule. Only then upgrade the account in the Dashboard, where you get a 72-hour rollback window with old-shape retries for failed webhooks.

How to check it worked

Re-run the script after the upgrade. The header and the newest event should agree, and the date should be on the current line.

python3 stripe_account_api_version.py
# current    2025-09-30.clover  header and newest event agree

The full code

One GET against /v1/events, read twice: once for the body and once for the response header. A restricted key with read access to Events is enough. Both classifications are pure functions — one decides which of the two readings to believe, the other decides how far behind that reading is — and the second takes today's date as an argument so the tests do not change verdict as the calendar moves.

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_account_api_version.py
"""Report the Stripe account's default API version and how far behind it is.

Read only. One GET and no writes: give this a RESTRICTED key with read access to
Events. The upgrade is printed, never performed, because this script holds a
credential to a live payments account.

Deliberately uses a plain HTTP client rather than an SDK. Every official Stripe
library sends its own Stripe-Version header, and Stripe honours it, so an
SDK-based version of this script reads back the library's version and reports it
as the account's.
"""
import argparse
import datetime as dt
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_account_api_version")

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

CURRENT_LINE = "2025-09-30"  # Clover
DATE = re.compile(r"^(\d{4}-\d{2}-\d{2})")
YEAR = 365


def authority(event_version, header_version):
    """Decide which of the two indirect readings to believe. Pure.

    Returns (version, note). The response header is the default right now; the
    newest event is the default at the moment that event was created. When they
    disagree the account moved recently, and that is worth saying out loud.
    """
    if not event_version and not header_version:
        return (None,
                "no reading available: no events in the 30 day window and no "
                "Stripe-Version on the response")
    if header_version and not event_version:
        return (header_version,
                "from the Stripe-Version response header; no events in the "
                "window to corroborate it")
    if event_version and not header_version:
        return (event_version,
                "from the newest event; the response carried no Stripe-Version "
                "header, so this is the default as of that event and not now")
    if str(header_version).split(".")[0] != str(event_version).split(".")[0]:
        return (header_version,
                "the header says %s and the newest event says %s: the default "
                "moved after that event, or was rolled back inside the 72 hour "
                "window. The retained events span both shapes."
                % (header_version, event_version))
    return (header_version, "header and newest event agree")


def verdict(version, today, current_line=CURRENT_LINE):
    """How far behind the account default is. Pure.

    `today` is an ISO date string and is an argument rather than a call to
    date.today() so the tests keep the same answer as the calendar moves.
    """
    if not version:
        return ("unknown",
                "nothing to judge: the account default could not be read from "
                "an event or from a response header")
    m = DATE.match(str(version))
    if not m:
        return ("unreadable",
                "%r has no YYYY-MM-DD prefix to compare" % (version,))
    date = m.group(1)
    cutoff = (dt.date.fromisoformat(today) - dt.timedelta(days=YEAR)).isoformat()
    if date < cutoff:
        return ("stale",
                "the account default is %s, more than a year behind. Read the "
                "changelog for every release line between %s and %s; the "
                "breaking changes accumulate rather than replace each other."
                % (date, date, current_line))
    if date < current_line:
        return ("trailing",
                "the account default is %s, behind the current %s line but "
                "within a year of it. One changelog to read."
                % (date, current_line))
    return ("current", "the account default is %s, on the current line" % date)


def read_default(session):
    """One GET, read twice: the newest event's version and the response header."""
    r = session.get(API + "/events", params={"limit": 1}, 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()
    data = r.json().get("data", [])
    event_version = data[0].get("api_version") if data else None
    return event_version, r.headers.get("Stripe-Version")


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--current-line", default=CURRENT_LINE,
                    help="the release line to measure against, as YYYY-MM-DD")
    ap.add_argument("--today", default=dt.date.today().isoformat(),
                    help="ISO date to measure the gap from, for reproducible runs")
    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()
    # No Stripe-Version header here on purpose: that is what makes the response
    # header report the account default rather than echo a version we chose.
    s.headers.update({"Authorization": "Bearer " + key})

    event_version, header_version = read_default(s)
    version, note = authority(event_version, header_version)
    state, detail = verdict(version, args.today, args.current_line)

    log.info("  %-9s %s", state, version or "unknown")
    log.info("  %s", note)

    if state == "current":
        log.info("%s  %s", state, detail)
        return 0

    log.warning("%s  %s", state, detail)
    log.warning("  test first without changing anything: send a per-request "
                "Stripe-Version: %s header and run your integration against it",
                args.current_line)
    log.warning("  then upgrade in the Dashboard: Workbench, Overview, API "
                "versions, Upgrade available")
    log.warning("  you get a 72 hour rollback window, during which webhooks that "
                "fail on the new shape are retried with the old structure")
    return 1


if __name__ == "__main__":
    sys.exit(main())
stripe-account-api-version.mjs
/**
 * Report the Stripe account's default API version and how far behind it is.
 *
 * Read only. One GET and no writes: give this a RESTRICTED key with read access
 * to Events. The upgrade is printed, never performed.
 *
 * Deliberately uses fetch rather than an SDK. Every official Stripe library
 * sends its own Stripe-Version header, and Stripe honours it, so an SDK-based
 * version of this script reads back the library's version, not the account's.
 */
const API = 'https://api.stripe.com/v1';

export const CURRENT_LINE = '2025-09-30'; // Clover
const DATE = /^(\d{4}-\d{2}-\d{2})/;
const YEAR = 365;

/**
 * Decide which of the two indirect readings to believe. Pure.
 * Returns [version, note].
 */
export function authority(eventVersion, headerVersion) {
  if (!eventVersion && !headerVersion) {
    return [null,
      'no reading available: no events in the 30 day window and no ' +
      'Stripe-Version on the response'];
  }
  if (headerVersion && !eventVersion) {
    return [headerVersion,
      'from the Stripe-Version response header; no events in the window to ' +
      'corroborate it'];
  }
  if (eventVersion && !headerVersion) {
    return [eventVersion,
      'from the newest event; the response carried no Stripe-Version header, ' +
      'so this is the default as of that event and not now'];
  }
  if (String(headerVersion).split('.')[0] !== String(eventVersion).split('.')[0]) {
    return [headerVersion,
      `the header says ${headerVersion} and the newest event says ` +
      `${eventVersion}: the default moved after that event, or was rolled back ` +
      'inside the 72 hour window. The retained events span both shapes.'];
  }
  return [headerVersion, 'header and newest event agree'];
}

/**
 * How far behind the account default is. Pure. `today` is an ISO date string
 * and is an argument so the tests keep the same answer as the calendar moves.
 */
export function verdict(version, today, currentLine = CURRENT_LINE) {
  if (!version) {
    return ['unknown',
      'nothing to judge: the account default could not be read from an event ' +
      'or from a response header'];
  }
  const m = DATE.exec(String(version));
  if (!m) {
    return ['unreadable', `${version} has no YYYY-MM-DD prefix to compare`];
  }
  const date = m[1];
  const cutoff = new Date(Date.parse(`${today}T00:00:00Z`) - YEAR * 86400000)
    .toISOString().slice(0, 10);
  if (date < cutoff) {
    return ['stale',
      `the account default is ${date}, more than a year behind. Read the ` +
      `changelog for every release line between ${date} and ${currentLine}; the ` +
      'breaking changes accumulate rather than replace each other.'];
  }
  if (date < currentLine) {
    return ['trailing',
      `the account default is ${date}, behind the current ${currentLine} line ` +
      'but within a year of it. One changelog to read.'];
  }
  return ['current', `the account default is ${date}, on the current line`];
}

export async function readDefault(key) {
  const url = new URL(API + '/events');
  url.searchParams.set('limit', '1');
  // No Stripe-Version header here on purpose: that is what makes the response
  // header report the account default rather than echo a version we chose.
  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}`);
  const body = await res.json();
  const first = (body.data ?? [])[0];
  return [first ? first.api_version : null, res.headers.get('stripe-version')];
}

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 today = process.env.TODAY ?? new Date().toISOString().slice(0, 10);
  const [eventVersion, headerVersion] = await readDefault(key);
  const [version, note] = authority(eventVersion, headerVersion);
  const [state, detail] = verdict(version, today);

  console.log(`  ${state.padEnd(9)} ${version ?? 'unknown'}`);
  console.log(`  ${note}`);

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

  console.warn(`${state}  ${detail}`);
  console.warn('  test first without changing anything: send a per-request ' +
               `Stripe-Version: ${CURRENT_LINE} header and run your integration ` +
               'against it');
  console.warn('  then upgrade in the Dashboard: Workbench, Overview, API ' +
               'versions, Upgrade available');
  console.warn('  you get a 72 hour rollback window, during which webhooks that ' +
               'fail on the new shape are retried with the old structure');
  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

Two of these exist because the account default is read indirectly and the two readings can legitimately disagree. The header is now; the newest event is whenever that event happened. A check that trusts the event alone reports the pre-upgrade version for as long as the account stays quiet, and a check that reports nothing when there are no events at all is better than one that calls silence current.

test_stripe_account_api_version.py
from stripe_account_api_version import authority, verdict

TODAY = "2026-01-15"


def test_nothing_readable_is_not_reported_as_current():
    version, note = authority(None, None)
    assert version is None
    assert verdict(version, TODAY)[0] == "unknown"
    assert "30 day" in note


def test_the_header_wins_and_the_disagreement_is_named():
    # The header is the default now. The event is the default when it fired.
    version, note = authority("2024-09-30.acacia", "2025-09-30.clover")
    assert version == "2025-09-30.clover"
    assert "2024-09-30.acacia" in note and "72 hour" in note


def test_over_a_year_behind_is_stale_and_under_it_is_not():
    assert verdict("2024-09-30.acacia", TODAY)[0] == "stale"
    assert verdict("2025-03-31.basil", TODAY)[0] == "trailing"


def test_the_release_line_suffix_is_trimmed_before_comparing():
    # "2025-09-30.clover" as a raw string is greater than "2025-09-30", so the
    # date has to be cut off the front before anything is ordered.
    assert verdict("2025-09-30.clover", TODAY)[0] == "current"


def test_a_version_with_no_date_is_not_silently_current():
    assert verdict("beta", TODAY)[0] == "unreadable"
stripe-account-api-version.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { authority, verdict } from './stripe-account-api-version.mjs';

const TODAY = '2026-01-15';

test('nothing readable is not reported as current', () => {
  const [version, note] = authority(null, null);
  assert.equal(version, null);
  assert.equal(verdict(version, TODAY)[0], 'unknown');
  assert.match(note, /30 day/);
});

test('the header wins and the disagreement is named', () => {
  const [version, note] = authority('2024-09-30.acacia', '2025-09-30.clover');
  assert.equal(version, '2025-09-30.clover');
  assert.match(note, /2024-09-30\.acacia/);
  assert.match(note, /72 hour/);
});

test('over a year behind is stale and under it is not', () => {
  assert.equal(verdict('2024-09-30.acacia', TODAY)[0], 'stale');
  assert.equal(verdict('2025-03-31.basil', TODAY)[0], 'trailing');
});

test('the release line suffix is trimmed before comparing', () => {
  assert.equal(verdict('2025-09-30.clover', TODAY)[0], 'current');
});

test('a version with no date is not silently current', () => {
  assert.equal(verdict('beta', TODAY)[0], 'unreadable');
});

FAQ

Is there really no API call that returns the account default?

There is no field on any object that reports it. The two ways to read it are both indirect: the api_version on an event, which is the default at the moment that event was created, and the Stripe-Version response header on a request that sent no version header of its own.

Why does my SDK report a different version than this script?

Because it sent one. Every official library pins a Stripe-Version on outgoing requests, so the response echoes the library's version rather than the account's. Reading the account default requires a client that sends no version header, which is why both scripts here use plain HTTP.

Will upgrading the account break my webhook handlers?

It changes the shape of events for any endpoint that is unpinned, because unpinned means follow the account default. Pinned endpoints are unaffected by the account upgrade and stay on their own version, which is a separate thing to check.

What happens if the upgrade goes badly?

There is a 72-hour rollback window. During it, webhook events that failed against the new shape are retried with the old structure. That window is also what leaves two api_version values in the retained event stream, which is worth knowing before you debug the aftermath.

Can I move one integration forward without upgrading the account?

Yes, and it is the safer order. Send Stripe-Version on individual requests and that request alone is served at the target version. Nothing about the account changes, so you can find the breaking changes one call site at a time.

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.