Versions
How dated versions work, how to pin a key to one, and how to read what changed.
/api/ has no version in its path. It has a date in a header.
Capa-Version: 2026-10-01A 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:
| Dial | Header | Whose schedule | Phase 0 |
|---|---|---|---|
| Platform version | Capa-Version | ours. We publish a new date when we change the API | one date, 2026-10-01 |
| Data contract | Capa-Contract | yours. It changes when you change your models | one 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:
- The
Capa-Versionheader on the request, when it is a supported date. - The version your key is pinned to.
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…" } }status | Meaning |
|---|---|
current | the newest published version |
supported | older, fully supported, no end date |
deprecated | we have announced a successor. It still serves every byte it served |
sunset | its 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
| Field | Meaning |
|---|---|
id | stable, <date>.<slug>. Safe to match on |
kind | response (a body moved), request (an input moved), behaviour (neither, but something acts differently) |
surfaces | rest, graphql, or both |
summary | one 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.