API reference
The dated /api/ read API: what exists today, how a request and a response are shaped, and what an error looks like.
This is the reference for /api/, the dated Capa API. It is written for a
developer wiring another system into Capa: a storefront, an internal tool, a
sync job, an agent.
What is here today
Nine routes exist:
| Route | Key | What it does |
|---|---|---|
GET /api/versions | none | lists the dated versions and their changes |
GET /api/me | yes | tells you what your key can do, on which surfaces |
GET /api/entries/{ns} | yes | a page of entries, with select, filters, sorting and cursors |
GET /api/entries/{ns}/{id} | yes | one entry |
POST /api/graphql | yes | the entry reads as GraphQL: every model your key can read, typed |
GET /api/graphql | yes | the same, cacheable, and the way to send a persisted query |
GET /api/pages | yes | your site's pages, and which entries each one reads |
GET /api/pages/{page} | yes | one page, its entries and its queries |
GET /api/preview | yes | verifies a preview token minted in the Capa admin |
The entry routes are the whole of Entries, the three page
routes are in Pages, and GraphQL is in GraphQL.
Schema, types, search, media, nested collections and writes are not built yet.
A GET that matches no route answers 404 route_not_found. A POST, PUT, PATCH
or DELETE answers 405 mutations_not_enabled, on every host, whatever the path
and whether or not you send a key. The one exception is POST /api/graphql,
which reads. Both are the error envelope below, so an
integration written today already parses what the write phases will send.
Your existing integration does not change. /v2/api, /v3/api,
/v2/schema, /v2/seo and /files keep serving the bytes they serve today,
with the key they serve them with today. The exceptions are privacy and
tenant-isolation fixes. Those on /v2/api and /v3/api are listed in
What changed in October 2026. /api/ is a second surface you
move to when you want to, route by route. The reference for /v2/api and
/v3/api is Legacy API: /v2/api and /v3/api.
A request
export CAPA_KEY=... # your key, from your secret store. Never in a script.
curl https://cdn.capacms.com/api/me \
-H "x-api-key: $CAPA_KEY" \
-H 'Capa-Version: 2026-10-01'| Header | Required | Meaning |
|---|---|---|
x-api-key | on every keyed route | your key. Both key families are accepted here (see Keys and scopes) |
Capa-Version | no | the dated version to serve. Omitted, your key's pinned version is used, and a key with no pin gets the first version |
Capa-Contract | no | your data-model version. Today the only value is 1, which is also the default. Anything else answers 404 contract_not_found |
Capa-Page | no | on an entry read, the page of your site the read is for. Recorded, never acted on: it changes no byte of the response and is not part of the cache key. A value Capa cannot store is dropped in silence. See Pages |
Capa-Schema | no | on an entry read, the checksum of the schema your code was generated from (CAPA_SCHEMA_CHECKSUM in your generated types). Same rules as Capa-Page: recorded, never acted on, never part of the cache key, dropped in silence when Capa cannot parse it. See Pages |
Capa-Path | no | on an entry read that sends Capa-Page, the concrete path being rendered (/blog/hello). Same rules as Capa-Page. See Entries |
A response
Every successful body has a data key and a meta key, in that order. The
exception is /api/graphql, which answers in GraphQL's own shape: data,
then extensions, with the request id in the X-Request-Id header (see
GraphQL).
This is GET /api/versions?from=2026-10-01, which takes no key:
{ "data": { "current": "2026-10-01", "versions": [] },
"meta": { "version": "2026-10-01", "requestId": "req_0f3c…" } }meta.requestId identifies one request. Quote it when you report a problem. GET /api/versions is cacheable for five minutes, so a cached answer carries the id of the request that filled the cache.
One case skips the envelope: a URL the server cannot parse at all, such as /api/%zz, is refused by the HTTP layer with a plain 400 before any route runs.
Response headers you can rely on:
| Header | Where | Value |
|---|---|---|
Capa-Version | every /api/ response, errors included | the version that was served |
Capa-Contract | every keyed response | 1 today |
Capa-Key-Id | every response your key authenticated | your key's id, never the key itself |
Vary | every keyed response | Origin, x-api-key, Capa-Version, Capa-Contract, and Accept-Encoding on a body over 8 KB, which is compressed for a caller that accepts it |
Vary | GET /api/versions, which takes no key | Origin, Capa-Version, Accept-Encoding: the version list is over 8 KB |
Cache-Control | GET /api/versions, 200 | public, max-age=300 |
Cache-Control | GET /api/entries/..., 200, production key | public, max-age=60 |
Cache-Control | GET /api/me and every error but one | no-store |
Cache-Control | a development key's entries | no-store, no-cache |
Cache-Control | a production key's 404 entry_not_found for a well-formed id | public, max-age=60, so the edge keeps a taken-down entry down (see Entries) |
ETag, Surrogate-Key | GET /api/entries/..., 200 or 304, production key | see Entries |
Origin leads every Vary because CORS reflects the calling origin back in
Access-Control-Allow-Origin. GET /api/versions is the one cacheable
response here, and a cache that ignored Origin on it could hand one site's
Access-Control-Allow-Origin to another for five minutes.
On /api/, the key itself is never echoed in a response header, a log line or
an error body. The legacy /v2/api and /v3/api routes still send it back in
an API-Key response header.
Errors
One envelope, on every /api/ route but /api/graphql, whose errors are
GraphQL errors: an errors list, each with the same code, type, hint
and docs inside its extensions (see GraphQL).
{ "error": { "type": "authentication",
"code": "invalid_key",
"message": "This API key is not valid.",
"docs": "https://docs.capacms.com/errors/invalid_key" },
"meta": { "version": "2026-10-01", "contract": 1, "requestId": "req_0f3c…" } }| Field | Always present | What it is for |
|---|---|---|
error.type | yes | the class of problem, stable per status |
error.code | yes | the thing to branch on in your code |
error.message | yes | one sentence, safe to show a developer |
error.param | no | the header, query parameter or body field at fault |
error.hint | no | what to do next, in words. Read it |
error.docs | yes | the page for this code |
meta | yes | the resolved version, contract and request id |
There is never a data key beside error. A 500 never carries details.
Codes any /api/ route can answer:
| Status | type | code | When |
|---|---|---|---|
| 400 | invalid_request | invalid_version | Capa-Version or ?from= is not a supported date |
| 401 | authentication | missing_key | no x-api-key header |
| 401 | authentication | invalid_key | unknown, revoked, expired, or a display value rather than a secret |
| 402 | payment | subscription_required | the project has no active subscription |
| 403 | permission | origin_refused | the key is bound to origins and this Origin is not one |
| 403 | permission | scope_missing | the key does not hold the scope this route needs |
| 403 | permission | edge_only | the request reached the origin directly instead of through the CDN |
| 404 | not_found | contract_not_found | Capa-Contract is not 1 |
| 404 | not_found | route_not_found | no such route under /api/ on the host you reached |
| 405 | method | mutations_not_enabled | writes are not enabled yet |
| 429 | rate_limited | rate_limit_exceeded | over the per-key limit, answered by the CDN; or more reads at once than the origin runs for one client of a key (3), for one key (4) or for all of a project's keys together (4), GraphQL documents and /api/entries reads together |
| 500 | api_error | internal | our fault. Quote meta.requestId |
| 503 | api_error | service_unavailable | the API was busy with other keys' reads, or had no database connection free in time. Nothing in the request is wrong: retry after Retry-After |
The entry routes add eight more 400 codes for the query grammar,
404 model_not_found, 404 entry_not_found and 504 query_timeout. They are
all in Entries with what each hint tells you.
401 invalid_key is deliberately the same answer for an unknown key, a revoked
key and an expired key. It does not tell an attacker which one it was. If a key
that worked yesterday stops working, check its expiry and its active flag
under Developers > Keys rather than reading the body.
Who am I
GET /api/me answers what the key in your hand can actually do. It is the
fastest way to check a key before you debug anything else.
{ "data": { "keyId": "…00a7", "name": "Shopify sync", "keyPrefix": "cap_live_EXAM",
"environment": "production", "bundle": "scoped",
"surfaces": ["/api/"],
"scopes": ["instance:read", "model:read"],
"models": [ { "namespace": "articles", "id": "…0003", "can": ["read"] } ],
"version": "2026-10-01", "contract": 1,
"legacy": { "contract": null, "legacyRefused": true, "reason": "surface" },
"expiresAt": null, "lastUsedAt": null, "rotatedFrom": null,
"writes": { "enabled": false },
"tenant": { "id": "…d49a1", "name": "Acme" } },
"meta": { "version": "2026-10-01", "contract": 1, "environment": "production",
"requestId": "req_0f3c…" } }| Field | Read it as |
|---|---|
surfaces | the paths this key is accepted on. A cap_ key lists /api/ only |
scopes | what it may do, in the grammar of Keys and scopes |
models | one row per model the key can touch, with the actions it holds there. A key restricted to one model sees one row |
legacy.contract | the data-model version the legacy surface serves this key, or null when the key is refused there |
expiresAt | when the key stops working, or null for never |
lastUsedAt | when the key last authenticated a request, on any surface, before this one. Recorded at most once a minute, so it can be up to a minute behind. null until the key is first used |
writes.enabled | false for every key, because writes are not built yet |
Every field is always present.
Environments
A key carries an environment. production sees published entries only.
Anything else also sees drafts.
It is a read-visibility dial, not a sandbox. There is one database. A
cap_test_ key that holds instance:publish publishes to your live site. If
you want somewhere safe to break things, duplicate the project.