A vendor moves its default version on a Tuesday and your integration starts writing nulls. Nothing in your repository changed. The field you read was renamed in a version released months ago, and your requests never said which version they wanted, so the service answered with its newest.1
What you get
You will end up with a client that names its version on every request and refuses a response served under a different one. You also get a failure that arrives at the call rather than three layers downstream. This is for you if you depend on an API somebody else releases.
Short answer
Send the version explicitly on every call, and make the client refuse to be constructed without one. Then check what the service says it applied, because a request that omits the version gets whatever the service considers current on the day it runs. Keep the pinned value in one constant so an upgrade is a single, reviewable change.
You will need
Node 22 or later, and an API that publishes more than one version. The mechanisms differ by vendor. Stripe pins on a dated version, GitHub uses a dated request header, and others use a media type parameter or a path segment.2
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| A date in a request header | Most APIs, because it is one header and it applies per request | A header a proxy can strip, and a value nobody sees in a URL | The API offers no header and only versions its paths |
| A media type parameter | Content negotiation is already in use and the version belongs to the representation | A header format that is easy to get wrong, and tooling that drops parameters | Your client library flattens the Accept header |
| A version in the path | You want the version visible in every log line and cacheable by URL | A path change per version, so a client upgrade touches every call site | The API versions its behavior rather than its routes |
| No version at all | Nothing, once the API has published a second version | A default that moves under you, silently, on the vendor’s schedule | Always, on any API that versions |
Three of these do the same job and the choice is usually made for you by the vendor. What is worth arguing about is where the value lives in your code. A version repeated at each call site drifts, and a version read from an environment variable changes without a code review. One constant, in one module, released with your own package, is the shape that makes an upgrade visible to whoever approves it. Anything else spreads the decision across files nobody reads together.
Refuse to build an unpinned client
The check belongs in the constructor, where it fails at startup rather than at request time.
export function meters({ baseUrl, version, fetchImpl = fetch }) {
if (!version) throw new Error('pin a version: the service default will move under you')
An unpinned client is not a client with a sensible default. It is a client whose behavior is set by somebody else’s release schedule, and the failure it produces arrives long after the deploy that introduced it.
Keep the version beside the code that depends on it. A constant in the client module, changed in a pull request, gives you a diff that says what moved and a place to record what was tested.
Check what the service applied
Sending a version is a request, not a guarantee.
const applied = res.headers.get('x-api-version')
if (!res.ok) {
const problem = await res.json().catch(() => ({}))
throw Object.assign(new Error(problem.title ?? `HTTP ${res.status}`), { status: res.status, problem })
}
if (applied && applied !== version) {
throw new Error(`asked for ${version}, served ${applied}`)
}
Three things produce the same symptom. A proxy strips an unknown header, a gateway rewrites one, or a service falls back for a version it is retiring. In each case you asked for one shape and received another. Comparing the echoed version with the requested one turns that into an error at the boundary.
If the service echoes nothing, ask the vendor. Until then, assert on a field that differs between versions in your contract test, which catches the same drift a request later.
Record the applied version on every stored record while you are at it. A row that says which version wrote it turns a migration question into a query, and the cost is one short column.
Check it worked
Run one client against two services whose defaults differ.
node demo.mjs
while the service default is the older version
pinned to 2024-11-01 id,serial,installed
pinned to 2026-03-01 id,serial,installed_at,state
pinned to a version that is gone Unknown API version
unpinned request id,serial,installed
after the service moves its default
pinned to 2024-11-01 id,serial,installed
unpinned request id,serial,installed_at,state
Compare the two unpinned lines. The same request, against the same service, returns a renamed field and an extra one after the vendor moves its default. The pinned lines are identical across both blocks, which is what pinning buys. The third line shows the other half: a version that no longer exists fails immediately and says what remains.
node --test client.test.mjs
1..5
# tests 5
# suites 0
# pass 5
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 293.311541
When it goes wrong
The header is sent and ignored. A proxy dropped an unrecognized header, or the name is wrong by a character. Log the applied version once at startup and compare it with the pinned one. Startup is the right moment, because a mismatch found then is a deploy problem rather than a customer report.
Everything works until a retirement. The pinned version reached its sunset date. Watch the vendor’s
Sunset and Deprecation headers, and treat either as a scheduled task rather than as noise.3
The upgrade breaks in production and not in tests. Tests run against a mock built from the old version. Pin the mock to the same constant, and change both in one commit.
Two services in one process disagree. A shared client was given one version and two callers with different expectations. Build one client per version rather than passing a version per call. Two clients cost two objects and make the difference visible where it is configured.
When not to do this
Do not pin and then never move. A version you never upgrade becomes a version the vendor retires while you are busy, and the migration arrives as an outage rather than as work you scheduled. Put a reminder against the pinned constant, and treat an upgrade as ordinary maintenance rather than as a project.
Do not pin to a version you have not tested. Copying a number out of the documentation gives you the illusion of control and the same surprise, later. Run the suite against it before the constant changes, and read the vendor’s changelog for the versions in between.
Do not read the version from an environment variable that differs between environments. Staging passing and production failing is the outcome, and the difference is invisible in the code. If the environments genuinely need different versions, that is a migration in progress, and it deserves a name rather than a variable.
Related how-tos
Last verified
Verified 2026-09-14 against Node 22.22.2. Both output blocks are what the preceding command printed.
Footnotes
-
Not every vendor answers with its newest. GitHub’s page on API versions gives 2026-03-10 as its example, released on a Tuesday, and says that a request without the
X-GitHub-Api-Versionheader defaults to 2022-11-28, the older of its two supported versions. When a version closes down, requests without a version move to the next oldest supported version rather than the newest. An unpinned client there gets the oldest thing still standing, which is a different surprise on a different day. ↩︎ Back to text -
Stripe’s dates have grown names. Its versioning page names the version in force as 2026-08-26.dahlia. A major release such as Acacia carries changes that are not backward-compatible, and each monthly release after it takes the same name and promises only compatible ones. A request that sends no
Stripe-Versionheader is not without a version. It uses the account’s default version, which is set in Workbench, so the choice was made, by somebody, on a settings page. ↩︎ Back to text -
GitHub documents both headers for a version approaching closing down.
Sunsetcarries the removal date and follows RFC 8594, and after that date a request naming the version gets 410 Gone.Deprecationcarries the closing date as an HTTP date per RFC 7231, which is the format RFC 8594 givesSunsetand not the one RFC 9745 givesDeprecation. The support window is 24 months from the release of the next version, which puts the end of 2022-11-28 at March 10, 2028. The dates are published. Reading them is the scheduled task. ↩︎ Back to text