Docs

Versions

How dated versions work, how to pin a key to one, and how to read what changed.

View as Markdown

/api/ has no version in its path. It has a date in a header.

Capa-Version: 2026-10-01

A version is a dated snapshot of how the API behaves. Once a date is published it never changes shape. New behaviour arrives as a new date, and your integration keeps getting the old one until you change the header.

There are two dials and they are independent:

DialHeaderWhose schedulePhase 0
Platform versionCapa-Versionours. We publish a new date when we change the APIone date, 2026-10-01
Data contractCapa-Contractyours. It changes when you change your modelsone contract, 1

Your fields moving is your business, so it gets its own dial. Our response envelope moving is ours. Neither one forces the other.

Which version you get

In this order:

  1. The Capa-Version header on the request, when it is a supported date.
  2. The version your key is pinned to.
  3. 2026-10-01, the first version.

Nothing floats. A key with no pin does not silently start receiving a newer shape the day we publish one. That is the whole point of dating: the day we ship a change is not the day your site changes.

A Capa-Version that is not a supported date is refused, never rounded:

{ "error": { "type": "invalid_request", "code": "invalid_version",
             "message": "Capa-Version 2026-13-99 is not a supported version.",
             "param": "Capa-Version",
             "hint": "Supported versions: 2026-10-01.",
             "docs": "https://docs.capacms.com/errors/invalid_version" },
  "meta": { "version": "2026-10-01", "contract": 1, "requestId": "req_0f3c…" } }

Every response tells you which version it was served under, in the Capa-Version response header and in meta.version. That is true of errors too, including the error above: the header carries the newest version, because the request's own value was not one.

Pinning a key

Pin the key rather than setting the header on every call. Then one place decides, and a route you forget to update still gets the version you chose.

curl -X PATCH https://api.capacms.com/v2/tenants/api-keys/<keyId> \
  -H 'Authorization: Bearer <your session token>' \
  -H 'content-type: application/json' \
  -d '{ "apiVersion": "2026-10-01" }'

A scoped key minted with no apiVersion is not left unpinned: it is pinned to the newest version that exists at the moment it is minted, and that pin never moves afterwards. So the day we publish a second date, the keys you already hold keep the date they were minted under, and only a key minted after that day gets the new one.

A key that carries no pin at all resolves to 2026-10-01, the first version. Two kinds of key are in that position: a legacy pk_ or sk_ key, because the legacy mint path sets no pin, and any key that predates pinning.

An apiVersion that is not a registered date is refused at mint and at edit time, so a key can never be pinned to a version that does not exist.

Listing versions

GET /api/versions takes no key. It is the one route you can call from a build script without a credential.

curl https://cdn.capacms.com/api/versions
{ "data": { "current": "2026-10-01",
            "versions": [ { "date": "2026-10-01", "status": "current",
                            "changes": [ { "id": "2026-10-01.initial",
                                           "kind": "behaviour",
                                           "surfaces": ["rest"],
                                           "summary": "First dated version of /api/." } ] } ] },
  "meta": { "version": "2026-10-01", "requestId": "req_0f3c…" } }
statusMeaning
currentthe newest published version
supportedolder, fully supported, no end date
deprecatedwe have announced a successor. It still serves every byte it served
sunsetits end date has passed. Move while it still says deprecated. Only this list reports the status today: no response carries a Deprecation or Sunset header yet

What changed since my version

curl 'https://cdn.capacms.com/api/versions?from=2026-10-01'

?from= returns the versions strictly newer than the one you name, with their changes. Today that is an empty list for the only version there is. Put this in a weekly job and you will hear about a new version from your own tooling rather than from a changelog you forgot to read.

A from that is not a supported date is 400 invalid_version with param: "from".

What a change looks like

FieldMeaning
idstable, <date>.<slug>. Safe to match on
kindresponse (a body moved), request (an input moved), behaviour (neither, but something acts differently)
surfacesrest, graphql, or both
summaryone sentence

Sunset policy

We do not retire a version on our own schedule while you are still calling it.

A version is deprecated when its successor exists and we have told you. It is only given a sunset date deliberately, and only when the traffic on it has been zero for thirty days. A pinned site will not receive a 410 while it is still making requests.

The machinery is not built yet. Today no version is deprecated, no response carries a Deprecation or Sunset header, and no version answers 410. When that lands, a deprecated version will name its end date in a Sunset header before the date, and this page will say so.

Contracts

Capa-Contract names your data-model version. In phase 0 there is exactly one, 1, which is also the default, so you can leave the header off entirely.

Sending anything else, including a malformed value, is refused rather than ignored:

{ "error": { "type": "not_found", "code": "contract_not_found",
             "message": "Contract 2 does not exist.",
             "param": "Capa-Contract",
             "hint": "This project has contract 1 only.",
             "docs": "https://docs.capacms.com/errors/contract_not_found" },
  "meta": { "version": "2026-10-01", "contract": 1, "requestId": "req_0f3c…" } }

Multiple contracts arrive in a later phase, along with the admin screens that start, compare, publish and retire them. Until then, GET /api/me shows legacy.contract, which is the contract the legacy surface serves your key, so you can see that a half-ported site is reading the same shape on both.

Changelog

API changelog is generated from the version registry in the code, so it cannot drift from what GET /api/versions returns. A guardrail test fails the build if it does.