Docs

API reference

The dated /api/ read API: what exists today, how a request and a response are shaped, and what an error looks like.

View as Markdown

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:

RouteKeyWhat it does
GET /api/versionsnonelists the dated versions and their changes
GET /api/meyestells you what your key can do, on which surfaces
GET /api/entries/{ns}yesa page of entries, with select, filters, sorting and cursors
GET /api/entries/{ns}/{id}yesone entry
POST /api/graphqlyesthe entry reads as GraphQL: every model your key can read, typed
GET /api/graphqlyesthe same, cacheable, and the way to send a persisted query
GET /api/pagesyesyour site's pages, and which entries each one reads
GET /api/pages/{page}yesone page, its entries and its queries
GET /api/previewyesverifies 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'
HeaderRequiredMeaning
x-api-keyon every keyed routeyour key. Both key families are accepted here (see Keys and scopes)
Capa-Versionnothe dated version to serve. Omitted, your key's pinned version is used, and a key with no pin gets the first version
Capa-Contractnoyour data-model version. Today the only value is 1, which is also the default. Anything else answers 404 contract_not_found
Capa-Pagenoon 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-Schemanoon 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-Pathnoon 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:

HeaderWhereValue
Capa-Versionevery /api/ response, errors includedthe version that was served
Capa-Contractevery keyed response1 today
Capa-Key-Idevery response your key authenticatedyour key's id, never the key itself
Varyevery keyed responseOrigin, 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
VaryGET /api/versions, which takes no keyOrigin, Capa-Version, Accept-Encoding: the version list is over 8 KB
Cache-ControlGET /api/versions, 200public, max-age=300
Cache-ControlGET /api/entries/..., 200, production keypublic, max-age=60
Cache-ControlGET /api/me and every error but oneno-store
Cache-Controla development key's entriesno-store, no-cache
Cache-Controla production key's 404 entry_not_found for a well-formed idpublic, max-age=60, so the edge keeps a taken-down entry down (see Entries)
ETag, Surrogate-KeyGET /api/entries/..., 200 or 304, production keysee 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…" } }
FieldAlways presentWhat it is for
error.typeyesthe class of problem, stable per status
error.codeyesthe thing to branch on in your code
error.messageyesone sentence, safe to show a developer
error.paramnothe header, query parameter or body field at fault
error.hintnowhat to do next, in words. Read it
error.docsyesthe page for this code
metayesthe 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:

StatustypecodeWhen
400invalid_requestinvalid_versionCapa-Version or ?from= is not a supported date
401authenticationmissing_keyno x-api-key header
401authenticationinvalid_keyunknown, revoked, expired, or a display value rather than a secret
402paymentsubscription_requiredthe project has no active subscription
403permissionorigin_refusedthe key is bound to origins and this Origin is not one
403permissionscope_missingthe key does not hold the scope this route needs
403permissionedge_onlythe request reached the origin directly instead of through the CDN
404not_foundcontract_not_foundCapa-Contract is not 1
404not_foundroute_not_foundno such route under /api/ on the host you reached
405methodmutations_not_enabledwrites are not enabled yet
429rate_limitedrate_limit_exceededover 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
500api_errorinternalour fault. Quote meta.requestId
503api_errorservice_unavailablethe 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…" } }
FieldRead it as
surfacesthe paths this key is accepted on. A cap_ key lists /api/ only
scopeswhat it may do, in the grammar of Keys and scopes
modelsone row per model the key can touch, with the actions it holds there. A key restricted to one model sees one row
legacy.contractthe data-model version the legacy surface serves this key, or null when the key is refused there
expiresAtwhen the key stops working, or null for never
lastUsedAtwhen 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.enabledfalse 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.