# MCP server Source: https://capacms.com/docs/ai/mcp Give Claude Code, Cursor, Codex or any MCP client read access to a Capa project with @capacms/mcp. This page is for giving an AI coding assistant (Claude Code, Codex, Cursor or any MCP client) access to a Capa project, so it can answer "what content is there?" and write the query your page needs, checked against real data. The assistant talks to Capa through the Capa MCP server, a small stdio server that reads with your key, sees exactly what the key sees, and never writes content. ## What it needs The server is the npm package `@capacms/mcp`, and your client starts it with `npx -y @capacms/mcp`. It needs Node 20.3 or later and nothing else: it has no dependencies, so `npx` fetches the package and runs it with nothing else to install. ## Set it up 1. Mint a read key in the Capa admin under **Developers > Keys**. A key that reads one model is enough to work on that model; see [Keys and scopes](https://capacms.com/docs/api/authentication). Mint a **production** key for work on what your site shows, and for any code the assistant writes that a site will ship: it reads published entries only, exactly what visitors see. Mint a **development** key only when the assistant should see unpublished work too, such as checking a draft before it goes live or building a preview. Every answer it gets then says it includes drafts and unpublished changes, and the code it writes still reads with whatever key your site uses. 2. Keep the key in your shell or your secret store, never in a file you commit. 3. Register the server with your client. Claude Code: ```bash claude mcp add capa \ -e CAPA_API_URL=https://cdn.capacms.com \ -e CAPA_KEY="$CAPA_KEY" \ -e CAPA_API_VERSION=2026-10-01 \ -- npx -y @capacms/mcp ``` Cursor, in `~/.cursor/mcp.json`. Use the file in your home folder, not the project's `.cursor/mcp.json`, so the key stays out of the repository: ```json { "mcpServers": { "capa": { "command": "npx", "args": ["-y", "@capacms/mcp"], "env": { "CAPA_API_URL": "https://cdn.capacms.com", "CAPA_KEY": "cap_live_...", "CAPA_API_VERSION": "2026-10-01" } } } } ``` Codex, in `~/.codex/config.toml`: ```toml [mcp_servers.capa] command = "npx" args = ["-y", "@capacms/mcp"] env = { CAPA_API_URL = "https://cdn.capacms.com", CAPA_KEY = "cap_live_...", CAPA_API_VERSION = "2026-10-01" } ``` Any other MCP client takes the same command and the same variables in its own config. `CAPA_API_URL` and `CAPA_KEY` are the names every Capa tool reads (`@capacms/sdk/nextjs`, `capa-codegen` and the SDK code the assistant writes), so one `.env` serves your app and the assistant. `CAPA_BASE_URL` and `CAPA_API_KEY` still work as aliases. `CAPA_API_VERSION` is optional. A legacy key (`pk_`, `sk_`, or an older key with no prefix) also needs `CAPA_TENANT_ID`; a `cap_` key does not. The server needs `CAPA_API_URL` and `CAPA_KEY` to start. Without them it exits at once and names the missing variable, rather than starting with nothing to offer. ## What the assistant can do Five tools do the querying, and their names are final: | Ask it to | Tool it uses | | --------------------------------------------------------------------- | --------------------- | | list the models and fields it can read, or show the GraphQL schema | `capa_graphql_schema` | | show what the content holds: counts, ranges, the values a field takes | `capa_explore_data` | | write a query for a goal, as GraphQL, a REST URL and SDK code | `capa_graphql_build` | | run a GraphQL query | `capa_graphql_query` | | explain an API error from a log or a failed call | `capa_explain_error` | The REST URL an assistant hands you is written exactly as the API writes it in `extensions.capa.rest`, the filter as `where` JSON, so it matches what the GraphQL Explorer shows for the same query. The SDK code comes when the assistant asks for it with `code`, once the query is right, in two forms: `graphql()` from `@capacms/sdk/nextjs` for a Next.js server component (`code: "next"`), and `createClient` from `@capacms/sdk/next` for anywhere else (`code: "node"`); `code: "all"` gives both and the REST read. Both read with a `cap_` key and with the legacy key a site already holds: `pk_`, `sk_`, or an older key with no prefix. For a legacy key the code comes with a note: the SDK warns once about that key, and draft previews need a `cap_` key, minted under Developers > Keys. `capa_explore_data` counts what is stored, the way a filter does: its `missing` count is what `{ null: true }` matches. It also says how many references point at nothing a query returns, and how many values do not fit their field's type, since a query returns those as `null`. Some keys get more tools: * Where the deployment serves pages, a key that reads every model gets `capa_list_pages`, `capa_get_page` and `capa_suggest_queries`, whichever key family it is. A `cap_` key needs the unscoped `instance:read` for them. * A legacy key also gets the older tools for models, content, search, generated types, editor layouts and workspaces. The legacy and page tools are described, with worked examples for the page tools, in the server's [README](https://www.npmjs.com/package/@capacms/mcp). Every tool is marked as reading or writing (MCP tool annotations), so a client can run the reads without asking and ask you before a write. Only two tools write, and neither touches content: `capa_set_model_layout` changes a model's editor layout and `capa_set_workspace` changes a workspace of the admin's rail. Both are offered to legacy keys only, and the API refuses them to a key without the permission: `agent` for a layout, write for a workspace. The five query tools also return their answers as structured content, with an output schema. The server also offers two resources, one resource template and three prompts, each only when the key has every tool it uses: | Resource | What it holds | | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | `capa://graphql/schema.graphql` | the whole GraphQL schema the key can read, as SDL, for a client that attaches a schema as context; the list gives its size | | `capa://graphql/schema/{model}.graphql` | one model's SDL, by namespace or type name, e.g. `capa://graphql/schema/articles.graphql` | | `capa://guide/querying` | the order to call the tools in, the filter and sort grammar, and the limits | The prompts are `explore-content`, `write-query` and `fix-query-error`. ## Tool reference One call each, with what it answered on the sample project (a production key, answers shortened where they show `...`). The assistant picks these itself; the calls show what it can ask for. `capa_graphql_schema` lists what the key reads; with `model`, one model's filters, sorts and a runnable example: ```json {"model":"authors"} {"model":"authors","type":"Authors","name":"Author","graphql":{"list":"authors","single":"author","filter":"AuthorsFilter","sort":"AuthorsSort"},"rest":"/api/entries/authors", "fields":[{"name":"name","type":"String","capa":"string","filter":["eq","ne","in","nin","contains","startsWith","endsWith","exists","null"],"sortable":true},...], "sort":["createdAt_ASC",...,"name_ASC","name_DESC","bio_ASC","bio_DESC"],"example":{"query":"query AuthorsList($first: Int) {...}","variables":{"first":5},...}, "system":"Every model type also has id, model, status, createdAt, updatedAt, publishedAt, _version, _tags, _folder. Filters and sorts take ..."} ``` `capa_explore_data` measures what the content holds: ```json {"model":"articles","field":"featured","sample":0} {"model":"articles","environment":"production","total":13,"scanned":13,"statuses":{"published":13},"counts":"Counts are of what is stored, ...", "fields":[{"field":"featured","type":"true_false","present":13,"empty":0,"missing":0,"true":6,"false":7}],"samples":[]} ``` `capa_graphql_build` writes a query from a goal, runs it once, and hands back GraphQL, the REST request and, with `code`, SDK code: ```json {"model":"articles","fields":["title",{"field":"author","fields":["name"]}],"filter":{"featured":{"eq":true}},"first":1,"run":true,"code":"all"} {"query":"query ArticlesList($first: Int, $filter: ArticlesFilter) {...}","variables":{"first":1,"filter":{"featured":{"eq":true}}},"operationName":"ArticlesList", "rest":"/api/entries/articles?select=title,author(name)&where=%7B%22featured%22:%7B%22eq%22:true%7D%7D&limit=1","sdk":{...},"environment":"production", "result":{"data":{...},"cost":{"depth":3,"rootFields":1,"connections":1,"nodesBound":2,"scans":1,"nodes":2,"fields":12},"budget":{"counted":502,"limit":5000}}} ``` `capa_graphql_query` runs any read: ```json {"query":"{ authors(first: 2, sort: [name_ASC]) { nodes { name } } }"} {"data":{"authors":{"nodes":[{"name":"Ada Vale"},{"name":"Brin Cole"}]}},"cost":{...},"budget":{"counted":502,"limit":5000},"rest":[{"field":"authors","url":"/api/entries/authors?select=name&sort=name&limit=2"}],"environment":"production"} ``` `budget` is what the API counted against its 5,000-entry limit before it read: sorting by a field of the list's own model costs 500, so two names count 502. A read that used a deprecated field also gets `deprecations`, each with its coordinate and the reason. `capa_explain_error` explains an error, or just its code, without calling Capa: ```json {"code":"invalid_cursor"} {"errors":[{"code":"invalid_cursor","meaning":"The after or before cursor is malformed, was minted for another sort, or belongs to another parent entry.", "fix":"Pass a cursor from a page of the same query, unchanged, with the same sort: its endCursor as after, or its startCursor as before.","next":"capa_graphql_query","docs":"https://docs.capacms.com/errors/invalid_cursor"}]} ``` `capa_read_entries` reads over REST, with REST's parameters. It takes the GraphQL tools' place where GraphQL is off, and sits beside them for the models GraphQL leaves out because their type names collide (`capa_graphql_schema` lists those as readable over REST only). The sample project leaves no model out, so this call runs there only where GraphQL is off: ```json {"model":"authors","select":"name","sort":["name"],"limit":1} {"data":[{"id":"00000000-0000-4000-8000-000000000011","model":"authors","status":"published","fields":{"name":"Ada Vale"}}], "page":{"limit":1,"hasNext":true,"next":"c1.eyJ2...","hasPrev":false,"prev":null},"environment":"production","rest":"/api/entries/authors?select=name&sort=name&limit=1"} ``` ## Prompts that work * "What models can you read in Capa, and what does an article look like?" * "Which tags do our articles use, and how many articles have none?" * "Write the query for the blog index: the 10 newest featured articles with their author's name. Give me the SDK code for a Next.js server component." * "This fails with `invalid_cursor`. Why, and what should the code do?" The assistant builds every query with `capa_graphql_build`, which runs it once before handing it over, so the query in the code you get has already worked against your content. The code declares the query as a `#graphql` literal: run `capa-codegen --graphql` and its data and variables are typed. For a Next.js server component it reads through `graphql()` from `@capacms/sdk/nextjs`, tagged with every model the query reads, so a publish refreshes the page. ## What it will not do * Write, publish or delete content. Content is edited in the Capa admin. * See a model the key cannot read. A one-model key gets a schema with only that model, relations to other models show as plain ids, and no answer names a model the key cannot read. * Flood the conversation. Every tool answer has a size budget of 20,000 characters. `capa_graphql_build`, `capa_graphql_query` and `capa_explore_data` take `maxChars` to set another, from 1,000 to 100,000. A cut answer says what was cut and how to ask for less. A cut list can still be paged without skipping an entry. When the answer holds each entry's cursor (`edges { cursor }`, which `capa_graphql_build` selects itself), `endCursor` is moved to the last entry shown. When it does not, `endCursor` comes back `null`, and the cut's `resume` says how to read on: run the query again with `first` set to the number shown. A long text value, such as an article body, is clipped to the room the answer has, and the assistant reads the rest in parts with `capa_graphql_query`'s `slice`. * Pass off a draft as published. Every answer says which key read it (`environment`), and a development key's answers say they include drafts and unpublished changes. A production key reads published entries only, so when it reads one entry by id and finds nothing, the answer says that an entry that exists only as a draft reads as null too. Three answers are never cut, because the assistant edits them and writes them back: a model with its editor layout (`capa_get_model`), the layout `capa_set_model_layout` saved, and a workspace document (`capa_get_workspace`). The schema resource is never cut either. A client attaches it only when you ask it to; the tools read the schema in bounded pieces. ## When something fails Every refusal says what to change for its own error code and names the tool that helps, so an assistant usually recovers on its own. A query over one of the API's budgets gets the API's own message and the change its hint names, for example "articles: could read 40,200 entries, over the limit of 5,000. Pass coauthors(first: 24) to fit, or lower first elsewhere.", and `capa_graphql_build` names the root `first` that fits as well. A call Capa does not answer within 25 seconds fails with a next step too. A server that cannot reach Capa says where it tried and why, for example: ```text Could not reach Capa at http://localhost:6199 for POST /api/graphql (ECONNREFUSED). Next: check that CAPA_API_URL is right and that the API is running and reachable from this machine, then retry. ``` When the assistant is stuck, paste the error into the chat and ask what it means. `capa_explain_error` knows every `/api/` error code and answers without calling Capa. It also tells a failed connection from an API error: a site log's `fetch failed ... ECONNREFUSED` gets the same next step as above, not a code the API never sent. ## If the tool list is short The server decides the tool list once, at startup, from the key and the deployment. When the deployment lacks a feature, or Capa does not answer, it says so on stderr. Most clients show that in their MCP log. * The GraphQL tools go to any key that reads at least one model. `capa_explain_error` goes to every key, so a key with no read scope gets only that one. * A deployment with GraphQL switched off gets no GraphQL tool. The assistant gets `capa_read_entries` instead, which reads the same content over REST (`GET /api/entries/`) for as long as GraphQL is off, and the startup line names it. * A deployment without the page routes gets no page tool. * When Capa does not answer at startup, every tool the key could use is offered anyway, and the first call reports the real error. # Keys and scopes Source: https://capacms.com/docs/api/authentication The two key families, minting a scoped key, every scope, presets, rotation, expiry and allowed origins. There are two families of key, and which one you want depends on which surface you are calling. | Family | Looks like | Works on | Carries scopes | | ------ | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | ----------------------------- | | Legacy | `pk_…` (production), `sk_…` (other environments), or an older key with no prefix | `/v2/api`, `/v3/api`, `/v2/schema`, `/v2/seo`, `/v2/agent/*` and `/api/` | no. One of four fixed presets | | Scoped | `cap_live_…` (production), `cap_test_…` (other environments) | `/api/` only | yes | **Your existing key keeps working, unchanged, everywhere it works today.** Nothing in this page alters a legacy key. It also reaches `/api/`, so you can point a new page at the new surface without minting anything. A key minted before the prefixes existed is 40 hexadecimal characters with no prefix. It is a legacy key like any other, and its environment is the one set on it. Mint a `cap_` key when you want a key that does less than your current one: one model, reads only, no deletes. That is the reason the family exists. ## What a scoped key can do today Read this before you mint one. Today `/api/` reads and never writes. **A `cap_` key holding `instance:read` reads entries**, with `GET /api/entries/` ([Entries](https://capacms.com/docs/api/entries)) and `GET` or `POST /api/graphql` ([GraphQL](https://capacms.com/docs/api/graphql)). `GET /api/me` answers any key, and `GET /api/versions` needs none. There are no writes anywhere on `/api/`: `writes.enabled` in `/api/me` is `false` for every key, and a write method under `/api/` is refused. A `cap_` key is also refused on `/v2/agent`, the legacy write surface, for the reason in the next section. So a write scope you grant today is stored, audited and enforced from the day the route it unlocks exists, and does nothing before then. **Keep your legacy key for every write and for anything that calls `/v2` or `/v3`.** Mint a `cap_` key to read on `/api/`, and move the rest of your traffic onto it route by route as the routes land. Apart from `instance:read`, each "Unlocks" column below describes the route that scope will gate, not a route you can call this week. ## Why a scoped key is refused on the legacy surface A `cap_` key sent to `/v2/api`, `/v3/api`, `/v2/schema`, `/v2/seo` or `/v2/agent/*` is refused with the same `401 {"error":"Invalid API key"}` an unknown key gets. That is on purpose. The legacy read routes do not consult scopes. If they accepted a scoped key they would serve it everything, and a restriction you could see in the admin would be quietly ignored on the surface most of your traffic uses. One enforcement point, or none. So a project that is half ported runs two keys: the legacy key its live site already uses, and a `cap_` key for the new code. `GET /api/me` shows which surfaces the key in your hand is accepted on. ## In the admin Developers > Keys does everything on this page without curl. **New key** opens the create dialog: a name, the environment, a starting point (Read only, Content writer, Full integration, Schema builder) or Custom with the groups of this page as checkboxes, a "Limit to models" picker on Entries and Models, and an expiry. A preset that needs a scope you do not hold yourself is greyed out and says which scope, rather than letting the mint refuse it afterwards. Deletes sit under their own heading. Content deletes (entries, media) are in no preset but Full integration; Schema builder carries `type:delete` and `category:delete`, because a schema it cannot tidy is a schema it cannot change; `model:delete` is in no preset at all. **The secret is shown once**, in a copy field, right where the form was. It is stored hashed, there is no reveal route, and the admin never writes it to a log, a URL or browser storage. If you lose it, rotate. **The list** shows each key's grants as chips, the surface it works on, when it expires and whether it is active. Its Last used column is the key's `lastUsedAt`, which `/api/me` reports too. It is recorded when the key authenticates a request on any surface (`/api/`, `/v2/api`, `/v3/api` and the other keyed routes), at most once a minute per key, so it can be up to a minute behind. A refused request does not count. Never means the key has not been used since this was switched on. A legacy row shows what its preset grants, marked `Legacy`, and its row menu offers **Create cap\_ key with these grants**, which opens the create dialog prefilled from that preset. **Rotate** is in the row menu. Pick how long the current key keeps working (Now, 1 hour, 24 hours, 7 days), and the new secret appears with how long the old one has left: hours inside two days, days beyond that, and "The old key has stopped working" for Now. **Edit** changes the name, the scopes and the expiry. **Deactivate** is how a key is revoked; the row can be reactivated. One thing the dialog will not edit: a key minted here with per-scope model limits inside a single group, which only curl can do. The editor has one "Limit to models" control per group, so that group is shown read-only, listing each scope and the model it is limited to, and its grants are saved back exactly as they were minted. The query explorer under Developers > Queries names the key it is about to run as, and says plainly that a `cap_` key is refused there, because it calls `/v2`. The GraphQL Explorer under Developers > GraphQL calls `/api/` and runs with a `cap_` key. ## Minting a scoped key ```bash curl -X POST https://api.capacms.com/v2/tenants/api-keys \ -H 'Authorization: Bearer ' \ -H 'content-type: application/json' \ -d '{ "environment": "production", "name": "Shopify sync", "scopes": ["instance:read", "model:read", "instance:update:"], "expiresAt": "2027-01-01T00:00:00.000Z", "apiVersion": "2026-10-01" }' ``` ```json { "id": "…00a7", "name": "Shopify sync", "apiKey": "cap_live_EXAMPLE", "keyPrefix": "cap_live_EXAM", "environment": "production", "scopes": ["instance:read", "model:read", "instance:update:…0003"], "version": "2026-10-01", "contract": 1, "expiresAt": "2027-01-01T00:00:00.000Z" } ``` **`apiKey` is shown once.** It is stored as a SHA-256 hash and there is no route that reveals it again. Copy it into your secret store before you close the terminal. If you lose it, rotate the key. | Field | Required | Notes | | ------------- | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | | `environment` | no, defaults to `production` | `production` reads published entries only. Anything else also reads drafts | | `name` | no | up to 120 characters. It is what the keys list shows, so name it after the system that holds it | | `scopes` | to get a `cap_` key | 1 to 100 grant strings. Sending this field is what makes the key a scoped one | | `expiresAt` | no | ISO 8601, at least one hour out. Omit for a key that never expires | | `apiVersion` | no | a supported date. Omit and the key is pinned to the newest version at the moment it is minted; the pin never moves on its own | | `contractId` | no | refused until contracts exist. Omit it | Omit `scopes` entirely and you get today's legacy key with today's body, which is what every existing integration and script already does. ### What you can grant You cannot grant a scope you do not hold yourself. If you are denied `instance:update` on one model, you cannot mint a key that holds `instance:update` on every model; you can mint one restricted to a model you are allowed to write. The check runs once, when the key is minted, and the answer names the scope: ```json { "error": "Cannot grant a scope you do not hold", "scope": "model:create" } ``` ## The scope list A scope is `resource:action`, or `resource:action:` to limit it to one model. It is the same grammar the admin uses for people, so what you read in `/api/me` is what is stored and what appears in the audit log. ### Read Exactly what a legacy `read` key can do, so this group on its own is a like-for-like replacement for one. | Scope | Unlocks | | ---------------- | ---------------------------------- | | `instance:read` | reading entries | | `model:read` | reading models and the schema | | `media:read` | reading media and folders | | `search:read` | search | | `category:read` | reading categories | | `type:read` | reading types | | `workspace:read` | reading workspaces and their nodes | ### Entries | Scope | Unlocks | | ------------------ | -------------------------------------------------------- | | `instance:create` | creating entries, and the create half of upsert | | `instance:update` | editing entries, and scheduled edits | | `instance:publish` | publishing and unpublishing, including scheduled publish | | `instance:delete` | deleting entries, and bulk delete | Delete is never implied by create or update. A key that imports a catalogue nightly does not need it. ### Models | Scope | Unlocks | | --------------- | --------------------------------------------------------------------------------- | | `model:create` | creating a model | | `model:update` | editing a model, its fields and its layout; starting and editing a draft contract | | `model:publish` | publishing a model version, and publishing or deprecating a contract | | `model:delete` | deleting a model, and retiring a contract | Deleting a model empties it, so the route also requires you to type the model's namespace back in the request body. ### Media | Scope | Unlocks | | -------------- | -------------------------------------- | | `media:create` | requesting an upload and finalising it | | `media:update` | editing metadata and folders | | `media:delete` | deleting media | ### Categories, types, workspaces | Scope | Unlocks | | ---------------------------------------------------------- | -------------------------- | | `category:create`, `category:update`, `category:delete` | the categories routes | | `type:create`, `type:update`, `type:delete` | the schema types routes | | `workspace:create`, `workspace:update`, `workspace:delete` | workspaces and their nodes | A key never sets a project's default workspace and never assigns a workspace to a person. Those are account administration, not content, so they are not reachable by any key. ### Webhooks | Scope | Unlocks | | ---------------- | ------------------------------------------------------- | | `webhook:read` | listing endpoints and deliveries | | `webhook:create` | registering an endpoint | | `webhook:update` | editing, pausing, resuming, rotating the signing secret | | `webhook:delete` | removing an endpoint | `webhook:create` is in no preset, and it is worth knowing why: a key that can register an endpoint receives every future publish at a URL of its choosing. Grant it to the one integration that needs it and to nothing else. The signing secret is returned once at create time and never revealed again. ### What no key can ever have Not by omission, by a list in the code with a test behind it: keys, members, invitations, billing, plan and subscription, project settings, integrations, secrets and every reveal, cache purge, search reindex, metrics, saved queries, the legacy job adapter, permission rows. `*:*` and `*:read` are refused too. A key cannot mint a key, widen itself, invite a person, change what you pay, or purge your cache. ### Wildcards `model:*` is accepted on input and stored expanded, as the five model actions. So is `media:*`, `category:*`, `type:*`, `workspace:*`, `webhook:*` and `instance:*`. That matters: an action we add to the grammar next year does not silently appear on a key you minted this year, because the key holds a list, not a pattern. `api_key:*` is refused, because that resource has no grantable action at all. ## Limiting a key to one model Add the model's id as a third segment. ```json "scopes": ["instance:read", "instance:update:8f2c…0003"] ``` That key reads every model and writes exactly one. Two things follow, and both are deliberate: * A scoped grant never satisfies an unscoped request. `instance:update:` does not let the key write model B, and the refusal says so: `hint: "This key needs instance:update, or instance:update: for products"`. * Cross-model reads evaluate per model. A key holding only `instance:read:` sees a one-model project: search drops the others, the schema lists only A, and `GET /api/me` reports exactly that in `models`. There is no surface on which the restriction is printed and then ignored. Use the model **id**, not its namespace. Ids never change; a namespace can be renamed, and a key that silently followed a rename would be a key that silently changed what it could write. ## Presets Starting points, not a separate vocabulary. Each one is a fixed list of the scopes above. | Preset | Scopes | | ---------------- | --------------------------------------------------------------------------------------------------- | | Read only | the Read group | | Content writer | Read, plus `instance:create`, `instance:update`, `instance:publish`, `media:create`, `media:update` | | Full integration | Content writer, plus `instance:delete`, `media:delete`, `category:*`, `workspace:*` | | Schema builder | Read, plus `model:create`, `model:update`, `model:publish`, `type:*`, `category:*` | | Custom | anything in the list above that you hold yourself | Content writer can create, edit and publish entries and upload files. It cannot delete anything and cannot change models. Schema builder can create and change models and publish schema. It cannot delete models and does not touch entries. ## Editing a key ```bash curl -X PATCH https://api.capacms.com/v2/tenants/api-keys/ \ -H 'Authorization: Bearer ' \ -H 'content-type: application/json' \ -d '{ "name": "Shopify sync (EU)", "scopes": ["instance:read"], "expiresAt": null }' ``` Send any of `name`, `scopes`, `expiresAt`, `apiVersion`. `"expiresAt": null` clears an expiry. The response is the key's current state, without the secret. `scopes` on a legacy `pk_` or `sk_` row is refused with `{"error":"Scopes need a cap_ key. Create one."}`. A legacy key is never converted in place, for the reason at the top of this page: it would be a key with scopes printed on it that the legacy surface ignores. ## Rotating a key ```bash curl -X POST https://api.capacms.com/v2/tenants/api-keys//rotate \ -H 'Authorization: Bearer ' \ -H 'content-type: application/json' \ -d '{ "graceHours": 24 }' ``` ```json { "id": "…00b8", "name": "Shopify sync", "apiKey": "cap_live_EXAMPLE", "keyPrefix": "cap_live_EXAM", "environment": "production", "permission": "scoped", "scopes": ["instance:read", "model:read"], "version": "2026-10-01", "contract": 1, "expiresAt": null, "rotatedFrom": { "id": "…00a7", "expiresAt": "2026-10-02T12:00:00.000Z" } } ``` Rotation mints a **second** key with the same name, scopes, environment, allowed origins and version pin, and gives the old one an expiry `graceHours` from now. Both work until then, so you can deploy the new secret without a window in which neither is valid. | | | | ------------ | ---------------------------------------------------------------------------------------------------------------------------- | | `graceHours` | integer, 0 to 168. Default 24. `0` retires the old key immediately | | Family | a `cap_` key rotates into a `cap_` key, a legacy key into a legacy key. Your live site's key stays a key its surface accepts | | Twice | a key can be rotated once. Rotating the same key again is `409` | | After | the old key answers `401` like any other invalid key | Rotate when a secret has leaked, when someone leaves, and on whatever schedule your own policy sets. There is no reveal route, so rotation is also the answer to "I lost it". ## Expiry `expiresAt` is optional and must be at least one hour out. An expired key is refused with the same `401` as an unknown key, on `/api/` and on the legacy surface alike, so an expiry is never an oracle for whether a key existed. Put an expiry in your own calendar too. Developers > Keys marks a key with a dot and a line 14 days out, but nothing emails you and your deploy pipeline would not read the screen in any case. ## Allowed origins A key can be bound to a list of origins. A server-to-server integration sends no `Origin` header and passes; a browser sending a different one is refused with `403 origin_refused`. If you ship a key to a browser, bind it, and use a production `cap_live_` key with the Read group only. Never ship a `cap_test_` key: it is a development key, so anyone who views source reads your drafts and unpublished changes with it. A write-capable key open to every origin is a write-capable key anyone who views source can use. ## Checking a key ```bash 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" ``` Everything above is readable back from that one route: the scopes, the models and actions they resolve to, the surfaces, the version pin, the expiry, and whether the key was rotated from an older one. Before you debug a `403`, read it. See [API reference](https://capacms.com/docs/api) for the full body. # Entries Source: https://capacms.com/docs/api/entries The whole read contract: the entry shape, select, filters, sorting, cursor paging, caching and every error. `GET /api/entries/{namespace}` and `GET /api/entries/{namespace}/{id}` read your content. This page is the whole read contract: the shape of an entry, the grammar for choosing fields and expanding relations, filters, sorting, cursor paging, caching, and every error you can get back with what to do about it. Start with [API reference](https://capacms.com/docs/api) for how a `/api/` request is shaped and [Keys and scopes](https://capacms.com/docs/api/authentication) for how to mint a key. | Route | What it answers | | ---------------------------- | ------------------------------- | | `GET /api/entries/{ns}` | a page of entries, newest first | | `GET /api/entries/{ns}/{id}` | one entry | `{ns}` is the model's namespace, matched exactly. It is not lowercased, and a namespace that does not exist on your project answers `404 model_not_found` with the readable namespaces listed in `hint`. ```bash export CAPA_KEY=... # from your secret store, never in a script curl 'https://cdn.capacms.com/api/entries/articles?select=title,views&limit=2&sort=-views' \ -H "x-api-key: $CAPA_KEY" ``` The routes read `select`, `filter[...]`, `where`, `sort`, `limit`, `count`, `after`, `before` and `shape`, each described below. Any other parameter is `400 invalid_parameter`, so a URL ported half way from `/v2/api` is refused rather than answered with the wrong entries. The hint gives the `/api/` form: `?slug=/` is refused with `?slug=/ is legacy equality: send filter[slug]=/.` A name that starts with `_` or `utm_`, such as a cache buster (`?_=1727400000000`) or a campaign tag copied from a page's own URL, is ignored. ## Telling Capa which page you are rendering Both routes accept an optional `Capa-Page` header naming the page of **your** site the read is for: ```bash curl 'https://cdn.capacms.com/api/entries/articles?limit=3' \ -H "x-api-key: $CAPA_KEY" \ -H 'Capa-Page: /blog/[slug]' ``` Send either the route pattern (`/blog/[slug]`) or the concrete path you are rendering (`/blog/hello`). It must start with `/` and be at most 200 characters. What you get back is [Pages](https://capacms.com/docs/preview/pages): which of your pages read which entries, and therefore what breaks if you unpublish one. Three rules, and each is a promise to your site: * **It is ignored when it is invalid, never a 400.** A proxy that mangles the header, or a typo in a template, must not take your blog down. A value Capa cannot store is dropped and the request is served exactly as if the header had never been sent. * **It never varies the response.** It is not in `Vary`, it is not echoed, and the body, the `ETag` and the `Surrogate-Key` are byte for byte what they would have been without it. Varying on it would multiply every cached object by the number of pages that read it, which is the opposite of what sending it is for. * **It is recorded, not acted on.** Nothing about what you are served changes. Repeating the header with one value is fine, because a proxy that appends a value to a request that already carried it asked for one page and said it twice. Two DIFFERENT values are dropped: Capa does not know which page the read was for, so it records none. `@capacms/sdk/next` sends it for you from the `page` option, and `routeOf(import.meta.url)` in `@capacms/sdk/nextjs` builds the string from a Next route file. ## Telling Capa which schema you built against Both routes also accept an optional `Capa-Schema` header, the checksum of the schema your code was generated from: ```bash curl 'https://cdn.capacms.com/api/entries/articles?limit=3' \ -H "x-api-key: $CAPA_KEY" \ -H 'Capa-Page: /blog/[slug]' \ -H 'Capa-Schema: 7199153b8f2bd4cf' ``` The value is the `checksum` `GET /v2/schema` returns, which `capa-codegen` writes into your generated types as `CAPA_SCHEMA_CHECKSUM`. It must be 8 to 64 lower-case hex characters. The same three rules apply, word for word: **ignored when it is invalid, never a 400**; **it never varies the response**; **it is recorded, not acted on**. A repeated header with one value collapses, and two different values are dropped, for the same reasons as above. It is recorded only on a request that also carries `Capa-Page`, because the stamp is stored on the page read row. What it buys is the `drift` suggestion in [Pages](https://capacms.com/docs/preview/pages): a site that has never been rebuilt keeps issuing perfectly valid requests, so without this header a stale build is invisible. `@capacms/sdk/next` sends it for you from the `schemaChecksum` option. ## Telling Capa which URL you are rendering A read that carries `Capa-Page: /blog/[slug]` may also carry `Capa-Path`, the concrete path being rendered, such as `/blog/hello`. It is what lets Capa list the real URLs an entry appears on, not only the route patterns. It starts with `/`, holds no whitespace, `?` or `#`, and is at most 1,024 characters. The same three rules apply: an invalid value is ignored, it never varies the response, and it is recorded, not acted on. Without `Capa-Page` it is not recorded at all. `@capacms/sdk/next` sends it for you from the `path` option. ## What an entry looks like ```json { "id": "00000000-0000-4000-8000-000000000021", "model": "articles", "status": "published", "createdAt": "2026-07-25T06:03:52.112Z", "updatedAt": "2026-07-25T06:03:52.112Z", "publishedAt": "2026-07-25T06:03:52.112Z", "version": 1, "folder": null, "tags": [], "fields": { "title": "Alpha ships today", "body": "Body of \"Alpha ships today\".", "views": 5, "featured": true, "tags": ["news"], "author": { "id": "00000000-0000-4000-8000-000000000011", "model": "authors" }, "coauthors": { "items": [ { "id": "00000000-0000-4000-8000-000000000012", "model": "authors" } ], "pageInfo": { "hasNext": false, "next": null } } } } ``` The keys are always in that order. The first nine are the system keys, and everything you defined on the model lives under `fields`, so a field you called `id`, `status` or `tags` never collides with a system key. | Key | Type | Meaning | | ------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `id` | uuid | the entry | | `model` | string | the namespace, so a mixed list stays readable | | `status` | `published`, `draft`, `changed` | see [Drafts](#drafts-and-environments) | | `createdAt` | ISO 8601 | when the entry was created | | `updatedAt` | ISO 8601 | when the entry was last saved. A production key reads when the version it is served was saved, so an unpublished draft never moves it (see [Drafts](#drafts-and-environments)) | | `publishedAt` | ISO 8601 or `null` | when the entry was last published, `null` if it never was | | `version` | integer | the version number of the row you are reading | | `folder` | uuid or `null` | the entry's folder | | `tags` | array of strings | the entry's own tags, not a field. A production key reads the tags the entry had when it was last published (see [Drafts](#drafts-and-environments)) | | `fields` | object | your fields, in the order you laid them out on the model | Inside `fields`: | Field type | Renders as | | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | string, markdown, html, code, enum, color | the stored string | | number | a JSON number | | true_false | `true` or `false` | | date | an ISO 8601 string | | array | a JSON array | | array of image, video or file | a JSON array of `{ "id", "url", "alt", "type", "width", "height" }`, one per file | | relation (single) | `{ "id": "...", "model": "authors" }`, or the whole entry when expanded | | relation (array) | `{ "items": [...], "pageInfo": { "hasNext": false, "next": null } }` | | relation into a model this key may not read | `{ "id": "...", "model": null }`, never expanded, see [Per-model keys](#per-model-keys) | | image, video, file | `{ "id", "url", "alt", "type", "width", "height" }`. `type` is the kind of file the upload recorded: `image`, `video`, `audio`, `document`, `pdf`, `file` or `unknown`. Any other stored value is printed as it is, where GraphQL reads `null`. `width` and `height` are the pixel size the upload measured, `null` for a file it did not measure. An empty `alt` is filled from the file's own alt text | A field you never filled is `null`. A relation pointing at an entry that was deleted, or that a production key cannot see, renders as `{ "id": "...", "model": "authors", "missing": true }` rather than vanishing, so a broken link in your content is visible instead of silent. A list wraps entries in `data` and adds `page`: ```json { "data": [ /* entries */ ], "page": { "limit": 2, "hasNext": true, "next": "c1.eyJ2…", "hasPrev": false, "prev": null }, "meta": { "version": "2026-10-01", "contract": 1, "environment": "production", "requestId": "req_0f3c…" } } ``` A single entry is `{ "data": { /* entry */ }, "meta": { … } }` with no `page`. ## Choosing fields: `select` With no `select` you get every system key and every field, with relations as references. That is the right default for a first call and the wrong one for a page that needs three fields out of forty. ``` select := item ("," item)* item := name | "*" | name "(" arg ("," arg)* ")" arg := item | "*" | "limit:" 1..200 | "sort:" ["-"] name | "after:" cursor name := bare | quoted | "$" system-key bare := a field namespace or a system key with none of , ( ) : . " * [ ] or whitespace in it, and not starting with $ or - quoted := '"' the field namespace, with each " inside doubled '"' ``` Whitespace is not allowed outside a quoted name. `,` separates items, `(` and `)` wrap the arguments of a relation, and `:` separates a modifier from its value. `limit:`, `sort:` and `after:` belong inside a relation's parentheses; at the root they are `400 invalid_select`, and the list takes the `limit`, `sort` and `after` parameters instead. **Any field name.** A field's name is whatever was saved in the admin, so it can hold characters the grammar uses. Write such a name in double quotes, and double a quote inside it. The same form works in `select`, `sort`, `where` and `filter[...]`, and a quoted name always means your field, never a system key or a relation hop. | Field | You write | | ----------------------------------------- | ----------------------------------------------------------------------------- | | `am/pm_indicator`, `open-time`, `émoji_ñ` | as it is: `select=am/pm_indicator` | | `price.usd` | `select="price.usd"`, `sort=-"price.usd"`, `where={"\"price.usd\"":{"gt":3}}` | | `a,b`, `paren(x)`, `colon:x`, `has space` | `select="a,b","paren(x)","colon:x","has space"` | | `say "hi"` | `select="say ""hi"""` | | `at.place` (a relation) | `select="at.place"(name)`, `where={"\"at.place\".name":{"eq":"Harbour"}}` | In a URL the quote is `%22` and a space `%20`: most HTTP clients encode them for you. A field whose name starts with `$` cannot be named, because `$` starts a system key; `select=*` and a request with no `select` return it. | You write | You get | | ------------------------------------------- | -------------------------------------------------------------------------------------------- | | (nothing) | every system key, every field, relations as references | | `select=title,views` | `id`, `model`, `status` and those two fields | | `select=*` | every system key and every field, as with no `select` | | `select=title,publishedAt,version` | `id`, `model`, `status`, the two system keys you named, and `title` | | `select=tags,$tags` | on a model with its own `tags` field: your field under `fields`, and the entry's tags on top | | `select=title,author` | `author` as `{ id, model }` | | `select=title,author(name,bio)` | `author` expanded to a nested entry with two fields | | `select=title,author(*)` | `author` expanded with every field | | `select=coauthors(name,limit:5,sort:name)` | the first five coauthors by name | | `select=title,author(name),coauthors(name)` | both relations expanded in one read, each with its `name` | Expansions nest, up to 5 levels of entries: on a model of your own whose authors have a `books` relation, `select=author(name,books(title,limit:3))` reads each entry's author with three of that author's books. The sample project's authors have no relations, so every row above reads one level. `id`, `model` and `status` come back whether or not you name them, on the root entry and on every expansion. Other system keys come back only when you name them, or select `*`, which returns every system key at that level. You can name them at any level: `select=title,author(name,publishedAt)` puts `publishedAt` on the expanded author. Wherever they appear they are printed in the fixed key order of [an entry](#what-an-entry-looks-like), not in the order you wrote them. The hint on an `unknown_field` lists the system keys only at the root, because an expansion's hint lists that model's fields and nothing else. **System keys and your fields.** A plain name means your field when the model has a field of that name, and the system key otherwise. The two never collide in the response, because your fields live under `fields` and the system keys do not, but they share one namespace in the query grammar. So a system key also has a name no field can take: `$` and the key, as `$tags`, `$createdAt` or `$id`. It works wherever the plain name does, in `select`, `filter`, `where` and `sort`, and after a hop as `author.$id`. On a model with its own `tags` and `createdAt` fields: | You write | It reads | | -------------------------------- | ---------------------------------- | | `select=tags` | your `tags` field | | `select=$tags` | the entry's own tags | | `sort=-createdAt` | your `createdAt` field | | `sort=-$createdAt` | when the entry was created | | `where={"$tags":{"has":"news"}}` | entries tagged `news` in the admin | On a model without such a field the two names are the same key, and the plain one is shorter. A `$` name that is not a system key is `400 unknown_field`. An expanded single relation is the nested entry itself. An expanded array relation is a slice: ```json "coauthors": { "items": [ { "id": "…0011", "model": "authors", "status": "published", "fields": { "name": "Ada Vale" } } ], "pageInfo": { "limit": 1, "hasNext": true, "next": "c1.eyJ2…" } } ``` `pageInfo.next` is a cursor token. Pass it back inside the parentheses as `after:` to get the next slice of that one relation on that one entry: ``` ?select=coauthors(name,limit:1,after:c1.eyJ2…) ``` The cursor carries the parent entry's id, so replaying it on a different entry answers `400 invalid_cursor`. A relation page is a window of the ids the entry stores. `limit:3` covers three stored ids, and an id whose entry was deleted, or that a production key cannot see, keeps its place in the window as `{ "id", "model", "missing": true }`. So `items` always holds one item per stored id in the window, and `pageInfo.next` always moves past it. An id stored twice appears twice, and a cursor minted on the second one resumes after the second one. Entries change between pages, and a relation cursor holds its place: * With `sort:`, the next page starts after the sort values the cursor carries, wherever its own entry sorts now. Renaming that entry between pages skips nothing and repeats nothing: a rename that moves it later brings it back on a later page. * Without `sort:`, the next page starts after the cursor's id in the list the entry stores now. If that id has been taken out of the list, the cursor is refused with `400 invalid_cursor` and the message `The entry this cursor points at has changed since the page was read.`, rather than starting the list again. Read the relation's first page again. ### Modifiers `limit:`, `sort:` and `after:` are only meaningful on an array relation, and only inside its parentheses. On a single relation they are `400 invalid_select`, because a single relation is one entry and there is nothing to page or order. | Modifier | Range | Default | | -------- | ------------------------------------------------- | ------------------------------------------ | | `limit:` | 1 to 200 | 100, counted as 10 by the node cap (below) | | `sort:` | one field of the target model, `-` for descending | the order the items are stored in | | `after:` | a cursor from that relation's `pageInfo.next` | none | ### The size caps A `select` can ask for more work than is reasonable, so four numbers bound it. | Cap | Value | | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | Depth | 5 levels of entries, the root counted: `author(employer(city(country(name))))` is the deepest, and it reads what legacy `depth=4` reads | | Relation expansions per request | 12 | | Nodes per request | 5,000 | | Top-level `limit` | 200 | Nodes are entries, root and expanded together. The cap is checked twice: before anything reaches the database, by multiplying the limits you asked for, and again after each level is fetched, by counting what actually came back. Either way over is `400 query_too_complex` with nothing half-served. The bound is a product, so it is the combination that costs. `limit=25` with `coauthors(*,limit:200)` bounds at 5,025 and is refused with `This request could return 5,025 entries, over the limit of 5,000.` and a hint that names the limit to change and a value that fits: `coauthors reads up to 200 entries for each of the 25 entries above it. Set limit:199 on coauthors to fit, or lower another limit. At most 5 levels, 12 expansions and 5,000 entries per request.` `limit=24` is 4,824 and passes. An array relation with no `limit:` still returns up to 100 entries, but the bound counts it as 10 for each entry above it, so ordinary pages are never refused for sizes nobody wrote: `select=title,coauthors(name),related(title)` on a page of 25 bounds at 25 × (1 + 10 + 10) = 525, and a list inside a list on one entry at 1 + 10 × 11 = 111. A `limit:` you write is counted as written, `limit:100` included. What comes back is still held to 5,000: a list holding more than it was counted for stops the read at that list, answering `This request read at least 5,010 entries at coauthors, over the limit of 5,000.` with a hint to set a smaller `limit:` on it. Give a `limit:` to any list that can grow long. Some reads cost more than the entries they return, and are charged 500 entries each on the same 5,000: * each `contains`, `startsWith`, `endsWith` or `ne` condition on one of the model's own fields, which reads and matches every entry's stored value; * each relation list sorted with `sort:`, which reads every entry the list references, for every entry above it, to know which come first. A request over the cap with them is refused with both parts stated: `This request costs 5,300 entries, over the limit of 5,000: 800 it could return, plus 9 scans at 500 each.` An `or` may also hold at most 3 such text conditions, nested ones counted, since an entry that matches none of them is read against every one: `where has an or of 5 contains, startsWith, endsWith or ne conditions, over the limit of 3.` An `and` stops at its first condition that fails, so it has no such limit. A `limit` or `limit:` out of range states the range: `limit is "abc", which is not a whole number from 1 to 200.` The URL and the request's headers together may be up to Node's default of 16 KB, as on the legacy API. Past that the HTTP server answers a bare `431` before the API reads the request, with a body of its own rather than this envelope. A `select` naming a few thousand fields is past it: ask for fewer fields in one request, or split the read in two. Through the CDN the practical limit is lower. The edge refuses a URL over 8 KB with its own `414 URI Too Long`, which is not this envelope and never reaches Capa. Keep a CDN-served read under 8,192 bytes, the same bound GraphQL GET has; a longer selection belongs in `POST /api/graphql`. Inline expansion has no filter. A sub-collection you want to filter is a separate request against the parent's id. ## Each related entry once: `shape=flat` An expansion is nested where you selected it, so twenty articles by one author carry that author twenty times. Add `shape=flat` and each expanded entry comes back once. On the sample project, Brin Cole writes the first article and co-writes the second: ```bash curl -s 'https://cdn.capacms.com/api/entries/articles?select=title,author(name),coauthors(name)&limit=2&shape=flat' \ -H "x-api-key: $CAPA_KEY" ``` ```json { "data": [ { "id": "…2e", "model": "articles", "status": "published", "fields": { "title": "Xi marks the spot", "author": { "id": "…12", "model": "authors" }, "coauthors": { "items": [ { "id": "…11", "model": "authors" }, { "id": "…13", "model": "authors" } ], "pageInfo": { "limit": 100, "hasNext": false, "next": null } } } }, { "id": "…2c", "model": "articles", "status": "published", "fields": { "title": "Mu on measurable goals", "author": { "id": "…13", "model": "authors" }, "coauthors": { "items": [ { "id": "…12", "model": "authors" } ], "pageInfo": { "limit": 100, "hasNext": false, "next": null } } } } ], "included": { "authors": { "00000000-0000-4000-8000-000000000012": { "id": "…12", "model": "authors", "status": "published", "fields": { "name": "Brin Cole" } }, "00000000-0000-4000-8000-000000000011": { "id": "…11", "model": "authors", "status": "published", "fields": { "name": "Ada Vale" } }, "00000000-0000-4000-8000-000000000013": { "id": "…13", "model": "authors", "status": "published", "fields": { "name": "Cody Marsh" } } } }, "page": { "limit": 2, "hasNext": true, "next": "c1.eyJ2Ijoi…", "hasPrev": false, "prev": null }, "meta": { "version": "2026-10-01", "contract": 1, "environment": "production", "requestId": "req_…" } } ``` Brin Cole and Cody Marsh are each reached twice, once as an author and once as a coauthor, and each is in `included` once. * Every relation is a `{ id, model }` reference, expanded or not. An array relation keeps `{ items, pageInfo }`, with references in `items`. * `included` holds every expanded entry once, by model and then id, nested expansions too. It is `{}` when nothing was expanded. * An entry that is already in `data` is not repeated in `included`: look it up in `data`. It carries any extra fields a relation path asked of it. * An entry reached by two paths carries the fields both asked for. * A reference stands for one rendering of the entry. When two paths render the same entry's relations differently (one pages `related` with `limit:1`, the other with `limit:2`, or one finds a relation missing), the path that disagrees with the entry's first rendering carries the entry in place instead: the whole entry, nested below it exactly as `shape=tree` renders it there. An entry every path renders the same way is still carried once. * `inflate` returns the `shape=tree` body exactly, for every `select`. * `page`, cursors and `total` are the same as the default shape. * `shape=tree` is the default. Anything other than `tree` or `flat` is `400 invalid_parameter`. The two shapes are cached separately and have different `ETag`s. Their `Surrogate-Key`s are the same, so a publish purges both. `inflate` in `@capacms/sdk/next` turns a flat response back into the nested one. ## Filtering ``` filter[]= equality filter[][]= an explicit operator where= boolean logic ``` `` is one of: | Path | Example | | -------------------------------------- | ------------------------------------- | | a field namespace | `filter[views][gt]=10` | | a system key | `filter[publishedAt][gte]=2026-01-01` | | a relation and one field of its target | `filter[author.name][eq]=Ada Vale` | | a media field's id | `filter[cover.id][eq]=` | The system keys you can filter on are `id`, `createdAt`, `updatedAt`, `publishedAt` and `tags`. Where a field of yours has the same name, write the system key as `$tags` (see [system keys and your fields](#system-keys-and-your-fields)). Across a hop the reach is narrower: the target's own fields, plus its `id`. `filter[author.createdAt][gte]=...` and the target's other system keys are `400 unknown_field`, with a hint saying that only `author.id` and the target's fields can be filtered. The hop is a semi-join and only the target's id is carried across it. Which operators a path takes depends on its type: | Type | Operators | | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | string, markdown, html, code, enum, color | `eq` `ne` `in` `nin` `contains` `startsWith` `endsWith` `exists` `null` | | number | `eq` `ne` `in` `nin` `lt` `lte` `gt` `gte` `exists` `null` | | true_false | `eq` `ne` `exists` `null` | | date, and `createdAt` `updatedAt` `publishedAt` | `eq` `ne` `lt` `lte` `gt` `gte` `exists` `null` | | array, and the entry's own `tags` | `has` `hasAny` `hasAll` `exists` `null`. The values are the array's items: numbers, `true` or `false`, dates, or strings | | relation, single | `eq` `ne` `in` `nin` `exists` `null`, plus one dotted hop | | relation, array | `has` `hasAny` `hasAll` `exists` `null`, plus one dotted hop | | media | `exists` `null`, and `eq` on `.id` | | `id` | `eq` `ne` `in` `nin` | Anything else is `400 invalid_operator`, and the hint lists what that type does take. `exists` and `null` ask whether a value is stored, and an empty array or blank text is stored. `filter[tags][exists]=true` matches an entry whose `tags` is `[]`, and `filter[tags][null]=true` matches only an entry where `tags` is absent or null. The entry's own tags always hold a list, so on `$tags`, `exists` means at least one tag and `null` means none. `in`, `nin`, `hasAny` and `hasAll` take a comma-separated list, up to 200 values: ``` ?filter[id][in]=00000000-0000-4000-8000-000000000021,00000000-0000-4000-8000-000000000022 ?filter[tags][hasAny]=news,tech ``` `contains`, `startsWith` and `endsWith` match literally and ignore case: `filter[title][contains]=kit` matches `Kitchen` and `KIT`. `%` and `_` in your value are characters, not wildcards. No operator matches part of a value with its case yet; `eq` compares the whole value, case included. Three things follow from how SQL compares nulls and are worth knowing before they surprise you: * `ne` and `nin` exclude rows whose value is missing. An entry with no `views` is not returned by `filter[views][ne]=5`. Ask for the missing ones with `filter[views][null]=true`, or put both sides in one `where` under `or`. * A stored value of the wrong type compares as missing rather than raising. An entry whose `views` holds the string `"ten"` is excluded from every numeric comparison instead of turning the request into a 500. * `not` keeps a missing value missing. `where={"not":{"tags":{"has":"news"}}}` does not return an entry with no `tags`, or with `tags` that is not an array, and the same holds for `eq` on a single relation. Ask for those with `null`, under `or`. Values are checked before they reach the database. A number field wants a finite number, a `true_false` field or an array of them wants `true` or `false` (the strings `"true"` and `"false"` are read as the same), a date or an array of dates wants ISO 8601, an id wants a UUID. A `null` value, and an operator object that names no operator (`{"views":{}}`), are refused rather than read as "no condition": ask for a missing value with `null`, and leave the key out for no condition. A date is written `YYYY-MM-DD`, with an optional time (`THH:MM`, then optional seconds with up to six fraction digits) and zone (`Z` or an offset up to `±14:59`). A date or time without a zone (`2026-07-01`, `2026-07-01T09:30`) is read as UTC, whatever zone the server runs in. Dates compare as instants to the microsecond, so `2023-12-31T10:00:00.000001Z` and `2023-12-31T15:30:00.000001+05:30` are the same value and `2023-12-31T10:00:00Z` is earlier than both. That holds where an offset carries the instant out of the years 1 to 9999: `9999-12-31T23:59:59.999-14:00` is `10000-01-01T13:59:59.999Z`, later than every instant in 9999, and `0001-01-01T00:00:00+14:00` falls in 1 BC. The items of an array of dates compare the same way, so `has` with `2024-01-01T00:00:00Z` matches an item saved as `2024-01-01`. Anything else is `400 invalid_filter_value` with the expected form in the hint. ### Boolean logic: `where` `filter[...]` is a list of conditions and they are ANDed. For `or` and `not`, send a JSON object as `where`: ``` ?where={"and":[{"views":{"gt":10}},{"or":[{"tags":{"has":"news"}},{"featured":{"eq":true}}]}]} ``` The object's keys are `and` (an array), `or` (an array), `not` (an object), or a path whose value is `{"": value}` or a bare scalar for equality. It is capped at 8 levels deep and 50 leaf conditions. `filter[...]` and `where` in the same request are ANDed together. Remember to URL-encode it. `where` that is not JSON, or is JSON but not an object, is `400 invalid_filter_value` on `param: "where"`. So is a part of it of the wrong shape, and the message names that part: `where.not is null.`, `where.or is not an array.`, `where.and[1] is not a JSON object.` ## Sorting ``` ?sort=-views,title,author.name ``` Up to three keys, comma-separated, `-` for descending. One of them may be a single relation hop. Nulls sort last in both directions, and `id asc` is always appended as the final tiebreak, so a page boundary never lands in the middle of a tie and shows you the same entry twice. Sortable: string, markdown, html, code, enum, color, number, true_false and date fields, plus `createdAt`, `updatedAt`, `publishedAt` and `id`. Anything else, an array field for instance, is `400 invalid_operator` on `param: "sort"`. With no `sort`, entries come back `createdAt` descending, newest first. What a sort costs. The default order (`-createdAt`) is read from an index, so every page, however deep, costs about the same. Any other sort orders the whole model on every page, and a sort on one of your fields, or through a relation, also computes that value for every entry, because no index holds it: the cost grows with the model's size, not the page's. On a model of 20,000 entries that is about 20 ms a page; filter first to narrow the set when you can. Text sorts use a language-aware collation, the same one the existing sorted list endpoints use, so accented characters land where a reader expects rather than where their byte value would put them. ## Paging | Parameter | Values | Default | | --------- | ----------------------------------- | ------- | | `limit` | 1 to 200 | 25 | | `after` | a cursor from `page.next` | none | | `before` | a cursor from `page.prev`, or `end` | none | | `count` | `true` or `false` | `false` | There are no page numbers. Walk the list by following `page.next` until `hasNext` is `false`: ```bash url='https://cdn.capacms.com/api/entries/articles?limit=50&sort=title' while [ -n "$url" ]; do body=$(curl -s "$url" -H "x-api-key: $CAPA_KEY") echo "$body" | jq -c '.data[]' next=$(echo "$body" | jq -r '.page.next // empty') url=$([ -n "$next" ] && echo "https://cdn.capacms.com/api/entries/articles?limit=50&sort=title&after=$next") done ``` `page.hasPrev` and `page.prev` are set on any page you reached with `after`, so you can walk backwards; `before` is the mirror image and sets `hasNext` and `next`. `before=end` reads the last `limit` entries of the list, in list order: the page a "load older" view starts from. Nothing follows it, so `hasNext` is `false`; `hasPrev` and `prev` say whether entries come before it, and `prev` walks back from there. `end` is not a cursor, and `after=end` is `400 invalid_cursor`. A cursor is an opaque signed token, `c1..`. Do not build one or parse one. It records the sort you were using and the platform version it was minted on, and it is signed, so these answer `400 invalid_cursor`: | What happened | The message | | ----------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | | the token was edited or truncated | This cursor is not valid. | | you changed `sort` between pages | This cursor was issued for a different sort. | | the cursor was minted on a different platform version | This cursor was issued for version …; this request runs on …. | | a relation cursor was replayed on another entry | This cursor belongs to another entry. | | the page was sorted by a text value over 128 bytes, and that entry has changed or is gone since | The entry this cursor points at has changed since the page was read. | | a relation page in stored order, and the cursor's id has been taken out of the list since | The entry this cursor points at has changed since the page was read. | In every case the hint is the same: request the first page again without a cursor. Sending `after` and `before` together is also `invalid_cursor`, with the message `This request sent after and before.` and the hint to send one of them. ### Totals `count=true` adds `page.total`. It is off by default because counting costs a second pass over the same rows and most listings do not need it. One case costs nothing extra: when `where` or `sort` names your model's own fields and the first page holds every match (no cursor, fewer matches than `limit`), the total is the page's length and no second pass runs. When the filter crosses a relation, the total is answered through a probe that stops at 50,001 rows. Past that you get `400 count_unavailable`, with the hint to drop the relation filter or drop `count=true`. Counting without a relation filter has no such ceiling. ## Reading one entry ``` GET /api/entries/articles/00000000-0000-4000-8000-000000000021?select=title,author(name) ``` `select` works exactly as it does on a list. Everything else does not: `limit`, `after`, `before`, `count`, `sort`, `filter` and `where` on a single entry answer `400 invalid_parameter` with the hint that only `select` applies. `404 entry_not_found` covers an id that is not a UUID, an entry in a different model, an entry in a different project, a deleted entry, and, for a production key, an entry that has never been published. They are one answer on purpose: telling them apart would tell a caller from another project which ids exist. ## Drafts and environments A key carries an environment, and the environment decides what you can see. | Key environment | Sees | `status` can be | | --------------- | ------------------------------------------ | ------------------------------- | | `production` | published entries only, published data | `published` | | anything else | every entry, draft data where there is any | `published`, `draft`, `changed` | `changed` means the entry is published and has a newer draft on top. A development key reading it gets the draft data. A production key gets the published data and sees `status: "published"`, because as far as that key is concerned the draft does not exist. The same goes for `updatedAt` and `tags`: a production key reads when the published version was saved, and the tags the entry had when it was last published, in the entry, in a sort and in a filter, so saving a draft changes nothing that key can see. A development key reads the entry's own `updatedAt`, which every save moves, and its current tags. Publishing makes the current tags the published ones. `folder` is the exception, on purpose: it is where the entry is filed in the admin, not part of its content, so moving an entry to another folder applies to every key at once, published or not, and purges the entry's cached responses. This mirrors what `/v2/api` and `/v3/api` already do for the same key. It is a read-visibility dial and not a sandbox: there is one database, and a test key that can publish publishes to your live site. ## Per-model keys A `cap_` key can be restricted to one model. The scope is `instance:read:`, where the third segment is the model's id, not its namespace, because ids never change. A key holding `instance:read` reads every model. A key holding `instance:read:00000000-0000-4000-8000-000000000003` reads that model and gets `403 scope_missing` on every other one, with the hint naming the scope it would need: ```json { "error": { "type": "permission", "code": "scope_missing", "message": "This key cannot do instance:read.", "hint": "This key needs instance:read, or instance:read:00000000-0000-4000-8000-000000000010 for authors.", "docs": "https://docs.capacms.com/errors/scope_missing" }, "meta": { "version": "2026-10-01", "contract": 1, "requestId": "req_0f3c…" } } ``` Both key families reach these routes. A `pk_` key uses the bundle it already has, so a `read` key works here with no change. A `cap_` key uses its scopes. `GET /api/me` lists them, and its `models` array tells you exactly which models the key in your hand can read. An unknown namespace is `404 model_not_found` for every key, checked before the scope. The order is deliberate, and not as an anti-enumeration measure: it is so that a restricted key asking for a model it does not hold is told it cannot read that model rather than that the model is missing, which is the answer that helps whoever is debugging a scope. The `hint` on that 404 lists only the namespaces this key can read. A `403` therefore does confirm that the namespace exists on your project. A restricted key can tell a model it may not read from one that is not there. It cannot read a byte of either. **What the restriction covers.** It covers rows, not only routes. The schema a restricted key is read against holds only the models it can read, so a relation pointing at a model it cannot read is not a path it can travel: | You write | You get | | ---------------------------------- | ------------------------------------------------------------------ | | `select=author(name)` | `400 invalid_select`, `author does not point at a readable model.` | | `filter[author.name][eq]=Ada Vale` | `400 unknown_field`, same message | | `filter[author.id][eq]=...` | `400 unknown_field`, same message | | `sort=author.name` | `400 unknown_field`, same message | | `select=title,author` | `200`, and `author` is `{ "id": "...", "model": null }` | The reference keeps its id, because the id is on the article and the key is allowed to read the article. What it loses is the namespace, which is the target model's, and every way of turning that id into content. An array relation into an unreadable model is the same: each item keeps its id and carries `model: null`. So a per-model key is a wall around the rows, not only a lock on the routes. The one thing it still tells you about a model you cannot read is that it exists, through the `403` above. ## Caching A production key gets cacheable responses. A development key never does, because a draft has no business in a shared cache. | Header | Production key | Development key | | ---------------------------------------------- | ---------------------------------------------------------------------- | -------------------- | | `Cache-Control` | `public, max-age=60` | `no-store, no-cache` | | `Surrogate-Control` | the configured edge lifetime | `no-store` | | `Surrogate-Key` | `t:… c:… k:… m:… e:…` | absent | | `ETag` | a strong tag over `data` and `page`, and `included` on a flat response | absent | | `Cloudflare-CDN-Cache-Control` | `no-store` | `no-store` | | `Vary` | `Origin, x-api-key, Capa-Version, Capa-Contract` | same | | `Capa-Version`, `Capa-Contract`, `Capa-Key-Id` | always | always | Every 4xx is `Cache-Control: no-store` with no `ETag` and no `Surrogate-Key`, with one exception. A production key's `404 entry_not_found` for a well-formed id is `public, max-age=60` with the configured `Surrogate-Control` and the keys `t:… c:… k:… m: e:`, and no `ETag`. When an entry is taken down, the edge fetches the 404 in place of the cached body, and the publish that brings the entry back purges the 404. A development key's 404 is `no-store`. ### Revalidation Send the `ETag` back as `If-None-Match` and an unchanged response is a `304` with an empty body and the same headers: ```bash curl -sD - -o /dev/null 'https://cdn.capacms.com/api/entries/articles?limit=2' \ -H "x-api-key: $CAPA_KEY" -H 'If-None-Match: "3f9c1a…"' ``` The tag hashes `data` and `page`. It deliberately does not hash `meta`, because `meta.requestId` is different on every request and a tag that included it would never match. ### Surrogate keys Each production response carries the tags the edge purges it by. Publishing an entry, or a save that moves it to another folder, purges its `e:` key and its model's `m:` key, so every page that showed the entry and every list of its model are fetched fresh, including a list whose order, `total` or next page the change moved. Unpublishing or deleting an entry purges the same keys hard, so the edge drops the old bodies at once rather than serving them while it fetches again; deleting a model purges its `m:` key hard, and deleting a project its `t:` key. | Key | One per | | ------------------------- | --------------------------------------------------------------------------------------------------- | | `t:` | response | | `c::` | response | | `k:` | response | | `m:` | the root model, every expanded relation's model, and every model a filter or sort hops into | | `e:` | entry in `data`, and every expanded entry | | `f:` | file a media value renders; editing the file's alt text purges it, deleting the file purges it hard | The header is capped at 12 KB. A response with more entries than fit keeps the `t:`, `c:`, `k:`, `m:` and `f:` keys, drops the `e:` keys and sets `Capa-Cache-Scope: model`. If the `f:` keys still do not fit, they give way to `f:`, which every file purge of the project also sends. A purge is then broader than it needed to be, never narrower, so nothing goes stale. Deleting or deactivating a key, rotating it, narrowing its scopes or its allowed origins, or setting or bringing forward its expiry purges its `k:` key. A key with an expiry also caps `Surrogate-Control`: the edge lifetime, stale windows included, ends when the key does. ### Rate limits Nothing limits how many reads a key makes per minute. What is limited is how many reads run at once, for a key and for a project. Over those shares is `429 rate_limit_exceeded` with `Retry-After` in seconds. See the `429` row under [Errors](#errors). No response carries an `X-RateLimit-*` header. ## Coming from `/v2/api` or `/v3/api` Your existing integration does not change. When you port a page, this is the translation. `articles` and its `author` and `coauthors` are the sample project's models, so those rows run as written against it. The `employer`, `city`, `country` and `gallery` fields stand for relation and media fields of your own models: the sample project has none, so put your own field names in those rows. | Legacy | `/api/` | | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- | | `GET /v2/api/articles` | `GET /api/entries/articles` | | `?depth=1` | `?select=*,author(*),coauthors(*)`, naming each relation field | | `?depth=1&nestedLimit=100` | `?select=*,coauthors(*,limit:100)` | | `?depth=2` | `?select=*,author(*,employer(*)),coauthors(*)` | | `?depth=4`, the deepest legacy read | `?select=*,author(*,employer(*,city(*,country(*))))`: five levels of entries, the deepest `select` reads | | `?limit=50&page=3` | `?limit=50&after=` | | `?sort=-views` | `?sort=-views`, unchanged | | `?views[gt]=10` | `?filter[views][gt]=10` | | `?ids=a,b,c` | `?filter[id][in]=a,b,c` | | `?title[string_contains]=…` | `?filter[title][contains]=…`, or `startsWith` / `endsWith` | | `?limit=500` | two or three pages: `/api/` caps `limit` at 200 | | `?nestedPage=2` | the relation's `pageInfo.next` inside `after:` | | `?structure=relations` (the default) | `?shape=flat`: each related entry once, in `included`, and every field in one place rather than under `data` and again at the top level | ### What reads differently These change what a ported page gets back, or refuse a request legacy answered. Each row gives the legacy call, the `/api/` call that matches it, and what to check. | Legacy | `/api/` | What changes | | ------------------------------------------------------------------------------------ | ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET /v2/api/articles` | `GET /api/entries/articles?sort=id` | **Default order.** Legacy lists entries by `id`, ascending. `/api/` lists them newest first (`createdAt` descending). Pass `sort=id` to keep the legacy order, or `sort: [id_ASC]` in GraphQL | | `GET /v2/api/articles`, where the `gallery` images have no alt text | `GET /api/entries/articles?select=title,gallery` | **`alt` is filled.** Legacy fills an empty `alt` from the file only on a single image or video field. `/api/` fills it on every media value: lists of images, files, and related entries at any depth. A page that renders `alt \|\| title` now shows the file's alt text where it used to show the title | | `GET /v2/api/articles?subtitle=x&sort=-subtitle`, where `articles` has no `subtitle` | `GET /api/entries/articles`, with the name left out | **Field names are strict.** Legacy ignores a filter or sort on a field the model does not have and answers the whole list. `/api/` refuses the same name in `select`, `filter`, `where` or `sort` with `400 unknown_field` and lists the model's fields in `hint`: `?filter[subtitle][eq]=x` is refused. Remove the name, or fix its spelling | | `GET /v2/api/home_pages?slug=/` | `GET /api/entries/home_pages?filter[slug]=/` | **Unknown parameters are refused.** Legacy reads `?slug=/` as equality on `slug`, and `?ids=`, `?depth=`, `?page=` and the others in the table above as its own parameters. `/api/` reads only [its own parameters](#parameters): any other is `400 invalid_parameter`, and the hint gives the `/api/` form, as `?slug=/ is legacy equality: send filter[slug]=/.` or `?page= is legacy paging: pass after= from the page before, since /api/ pages by cursor.` Ignored, the old URL would answer the first page of the whole list, and a ported home page would show another page. A name that starts with `_` or `utm_` is ignored | | `GET /v2/api/menu`, where `menu` has a field named `price.usd` | `GET /api/entries/menu?select=title,"price.usd"` | **Some field names are quoted.** A name that holds `.` `,` `(` `)` `:` `"` `*` `[` `]` or a space is written in double quotes in `select`, `sort`, `where` and `filter[...]`, so it is never read as a relation hop or a list separator. Other names, `am/pm_indicator` among them, are written as they are. A name that starts with `$` cannot be named: `select=*` returns it. See [Any field name](#field-names) | | `GET /v2/api/articles?title[string_contains]=Kit` | `GET /api/entries/articles?filter[title][contains]=Kit` | **Text matching ignores case.** Legacy's `string_contains` matches the case you wrote, so `Kit` misses `kitchen`. `/api/`'s `contains`, `startsWith` and `endsWith` ignore case, so `Kit` matches `Kitchen`, `kitchen` and `KIT`, and a page can list more entries than it did. No `/api/` operator matches part of a value case-sensitively yet. Where case matters, check the value in your own code after the read; `eq` still compares a whole value exactly, case included | | `GET /v2/api/articles?depth=1&nestedLimit=10` | `GET /api/entries/articles?select=*,coauthors(*,limit:10)` | **Nested limits are per list.** Legacy's `nestedLimit` sets every list at once, 100 by default and up to 500. `/api/` has no request-wide setting: each list takes its own `limit:`, 1 to 200, and reads up to 100 without one, which the [node cap](#the-size-caps) counts as 10 before the read. Name the number the page shows on every list: the response stays small, and a list that holds more than it was counted for cannot get the read refused after the fetch | | `GET /v2/api/articles?limit=500&page=3` | `GET /api/entries/articles?limit=200&after=` | **Cursor paging, at most 200 a page.** There are no page numbers and no 500-item pages. Walk the list with `page.next` (`first: 200, after: $endCursor` in GraphQL), so a build that jumped to page 7 now follows cursors and a legacy call above 200 becomes several requests. See [Paging](#paging) | | `GET /v2/api/search?q=kitchen` | none yet | **Search stays on legacy.** `/api/` has no search. Keep calling `/v2/api/search` for it, with the same legacy key, until `/api/` gains one; everything else on the page can move | | `GET /v2/api/articles?depth=0`, where `hero_image` was never set | `GET /api/entries/articles?select=title,hero_image` | **An empty media field is `null`.** Legacy sends an unset image, video or file as an empty file object (`url`, `alt`, `id` and the rest all `""`), which is truthy, and leaves out a field the entry never stored. `/api/` sends `null` for both. A page that tests `if (image)` or `image ? ... : fallback` renders the fallback on `/api/` where legacy rendered an empty image. Test `image?.url` on both | | `GET /v2/api/menu?depth=1`, where `items` lists an entry that was deleted | `GET /api/entries/menu?select=items(title)` | **A dangling reference keeps its slot in REST.** Legacy leaves the id in the list and the entry out of `relations`, so site code skips it. REST sends `{ "id": "...", "model": "...", "missing": true }` in its place, with no `fields`, and GraphQL leaves it out. Skip items with `missing` before reading `fields` | ## Errors Every failure is the envelope from [API reference](https://capacms.com/docs/api), and most carry a `hint`. The hint is the useful part: it names the fields you could have asked for, the operators that type takes, or the scope your key is missing. Five carry none on REST, and say so in the table: `missing_key`, `invalid_key`, `origin_refused`, `mutations_not_enabled` and `internal`. `/api/graphql` adds a hint to the first four. ```json { "error": { "type": "invalid_request", "code": "unknown_field", "message": "articles has no field \"subtitle\" in contract 1.", "param": "select", "hint": "Fields: title, body, views, featured, tags, author, coauthors. System keys: id, model, status, createdAt, updatedAt, publishedAt, version, folder, $tags.", "docs": "https://docs.capacms.com/errors/unknown_field" }, "meta": { "version": "2026-10-01", "contract": 1, "requestId": "req_0f3c…" } } ``` | Status | `type` | `code` | When | What the hint tells you | | ------ | ----------------- | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 400 | `invalid_request` | `invalid_version` | `Capa-Version` is not a supported date | the versions that exist | | 400 | `invalid_request` | `unknown_field` | a name in `select`, `filter`, `where` or `sort` is not a field of that model, or a `filter` or `sort` hop into a model [this key may not read](#per-model-keys) | every field of the model, and the system keys at the root | | 400 | `invalid_request` | `invalid_select` | unbalanced parentheses, an empty item, a duplicate item, parentheses on something that is not a relation, a modifier on a single relation, a malformed `sort:` inside `select`, or an expansion into a model [this key may not read](#per-model-keys) | the grammar, with a worked example; for a name that holds a space or a character the grammar uses, its [quoted form](#field-names) | | 400 | `invalid_request` | `invalid_operator` | an operator the type does not take, or an unsortable field in `sort` | the operators that type does take, or the sortable types | | 400 | `invalid_request` | `invalid_filter_value` | a value of the wrong shape, `where` that is not a JSON object, or a part of `where` of the wrong shape, which the message names (`where.not is null.`) | the expected form: a number, `true` or `false`, an ISO 8601 date, a UUID, a comma-separated list; for `where`, a worked example of the object | | 400 | `invalid_request` | `invalid_cursor` | a tampered, foreign-version, wrong-sort or wrong-parent cursor, or `after` and `before` together | request the first page again without a cursor; for `after` and `before` together, send one of them | | 400 | `invalid_request` | `invalid_parameter` | a query parameter the route does not read, `limit` or `count` malformed, a malformed `sort` or more than three sort keys, a repeated query key, or a paging parameter on a single entry | for a legacy parameter, the `/api/` form; for any other name, the parameters there are; the accepted range, the sort grammar, or that only `select` applies here | | 400 | `invalid_request` | `query_too_complex` | past 5 levels of entries, 12 expansions or 5,000 nodes | for depth, the 5 levels and to read the deeper entries with a second request; for nodes, the list to change and a `limit:` that fits; otherwise the three caps, and to lower a `limit:` or expand fewer relations | | 400 | `invalid_request` | `count_unavailable` | `count=true` with a relation filter over 50,000 rows | drop the relation filter or drop `count=true` | | 504 | `api_error` | `query_timeout` | the read hit the server-side statement timeout (5 seconds by default) | narrow the filter, expand fewer relations or request a smaller `limit`, then retry | | 401 | `authentication` | `missing_key` | no `x-api-key` header | no hint on REST: send the header, with a key from Developers > Keys | | 401 | `authentication` | `invalid_key` | unknown, revoked or expired key | no hint on REST: check the key's expiry and active flag under Developers > Keys | | 402 | `payment` | `subscription_required` | the project has no active subscription | check the plan on Settings > Billing | | 403 | `permission` | `origin_refused` | the key is bound to origins and this `Origin` is not one | no hint on REST; the message names the refused origin. Add it to the key under Developers > Keys, or send the request from a server | | 403 | `permission` | `scope_missing` | the key does not hold `instance:read` for this model | the scope to add, unscoped or for this model | | 403 | `permission` | `edge_only` | the request reached the origin directly instead of through the CDN | send it to the API hostname | | 404 | `not_found` | `model_not_found` | no model with that namespace | the namespaces this key can read | | 404 | `not_found` | `entry_not_found` | no such entry for this key | development keys read drafts, production keys read published entries only | | 404 | `not_found` | `contract_not_found` | `Capa-Contract` is not `1` | the contracts that exist | | 405 | `method` | `mutations_not_enabled` | a POST, PUT, PATCH or DELETE | no hint on REST: writes are not enabled yet | | 429 | `rate_limited` | `rate_limit_exceeded` | one client with 3 reads running with a key and 64 more waiting, the client with the most reads waiting when a key has 4 running and 256 waiting across its clients (a client with fewer waiting takes that client's newest place), a project whose keys together have 4 reads running (`This project has too many reads running at once across its keys.`), or a read that waited out the database budget behind one of those shares. GraphQL documents count toward the same shares, and another client of the key is still served. A client is its address, or its /64 for IPv6 | retry after `Retry-After` | | 500 | `api_error` | `internal` | our fault | no hint: quote `meta.requestId` | | 503 | `api_error` | `service_unavailable` | the API process was full of other projects' reads for the whole database budget, the last slot being kept for a project with none running, or had no database connection free in time | nothing in the request is wrong: retry after `Retry-After` | `401 invalid_key` is the same answer for an unknown key, a revoked key and an expired key, and it does not say which. If a key that worked yesterday stops working, read its row under Developers > Keys rather than the response body. A `500` never carries details. A read that runs longer than the statement timeout is not a `500`: it is `504 query_timeout` from the table above, with a hint, and it is logged against the `requestId` in `meta`. # GraphQL Source: https://capacms.com/docs/api/graphql The same reads as a typed GraphQL schema: names, filters, pagination, limits, cost and persisted queries. Every model your key can read, as a typed GraphQL schema, at one endpoint. ```bash curl https://cdn.capacms.com/api/graphql \ -H "x-api-key: $CAPA_KEY" \ -H 'content-type: application/json' \ -d '{"query":"{ articles(first: 2) { nodes { id title author { name } } pageInfo { hasNextPage endCursor } } }"}' ``` ```json { "data": { "articles": { "nodes": [ { "id": "00000000-0000-4000-8000-00000000002e", "title": "Xi marks the spot", "author": { "name": "Brin Cole" } }, { "id": "00000000-0000-4000-8000-00000000002c", "title": "Mu on measurable goals", "author": { "name": "Cody Marsh" } } ], "pageInfo": { "hasNextPage": true, "endCursor": "c1.eyJ2Ijoi…" } } }, "extensions": { "cost": { "requestedQueryCost": 4, "actualQueryCost": 4, "budget": { "counted": 4, "limit": 5000 } } } } ``` That is the whole idea. The rest of this page is the detail. **GraphQL is REST, typed.** Every root field is translated into a `GET /api/entries` request, planned by the same planner and run by the same runner. Send `"extensions": { "capa": true }` with the query and the answer prints that request in `extensions.capa.rest` (see [`extensions`](#extensions)): ```json "extensions": { "cost": { "requestedQueryCost": 4, "actualQueryCost": 4, "budget": { "counted": 4, "limit": 5000 } }, "capa": { "requestId": "req_0b73…", "version": "2026-10-01", "contract": 1, "environment": "production", "cost": { "depth": 3, "rootFields": 1, "connections": 1, "nodesBound": 4, "scans": 0, "nodes": 4, "fields": 9 }, "rest": [ { "field": "articles", "url": "/api/entries/articles?select=title,author(name)&limit=2" } ] } } ``` The values, the cursors, the drafts a key sees, the limits, the error codes and the cache keys are the REST ones, because they are the same code. Send any `rest` URL with the same `x-api-key` and `Capa-Version` headers and you get the same entries: ```bash curl -G https://cdn.capacms.com/api/entries/articles \ -H "x-api-key: $CAPA_KEY" -H 'Capa-Version: 2026-10-01' \ --data-urlencode 'select=title,author(name)' --data-urlencode 'limit=2' ``` A browser's address bar sends no key, so pasting the URL there answers `401 missing_key`. To try a query before you write code, open Developers > GraphQL in the Capa admin. The Explorer runs it with any of your keys and shows the data, its cost and the REST request beside it. ## From the SDK `@capacms/sdk/next` is Capa's own client. The query above, from a Node script: ```ts import { createClient } from "@capacms/sdk/next"; const capa = createClient({ baseUrl: process.env.CAPA_API_URL!, apiKey: process.env.CAPA_KEY!, version: "2026-10-01", }); type Latest = { articles: { nodes: Array<{ id: string; title: string | null; author: { name: string | null } | null }> }; }; const { data, errors, extensions } = await capa.graphql( `query Latest($first: Int) { articles(first: $first) { nodes { id title author { name } } } }`, { first: 2 }, ); ``` `Latest` is the shape the document selects. To have TypeScript infer it, and check the variables too, write the document as a `#graphql` literal and run `capa-codegen --graphql` once before you compile: ``capa.graphql(`#graphql query Latest(...) { ... }`, { first: 2 })`` is then typed with no type argument. TypeScript refuses a `#graphql` literal codegen has not read yet, so a document never goes untyped by accident (see [Typed documents](https://capacms.com/docs/sdk/graphql#typed-documents)). `capa.graphql` resolves once the API has run the document, with `{ data, errors, extensions }`, each error carrying its `code`, `hint`, `path` and `docs`. It throws `CapaError` when the API refuses the request as a whole: a body with `errors` and no `data`, whatever its HTTP status (see [Errors](#errors)), and any status other than 200, such as 401, 402, 403, 404, 405 or 429. The SDK README covers the rest: | To | Use | SDK README | | ----------------------------------------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------- | | read in a Next.js server component, cached and revalidated by tag | `graphql()` from `@capacms/sdk/nextjs` | [In a Next.js server component](https://capacms.com/docs/sdk/graphql#in-a-nextjs-server-component) | | infer result and variable types from the document, with no casts | `capa-codegen --graphql` | [Typed documents](https://capacms.com/docs/sdk/graphql#typed-documents) | | write the query as an object, typed from what it selects | `capa.graphql.query({ ... })` | [The typed builder](https://capacms.com/docs/sdk/graphql#the-typed-builder) | | send a hash instead of the document, cached at the CDN | `capa persist` at build time, then `{ persisted: true }` | [Persisted queries](https://capacms.com/docs/sdk/cli#persisted-queries) | Any GraphQL client works too. The rest of this page is the HTTP contract every client speaks. ## Endpoints | Request | Notes | | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------- | | `POST https://cdn.capacms.com/api/graphql` | `Content-Type: application/json`, body `{ query, variables, operationName, extensions }`. Never cached. | | `GET https://cdn.capacms.com/api/graphql?query=…&variables=…&operationName=…&extensions=…` | `variables` and `extensions` are JSON strings. Cacheable, see [Caching](#caching). | Both take the same headers as every keyed `/api/` route: `x-api-key`, `Capa-Version` and `Capa-Contract` (see [API reference](https://capacms.com/docs/api)). The key decides everything: which models are in the schema, whether drafts are visible, which version is served when you send no `Capa-Version`. One operation per request. A JSON array body (batching) is refused with `400 invalid_parameter`, `param: "body"`. ## Your schema The schema is built from the models your key can `instance:read`, per request, and cached until one of them changes. Introspection works for every key, and a key restricted to one model sees a schema with one model in it: the other models' types, roots, sorts and filters do not exist, a relation into one of them is typed `ID`, and no description names them. For a sample project with three models, `articles`, `authors` and `snippets`, the query type is: ```graphql type Query { articles(first: Int, after: String, last: Int, before: String, sort: [ArticlesSort!], filter: ArticlesFilter): ArticlesConnection article(id: ID!): Articles authors(first: Int, after: String, last: Int, before: String, sort: [AuthorsSort!], filter: AuthorsFilter): AuthorsConnection author(id: ID!): Authors snippets(first: Int, after: String, last: Int, before: String, sort: [SnippetsSort!], filter: SnippetsFilter): SnippetsConnection snippet(id: ID!): Snippets entry(id: ID!): Entry node(id: ID!): Node nodes(ids: [ID!]!): [Node] version: String! me: KeyInfo! } ``` and a model type is: ```graphql """Article (model articles)""" type Articles implements Node & Entry { id: ID! model: String! status: EntryStatus! # published | draft | changed createdAt: DateTime! updatedAt: DateTime! publishedAt: DateTime _version: Int! _tags: [String!]! _folder: ID title: String # "Capa field title, type string" body: String views: Float featured: Boolean tags: [String] author: Authors coauthors(first: Int, after: String, sort: AuthorsRelationSort): AuthorsRelationConnection! } ``` Every type, field, argument and enum value has a description. A customer field's description leads with the label the admin shows and, for an enum field, the values it takes, so GraphiQL, your editor's hover and generated types read the way your content team named things. For a `products` model with an enum field `stage` labelled "Publishing stage", which the sample project does not have, the field's description reads: ```text Publishing stage. One of: draft, review, live. Capa field stage, type enum ``` The label is written as a sentence, with a capital. A label that only restates the namespace, such as `Title` on `title` or `Sub title` on `sub_title`, is left out, so `title` above is described by its tag alone. A relation into a model your key cannot read shows no label, since the label usually names that model. A relation into a model your key can read but GraphQL leaves out (see [Names](#names)) is typed `ID` as well, and keeps its label and the `, model ` part, so a tool can still say which model the id belongs to, as REST's `model` does. Customer fields are always nullable, so one entry with an empty field never nulls a list. ### Type mapping | Capa field | GraphQL type | Filter input | | ------------------------------------------ | ------------------------------------------------------ | ---------------------------------------------------------------------------------------- | | string, markdown, html, code, enum, color | `String` | `StringFilter` (`eq ne in nin contains startsWith endsWith exists null`) | | number | `Float` | `FloatFilter` (`eq ne in nin lt lte gt gte exists null`) | | true_false | `Boolean` | `BooleanFilter` (`eq ne exists null`) | | date | `DateTime` (ISO 8601) | `DateTimeFilter` (`eq ne lt lte gt gte exists null`) | | array of number | `[Float]` | `FloatListFilter` (`has hasAny hasAll exists null`), numbers | | array of true_false | `[Boolean]` | `BooleanListFilter` (`has hasAny hasAll exists null`), `true` or `false` | | array of date | `[DateTime]` | `DateTimeListFilter` (`has hasAny hasAll exists null`), compared as instants | | any other scalar array | `[String]` | `StringListFilter` (`has hasAny hasAll exists null`) | | relation to a model the key reads | that model's type | `RelationFilter`: `eq ne in nin exists null`, plus one hop into the target's fields | | array of relation to a model the key reads | `RelationConnection!` | `RelationListFilter`: `has hasAny hasAll exists null`, plus one hop | | relation to a model the key cannot read | `ID` or `[ID]` | `RelationIDFilter` / `RelationIDListFilter`, id operators only | | image, video, file | `Media` (`id url alt type width height`), or `[Media]` | `MediaFilter` (`exists null id`); a list of media takes `PresenceFilter` (`exists null`) | | mixed | `JSON` | `JSONFilter` (`exists null`) | | array of mixed | `[JSON]` | `PresenceFilter` (`exists null`) | `Media` is a file stored in Capa: an image, video, audio file, document or PDF. `Media.type` is a `MediaKind`, the kind of file the upload recorded: `image`, `video`, `audio`, `document`, `pdf`, `file` or `unknown`. It is not a MIME type. A stored value of any other kind reads as `null`, where REST prints it as it is. `alt` is the text stored on the field or, when that is empty, the file's own alt text. A list of media has the same six keys per item. `KeyInfo.environment` is a `KeyEnvironment` (`production` or `development`) and each model's `can` a list of `KeyAction` (`read`, `create`, `update`, `publish`, `delete`). A `DateTime` is the value as it was saved. A date field saved as a date alone reads as one (`"2030-12-31"`), which compares as midnight UTC, and one saved with a time reads with it. A client that parses `DateTime` into an instant should accept both forms; filters take both. Enum fields are `String`, not generated enums, so editing an option list is never a breaking schema change. A stored value that does not fit its type (the text `"abc"` in a number field) reads as `null`, with no error, exactly as REST keeps serving that row. ### Names The same models always get the same names, whatever order they were created in, and the Explorer, the SDK and the MCP server read every name from introspection rather than computing their own. | Rule | Examples | | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Type:** the namespace in PascalCase, split on `-` and `_`. A leading digit gets `_`. A name GraphQL already uses, one ending in `Connection`, `Edge`, `Filter`, `Sort`, `Relation` or `RelationList`, or one whose filter type would be a shared filter's name, gets `Model`. | `articles` → `Articles`, `blog_posts` → `BlogPosts`, `blogPosts` → `Blogposts`, `2024_events` → `_2024Events`, `media` → `MediaModel`, `page_info` → `PageInfoModel`, `product_sort` → `ProductSortModel`, `boolean_list` → `BooleanListModel`, `presence` → `PresenceModel` | | **Two models, one name:** both use `Model_`. If those collide too, both are left out and named in the `Query` description. | `blog_post` + `blog_Post` → `Model_blog_post`, `Model_blog_Post`; `blog_post` + `blog-post` → both left out | | **List root:** the PascalCase name before any `Model` suffix, with a lower-case first letter. `entry`, `entries`, `version`, `me`, `search`, `media`, `schema`, `contract`, `node` and `nodes` get `List`. | `articles`, `blogPosts`, `_2024Events`, `mediaList` | | **Single root:** the singular of the last word of the list root, or `ById` when there is no clean singular or it would collide. On a model that holds one entry, `id` is optional (see [A model that holds one entry](#a-model-that-holds-one-entry)). | `articles` → `article`, `categories` → `category`, `addresses` → `address`, `people` → `person`, `series` → `seriesById`, `status` → `statusById`, `faq` → `faqById`; `article` and `articles` both present → `articleById`, `articlesById` | | **Field:** the namespace as written when GraphQL allows it; otherwise every character GraphQL cannot use becomes `_`. A name equal to a system field gets `_field`. Two fields that end up with one name are both left out and listed in the type's description, and so is a field whose name starts with `$`, which REST cannot name either (see [Any field name](https://capacms.com/docs/api/entries#field-names)). | `hero-image` → `hero_image`, `am/pm_indicator` → `am_pm_indicator`, `price.usd` → `price_usd`, `status` → `status_field`, `tags` stays `tags` (the system tags are `_tags`) | Descriptions carry the Capa names in fixed formats, so a tool can map every GraphQL name back to Capa. These strings are a contract and do not change within a version: | Where | Format | | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | A model type | ` (model )`, the namespace standing in for an empty model name. A second line `Not exposed: , ...` lists fields the type leaves out | | A customer field | The label and an enum's options on the first line, when the field has them and the label says more than the namespace, then `Capa field , type [ of ][, model ]` on the last line, the model part only when your key can read the target. The namespace is written as REST writes it: bare, or in double quotes with a quote inside doubled when it holds a character select gives a meaning to (`Capa field "a,b", type string`). Read it with `/(?:^\|\n)Capa field ("(?:[^"]\|"")*"\|[^,\s]+), type (\S+)(?: of (\S+))?(?:, model ([^,\s]+))?$/` and unquote a quoted namespace | | A list root | `Entries of the model. Same as GET /api/entries/.` | | A single root | `One entry of the model by id, or null. Same as GET /api/entries//{id}.`, and on a model that holds one entry, then ` Without id, the model's one entry: GET /api/entries/?limit=1.` | | `Query` | ends with `Models not exposed in GraphQL: , ...` when a model could not be named | ### System fields Every model type has `id`, `model`, `status`, `createdAt`, `updatedAt` and `publishedAt` (the `Entry` interface), plus `_version`, `_tags` and `_folder`. Every model type and `Entry` also implement Relay's `Node`, whose one field is `id` (see [One entry](#one-entry)). A customer field with one of those names is exposed as `_field`, so the two never collide: on a model with its own `tags` field, `tags` is yours and `_tags` is the entry's. REST names the system keys plainly (`tags`, `createdAt`), and a field of the same name takes the plain name there. `$tags`, `$createdAt` and the other system keys with a `$` always mean the system key, in `select`, `filter`, `where` and `sort` (see [Entries](https://capacms.com/docs/api/entries#system-keys-and-your-fields)), and `extensions.capa.rest` uses them wherever a field takes the plain name: `{ scalar(id: "…") { _tags tags } }` prints `?select=$tags,tags`. The system sort values (`createdAt_ASC`) and filter keys (`createdAt`, `_tags`) exist on every model for the same reason: `filter: { _tags: { has: "news" } }` is REST's `$tags` filter, or `tags` on a model with no field of that name. For a production key, `updatedAt` is when the published version was saved, in the value, in a sort and in a filter, so saving a draft changes nothing that key can read. A development key reads the entry's own `updatedAt`, which every save moves. ### The schema as SDL Codegen, an IDE plugin or a linter that wants the schema as a file reads it by introspection, with the key it will run with, since the schema is that key's. In the admin, the Explorer's Copy menu has Download SDL. From a script: ```ts import { writeFileSync } from "node:fs"; import { buildClientSchema, getIntrospectionQuery, printSchema } from "graphql"; const res = await fetch("https://cdn.capacms.com/api/graphql", { method: "POST", headers: { "x-api-key": process.env.CAPA_KEY!, "Capa-Version": "2026-10-01", "content-type": "application/json" }, body: JSON.stringify({ query: getIntrospectionQuery() }), }); const { data } = await res.json(); writeFileSync("schema.graphql", printSchema(buildClientSchema(data))); ``` ## Lists, filters and sorts ```graphql query ArticlesList($first: Int, $filter: ArticlesFilter, $sort: [ArticlesSort!]) { articles(first: $first, filter: $filter, sort: $sort) { totalCount edges { cursor node { id title views author { name } } } pageInfo { hasNextPage endCursor } } } ``` ```json { "first": 10, "filter": { "views": { "gte": 10 }, "author": { "name": { "eq": "Ada Vale" } } }, "sort": ["views_DESC", "title_ASC"] } ``` This is `GET /api/entries/articles?select=title,views,author(name)&where={"views":{"gte":10},"author.name":{"eq":"Ada Vale"}}&sort=-views,title&limit=10&count=true`. * **Filters** are REST's `where`, with typed keys. Keys in one object must all hold; `and`, `or` and `not` combine objects. A relation filter can compare the relation's id (`author: { eq: "…" }`) or hop once into the target's fields (`author: { name: { eq: "Ada Vale" } }`), which becomes REST's `author.name`. `_tags` filters the entry's own tags; `_version` and `_folder` cannot be filtered, on REST either. A `null` value, an operator object with no operator (`views: {}`, or `author: { eq: $id }` with `$id` unset) and an empty `not` are refused with `invalid_filter_value`, as REST refuses the same `where`: match a missing value with `null: true`, and leave a key out for no condition. Only the whole `filter` argument may be absent or null. `exists: true` matches any stored value, and an empty list or blank text is stored; `null: true` matches only a value that is absent or null. `_tags` always holds a list, so there `exists: true` means at least one tag and `null: true` means none. A `DateTimeFilter` compares instants to the microsecond, reads a value with no zone as UTC, and takes a date alone (`2026-10-01`) as midnight UTC. An offset may carry the instant past year 9999 or before year 1: `9999-12-31T23:59:59.999-14:00` is later than every instant in 9999. The accepted form is REST's (see [Entries](https://capacms.com/docs/api/entries#filtering)). * **Sorts** are enum values `_ASC` and `_DESC`, up to three, applied in order, with ties broken by id. The system fields `createdAt`, `updatedAt`, `publishedAt` and `id` come first, then every text, number, true_false and date field, then `__` for one hop into a single relation (`author__name_ASC`). With no sort, the newest entry comes first. * **`totalCount`** is counted only when you select it, because it costs one more query. ## Pagination and cursors Connections are Relay connections: `edges { cursor node }`, `nodes`, and `pageInfo { hasNextPage hasPreviousPage startCursor endCursor }`, paged with Relay's four arguments. To read a list page by page, send each page's `endCursor` back as `after`: ```graphql query Page($after: String) { articles(first: 2, after: $after) { nodes { id title } pageInfo { hasNextPage startCursor endCursor } } } ``` The first request sends no variables: ```json { "data": { "articles": { "nodes": [ { "id": "…002e", "title": "Xi marks the spot" }, { "id": "…002c", "title": "Mu on measurable goals" } ], "pageInfo": { "hasNextPage": true, "startCursor": "c1.eyJ2IjoiMjAyNi0xMC0wMSIs…bdfd4d29a6e2bd45", "endCursor": "c1.eyJ2IjoiMjAyNi0xMC0wMSIs…67fb50b3d6bae5c9" } } } } ``` The next sends that page's `endCursor`, `{ "after": "c1.eyJ2IjoiMjAyNi0xMC0wMSIs…67fb50b3d6bae5c9" }`, and so on while `hasNextPage` is true: ```json { "data": { "articles": { "nodes": [ { "id": "…002b", "title": "Lambda calculus corner" }, { "id": "…002a", "title": "Kappa keeps it simple" } ], "pageInfo": { "hasNextPage": true, "startCursor": "c1.eyJ2IjoiMjAyNi0xMC0wMSIs…c67971c76cda041f", "endCursor": "c1.eyJ2IjoiMjAyNi0xMC0wMSIs…d08805203df15d29" } } } } ``` To go back, send a page's `startCursor` as `before` with `last`: ```graphql query PageBack($before: String!) { articles(last: 2, before: $before) { nodes { id title } pageInfo { hasPreviousPage startCursor } } } ``` From the second page, send its `startCursor`: ```json { "before": "c1.eyJ2IjoiMjAyNi0xMC0wMSIs…c67971c76cda041f" } ``` and the answer is the first page again, with nothing before it: ```json { "data": { "articles": { "nodes": [ { "id": "…002e", "title": "Xi marks the spot" }, { "id": "…002c", "title": "Mu on measurable goals" } ], "pageInfo": { "hasPreviousPage": false, "startCursor": "c1.eyJ2IjoiMjAyNi0xMC0wMSIs…bdfd4d29a6e2bd45" } } } } ``` * Forward: `first`, and `endCursor` as `after`. REST's `limit` and `after`. * Backward: `last`, and `startCursor` as `before`: the `last` entries just before the cursor, in list order. REST's `limit` and `before`, so `hasPreviousPage` says whether more entries come before the page. `before` alone reads 25. * The end of a list: `last` with no `before`, or with `before: null` as Relay's and Apollo's backward pagination send it on their first fetch, reads the last entries of the list, in list order. `hasNextPage` is false, and `hasPreviousPage` says whether more entries come before them. The REST twin is `limit` with `before=end`. Keep paging back with `startCursor` as `before`. * `first` with `before`, and `last` with `first` or with `after`, are refused with `invalid_parameter`. Relay gives them meanings no REST request reads, such as the first entries of the list that come before a cursor, so they are refused rather than answered differently. * `first` and `last` are 1 to 200 on a root (`first` defaults to 25). A nested connection takes `first` (default 100) and `after` only. A nested connection with no `first` still reads up to 100, but the entries budget counts it as 10 for each entry above it (see [Limits](#limits)). * The defaults are stated in each argument's description, not declared as SDL default values, so introspection reports no default. A `first` left out, set to `null` or bound to a variable you did not send is the default; a `first` you write counts as written, `first: 100` included. So a tool that fills in introspection's defaults sends exactly the document you wrote. * **Cursors are REST's cursors.** While `hasNextPage` is true, `endCursor` is the `page.next` REST returns for the same request, and either surface accepts the other's cursor. On the last page `endCursor` still names the last entry, where REST's `page.next` is `null`. * A cursor carries each sort value up to 128 bytes. A longer text value is carried as its digest, and the API reads the entry's full value back when the cursor is used. If that entry has changed or is gone by then, the cursor is refused with `invalid_cursor` and the message `The entry this cursor points at has changed since the page was read.` A nested array relation is a `RelationConnection` with `first`, `after` and one `sort`. It has no filter and no `totalCount`, because REST's inline expansion has neither. Its `sort` is a `RelationSort`: the related entries' own fields and system fields, without the `author__name_ASC` values of `Sort`, because an inline expansion cannot order through a further relation. `after` on a nested connection works on a single entry (`article(id:)`, `entry(id:)`), where the cursor belongs to one parent. A nested page is a window of the ids the parent stores, as on REST. `first: 3` covers three stored ids, and an id whose entry was deleted, or that a production key cannot see, keeps its place in the window with no node. A page can therefore hold fewer nodes than `first`, or none, while `hasNextPage` is true. The answer names each id it left out in [`extensions.missingReferences`](#extensions), as it does for a single relation that answers `null` for the same reason. While there is a next page, `endCursor` points past the last id of the window, so passing it as `after` always moves on, even past a window with no nodes. On the last page it is the last edge's cursor. A nested cursor holds its place when entries change between pages. With `sort`, the next page starts after the sort values the cursor carries, so renaming the cursor's entry skips and repeats nothing. Without `sort`, it starts after the cursor's id in the list the parent stores now. If that id has been taken out of the list, the connection's root field is `null` with `invalid_cursor` and `the entry this cursor points at has changed since the page was read`: read that connection's first page again. ## One entry By its model's single root: ```graphql { article(id: "00000000-0000-4000-8000-000000000023") { title coauthors(first: 1, sort: name_ASC) { nodes { name } } } } ``` Or by id alone, whatever its model: ```graphql { entry(id: "00000000-0000-4000-8000-000000000023") { __typename id ... on Articles { title } } } ``` Each block is one request. A document holds several operations only when every one is named, and then `operationName` picks the one to run. An unnamed operation beside another is refused with `graphql_validation_failed` and the hint `Name every operation, such as query ArticleById { ... }, and send operationName, or send one operation per request.` An id that is not a UUID is refused with `invalid_parameter` and the hint `Entry ids are UUIDs.` A well-formed id that does not exist, is not published (on a production key) or belongs to a model your key cannot read is `null`, with no error, so the answer never tells you which. `entry(id:)` answers the `Entry` interface; `__typename` is the model's type. ### By Relay id: `node` and `nodes` Entry ids are UUIDs, unique across every model, so they are Relay's global ids as they are. `node(id:)` is `entry(id:)` typed as Relay's `Node`, which is what a Relay client asks when it refetches an object: ```graphql { node(id: "00000000-0000-4000-8000-000000000023") { id ... on Articles { title } } } ``` `nodes(ids:)` reads several entries in one root field, whatever their models: ```graphql query Refetch($ids: [ID!]!) { nodes(ids: $ids) { __typename id ... on Articles { title } ... on Authors { name } } } ``` ```json { "ids": ["00000000-0000-4000-8000-000000000023", "00000000-0000-4000-8000-000000000011"] } ``` * The answer is a list in the order the ids were sent, with `null` for an id that is not an entry your key can read, as `entry(id:)` answers `null`. An id sent twice is answered twice and read once. * The field itself is nullable, `[Node]`, like every entry root. When its read fails (the statement timeout, or the document's entry budget), `nodes` is `null` with its error at `path: ["nodes"]`, and the root fields read before it keep their data. * At most 100 ids. More is refused with `invalid_parameter`, `param: "ids"`, and `nodes: ids has 101 ids, over the limit of 100.` * It is one entry root field toward the limit of 10, and costs what reading each of its ids as an `entry(id:)` would: the entries each id brings, in the heaviest model it could be in, summed over the ids. * It runs one query for the models of all the ids, then, for each model that holds any of them, the REST read of that model's entries among them, with only that model's ids and a `limit` of how many there are. `extensions.capa.rest` prints each one, URL-encoded. For the two ids above, one an article and one an author, that is two reads. Decoded, the Articles one is `/api/entries/articles?select=title&where={"id":{"in":["…023"]}}&limit=1`. ### A model that holds one entry A model set to hold a single entry, such as a site's settings, is read without an id: ```graphql { siteSetting { title footer } } ``` `site_settings` here stands for any such model: the sample project above has none, so this example runs only in a project that has one. It is `GET /api/entries/site_settings?limit=1`: the model's one entry, or `null` when there is none the key can read (for a production key, none published). `id` is still accepted, so `siteSetting(id: "…")` reads by id as every single root does. Every other model's single root requires `id`. ## The key and the version ```graphql { version me { keyId environment bundle scopes models { namespace typeName can } } } ``` `me` holds the same values as `GET /api/me`, with each model's GraphQL type name added. `version` is the `Capa-Version` the request ran on. ## Limits Every limit is checked before any SQL runs, and each refusal states the measured value and the limit. | Limit | Value | | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Document size (UTF-8 of `query`) | 32,768 bytes | | Variables (JSON) | 32,768 bytes | | POST body | 65,536 bytes | | GET URL (path and query) | 8,192 bytes | | URL and headers | Node's default of 16 KB together. Past that the HTTP server answers a bare `431` before the API reads the request, on every path, as it always has for REST and the legacy API | | Bracket nesting | 128 levels of `{`, `[` and `(`, checked before the document is parsed | | Filter nesting | 8 levels, as REST's `where`: the filter object, each `and` or `or` list and each `not` add one. A filter in variables is measured before it is coerced | | Depth | 8 levels. The root field is level 1 and each field below it adds one, the leaf included, so `{ articles { nodes { author { name } } } }` is 3 levels deep. `edges`, `node`, `nodes` and `pageInfo` count zero, fragments are followed | | Introspection depth | 20 levels inside `__schema` and `__type` (the standard introspection query fits) | | Entry root fields per operation | 10. Aliases count, and `node` and `nodes` are one each; `version`, `me`, `__typename` and introspection do not | | Introspection per operation | `__schema` once and `__type` 10 times, aliases counted, since each copy is answered in full | | Introspection answers computed | 10 at once for a project, then 1 a second. Only a document that selects `__schema` and is not answered from memory counts: sending the same introspection query again is answered from memory and is free. Past it: `429 rate_limit_exceeded` with `Retry-After: 1` | | Connections per operation | 25. Every list root and every selected array relation, aliases counted | | Entries per operation | 5,000, the sum of every root field's REST bound before SQL, a nested list with no `first` counted as 10 for each entry above it, plus 500 for each scan: each `totalCount`, each filter or sort through a relation, each root field that filters or sorts by its model's own fields, one more for each `contains`, `startsWith`, `endsWith` or `ne` condition past its first, and each relation list sorted with `sort` (see [Cost](#cost)), and the rows actually read after | | Text conditions in one `or` | 3 `contains`, `startsWith`, `endsWith` or `ne` conditions, nested ones counted, since an entry that matches none of them is read against every one. An `and` stops at its first condition that fails, so it has no such limit | | Repeated fields | 10,000 comparison steps. graphql-js checks every pair of fields that share a response name in one selection set, every pair of fragments spread side by side and every fragment against the fields beside it, so a document that selects one field hundreds of times, directly or through fragments, is refused before it is checked | | Fragments | 100 defined in the document, and 50 spread side by side in one selection set | | Selected fields | 1,000 after fragments are expanded, and 1,000 written in the whole document, every operation and fragment counted, since every one is validated | | Entry depth | 5 levels of entries per root field, its own entries counted, so relations nest at most 4 deep below them: what legacy `depth=4` reads. REST's `select` counts the same way (`select=author(employer(city(country(name))))` is its deepest) | | Relation expansions | 12 per root field, as on REST | | `first` | 1 to 200 | | `sort` | up to 3 on a root, 1 on a nested connection | | Database time | 5 seconds by default, for the whole document: every root field draws on the same budget, so ten slow root fields cannot take ten times as long. A client that disconnects stops its document at the next query | | Reads running at once | 4 per project, across all of its keys, GraphQL documents and `/api/entries` reads counted together, and at most 7 for the whole API process, so connections always stay free for key checks and health checks. The last of the 7 is kept for a project with no read running, so two busy projects never hold every slot. One client, by its address, runs at most 3 of its key's 4, so a caller flooding a site's public key never takes the slot its other visitors read with. Up to 64 more per client, and 256 per key, wait for a slot as long as the database budget. A client at its 3 or with 64 waiting, a key at its 4 or with 256 waiting, or a project whose keys have 4 running is told `429 rate_limit_exceeded`; a key held off because the process was full of other projects' reads is told `503 service_unavailable`. Each carries `Retry-After: 1`. A read whose caller hangs up is stopped at once, the statement running included | A budget refusal is `query_too_complex`. Its message states what the document measured and the limit it passed, with thousands separators. Its hint names that one limit and what to change, and links here. | Refused because | Message | Hint | | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | nested too deep | `This document is 12 levels deep, over the limit of 8.` | `Fields may nest 8 levels, where edges, node, nodes and pageInfo count zero. Read the deeper entries with a second query.` | | brackets nested too deep | `This document nests brackets 200 levels deep, over the limit of 128.` | `Brackets may nest 128 deep. Flatten the deepest selection, argument or filter.` | | too many root fields | `This document has 11 entry root fields, over the limit of 10.` | `A document may have 10 entry root fields, aliases counted; version and me are free. Split it into several requests.` | | introspection copied | `This document selects __schema 2 times, over the limit of 1.` | `A document may select __schema once and __type 10 times, aliases counted, since each copy is answered in full. Ask for every type you need inside one __schema.` | | too many entries | `This document could read 6,600 entries, over the limit of 5,000: a 2,200, b 2,200, c 2,200.` The per-root part appears when more than one root field reads entries | `A document may cost 5,000 entries: first, multiplied through every relation list inside it, a list with no first counted as 10, plus 500 for each totalCount, each filter or sort through a relation, each root field that filters or sorts by its own fields, one more for each contains, startsWith, endsWith or ne condition past its first, and each relation list sorted with sort. Lower first, drop totalCount, or split the document.` | | counts, filters and sorts | `This document costs 10,010 entries, over the limit of 5,000: 10 it could return, plus 20 scans at 500 each.` | the same | | one root field reads too many | `articles: could read 40,200 entries, over the limit of 5,000.` | the same | | relations nested too deep | `articles: reads related entries 6 levels deep, the root entry counted, over the limit of 5.` | `A root field may read 5 levels of entries, its own counted, so relations nest 4 deep below them. Read the deeper entries with a second query, by id.` | | a list operator with too many values | `articles: id with in was sent 201 values, over the limit of 200.` | `in, nin, hasAny and hasAll may take 200 values each. Split the list across requests.` | | a filter nested too deep | `articles: filter nests at least 9 levels deep, over the limit of 8.` | `A filter may nest 8 levels of and, or and not, with 50 conditions in all. Flatten it.` | | an `or` of too many text searches | `articles: filter has an or of 5 contains, startsWith, endsWith or ne conditions, over the limit of 3.` | `An or may hold 3 contains, startsWith, endsWith or ne conditions, since an entry that matches none of them is read against every one. Search fewer fields at once, or send the rest in another request.` | | a filter in variables nested too deep | `Variable "$f": filter nests 12 levels deep, over the limit of 8.` | the same | | one field repeated too often | `This document repeats fields so often that checking them takes at least 10,001 steps, over the limit of 10,000.` | `Select each field once per selection set, directly or through fragments: every copy is checked against every other.` | | too many fragments | `This document spreads 51 fragments side by side in one selection set, over the limit of 50.` | `A document may define 100 fragments and spread 50 side by side in one selection set. Merge fragments that select the same entries.` | Every hint ends with `See https://docs.capacms.com/api/graphql#limits`. Before any SQL, a nested list you did not give a `first` counts as 10 entries for each entry above it, although it reads up to 100, as REST's `limit:` does. So two plain `articles { nodes { coauthors { nodes { name } } } }` roots are 2 × 25 × (1 + 10) = 550 entries, and a list inside a list on one entry is 1 + 10 × 11 = 111. A `first` you write is counted as written, 100 included. The rows a document actually reads are held to 5,000 as well, across all its root fields: a list holding more than it was counted for stops the read at that list, and that root field is `null` with a `query_too_complex` error naming the list (`b: this request read at least 5,010 entries at coauthors, over the limit of 5,000.`), while the root fields read before it keep their data. Give a `first` to any list that can grow long. An entries refusal names the list to change. Its `path` is the root field, its `locations` point at the list, and its hint starts with a `first` that brings the whole document under 5,000, everything else unchanged, before the budget's own hint above: ``` a.coauthors has no first, so it counts as 10 entries for each of the 200 entries above it. Pass coauthors(first: 2) to fit, or lower first elsewhere. ``` The hint names the list whose `first` can take the most off the total, then a root field's own `first`. When no single `first` fits it says so: select fewer relation lists, drop a `totalCount`, a filter or a sort, or split the document. Distinct aliases cost nothing against the repeated-fields limit: only fields that share a response name are compared, and an ordinary query takes 0 steps. Each fragment visited costs a step whether or not it compares anything, so the standard introspection query takes 8 and twenty fragments spread into one selection about 2,000. A count that stops at the first item past the limit (a fragment spread into itself over and over, a filter nested without end) says `at least`. A document nested past 128 brackets is refused before it is parsed, in the words of the limit its deep part passes: a deep filter as a filter, a deep selection as `This document is at least 130 levels deep, over the limit of 8.`, and anything else as bracket nesting. Should the server still run out of stack on a document or its variables, the answer is `query_too_complex` too, with `This document nests too deep to read.` or `The variables nest too deep to read.`, never a 500. A body, URL or variables over its limit is `invalid_parameter`, and the message states the size: `The request body is 70,000 bytes, over the limit of 65,536.` The API stops reading a body at the limit. When `Content-Length` is over it, the message states that length; when it is not sent, the message says `at least` and the bytes read. Either way the answer closes the connection. A GET whose URL and headers together pass 16 KB never reaches the API: the HTTP server answers `431` with a body of its own, not a GraphQL response. Send a document that long by POST, or as a persisted query. ### Cost Every executed response reports its cost the way Shopify's APIs do, with the figure the limit is checked against beside it. This is `{ articles(first: 100) { nodes { coauthors { nodes { name } } } } }` on the sample project: ```json { "extensions": { "cost": { "requestedQueryCost": 5000, "actualQueryCost": 22, "budget": { "counted": 1100, "limit": 5000 } } } } ``` The unit is entries. * `budget.counted` is the number to watch. It is what the 5,000-entry limit, `budget.limit`, is checked against before any SQL, and a document is refused exactly when `counted` passes `limit`. Here it is 1,100: 100 articles, and each list of coauthors with no `first` counted as 10. * `requestedQueryCost` is the most the document can cost: every entry it could return, each nested list at the most it reads (its `first`, or 100 without one), capped at the 5,000 entries a document may read, plus 500 for each scan, because a scan reads entries the answer does not hold. Here 100 lists of up to 100 coauthors reach the cap, so it cannot tell you how close you are to the limit: this document and one exactly at the limit both request 5,000. * `actualQueryCost` is the entries the document did read, plus 500 for each scan that started, so it is never above `requestedQueryCost`. A root field that runs out of time is charged its whole cost, the entries it could have returned as well as its scans, because the database worked on it for the whole 5 seconds; the root fields after it read nothing and cost nothing. A scan is: * a `totalCount`, which reads every match. When the root field filters or sorts by its own fields and its first page holds every match (no `after` or `before`, and fewer matches than `first`), the count is the page's length, read with the page, so no scan runs for it; * a filter or sort key that goes through a relation, which reads the related entries of every candidate; * a root field that filters or sorts by its model's own fields, which reads the stored fields of every entry it could match, since no index serves them. It counts once however many such conditions the root field has, except that each `contains`, `startsWith`, `endsWith` and `ne` condition counts once of its own: each reads and matches every entry's stored value again, so five of them take five times as long as one. A relation's `eq` (`author: { eq: "…" }`) is served by an index and is free, as are `id`, `createdAt`, `updatedAt`, `publishedAt` and `_tags`; * a relation list sorted with `sort`, which reads every entry the list references, on every entry above it, to know which come first. A list in its stored order reads only the entries it returns, and is free. `__schema` costs one entry for every 100 members of the schema your key sees: its types, their fields and arguments, input fields, enum values and union members. A schema of 152 models and 2,128 fields has 21,951 members, so its `__schema` costs 220. It is in `requestedQueryCost` whenever the document selects `__schema`, and in `actualQueryCost` only when the answer was computed. A document that reads only the schema is kept after its first answer and answered from memory, comments, whitespace and commas aside, and then its `actualQueryCost` is 0. `__type` is free. So `{ articles(first: 10, filter: { featured: { eq: true } }) { nodes { title } } }` costs 510, a search of `title` or `body` with two `contains` in an `or` costs 1,010, and ten root fields that each search long text with `contains` cost 5,010 and are refused before they run. What is held to 5,000 before any SQL counts a nested list with no `first` as 10 entries for each entry above it, not the 100 it may read, so ordinary documents are not refused for sizes nobody wrote (see [Limits](#limits)). That figure is `budget.counted`, for every key: `extensions.capa.cost.nodesBound`, plus 500 for each of `extensions.capa.cost.scans`, plus what `__schema` costs. It is the one a refusal states. So `{ articles(first: 1) { nodes { coauthors { nodes { name } } } } }` passes as 11 entries and requests 101: one article and up to 100 coauthors. There is no per-key cost budget today, so there is no `throttleStatus`; when one exists it will be reported beside these two. Nothing limits how often a key may ask. How many documents it may run at once is held by the limit in the table above. A document refused for reading more than 5,000 entries carries `cost` too: `requestedQueryCost` uncapped, `actualQueryCost` 0 and `budget.counted` over `budget.limit`, counting every root field, a root field refused for another reason included. No other refusal carries `cost`: a bad cursor, filter or argument says nothing about what the document would read. ## Errors ```json { "errors": [ { "message": "articles: first is 500, outside 1 to 200.", "locations": [ { "line": 1, "column": 3 } ], "path": [ "articles" ], "extensions": { "type": "invalid_request", "code": "invalid_parameter", "param": "first", "hint": "first is an integer from 1 to 200.", "docs": "https://docs.capacms.com/errors/invalid_parameter", "requestId": "req_afcb…" } } ] } ``` * **Request errors** stop the document before anything runs: `errors`, and no `data` key, on a `200` unless you ask for another status (see [Status codes](#status-codes)). A validation error carries the `path` of the field it is about, unless it sits in a fragment definition, and a hint for the mistake it names: `title is a field of each entry, so select it inside nodes or edges { node }, such as articles { nodes { title } }.` or `Use filter, not where, such as articles(filter: { title: { eq: "Hello" } }).` * **Field errors** happen after SQL started: HTTP 200, `data` with that root field `null`, and an error with its `path`. Your client library keeps the partial data and the hints. ### Status codes A request error answers `200` by default, as Shopify's APIs do, so every client library hands you its `errors` and their hints. Ask for `application/graphql-response+json` and the same refusal keeps its 4xx: ```bash curl -i https://cdn.capacms.com/api/graphql \ -H "x-api-key: $CAPA_KEY" \ -H 'content-type: application/json' \ -H 'Accept: application/graphql-response+json' \ -d '{"query":"{ articles(first: 500) { nodes { title } } }"}' # HTTP/1.1 400 Bad Request # content-type: application/graphql-response+json; charset=utf-8 ``` | Your `Accept` | A request error answers | Sent as | | ------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------- | ----------------------------------- | | none, `*/*`, or `application/json` ranked first | `200`, with `errors` and no `data` | `application/json` | | `application/graphql-response+json` ranked at least as high as `application/json`, as Apollo Client 4, urql and graphql-http send it | its 4xx from the table below | `application/graphql-response+json` | This is the GraphQL over HTTP rule for each media type. Some answers keep their status whatever you send: * who may ask, and how often: `401`, `402`, `403`, `404`, `405` and `429`; * a request that is not a GraphQL request at all: the wrong content type, a body that is not JSON, no `query`, a body, URL or variables over the limit, a bad `Capa-Version`, a malformed `extensions.persistedQuery`, a `Capa-Persist` other than `pin` or `pin; release=; env=`, or a hash that does not match its document. Each is a `400`; * `PersistedQueryNotFound`, a `200` under both types, because persisted-query links retry on exactly that answer. So a `200` is not enough on its own: read `errors` too. A body with `errors` and no `data` is a refusal. The SDK and the MCP tools do this for you. Every other answer the edge does not cache comes back in the media type you asked for, with the same body. A cacheable `GET` always answers `application/json`, so the edge keeps one copy whatever your client sends. `extensions.code` reuses REST's codes; messages from the REST planner are prefixed with the root field (`articles: …`) and name GraphQL arguments (`filter`, `first`, `totalCount`) rather than REST parameters. Their hints say what to change in the document you sent: `filter: { and: [] }` is told `and needs at least one condition, such as and: [{ createdAt: { gte: "2026-01-01" } }]. Leave and out when you have none.`, an empty `in` list that it matches no entry, and a `totalCount` that cannot be taken to stop selecting it or to filter without a relation. A key refused before the document is read (`missing_key`, `invalid_key`, `origin_refused`) carries a hint too: where the key goes, where to find one, and how an origin list works. | Code | `application/json` | `graphql-response+json` | When | | ----------------------------------------------------------------------------------------------- | ---------------------- | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `missing_key`, `invalid_key` | 401 | 401 | no key, or a key Capa does not know | | `subscription_required` | 402 | 402 | the project's plan does not allow reads | | `origin_refused`, `edge_only` | 403 | 403 | the key's origin rules or the edge lock refused the request | | `invalid_version` | 400 | 400 | `Capa-Version` names no version | | `contract_not_found` | 404 | 404 | `Capa-Contract` other than 1 | | `invalid_parameter` | 400 | 400 | the request itself: no `query`; a body that is not JSON; a batch; wrong content type; a body, URL or variables over the limit; `variables` that is not an object; a malformed `extensions.persistedQuery`; an `extensions.capa` that is not `true` or `false`; a `Capa-Persist` other than `pin` or `pin; release=; env=` | | `invalid_parameter` | 200 | 400 | the document: variables that do not match their types; `operationName` missing or unknown with several operations; `first`, `last` or `sort` out of range; `first` with `before`, or `last` without `before`, with `first` or with `after`; an id that is not a UUID | | `graphql_parse_failed` | 200 | 400 | a syntax error, with `locations` and a hint that names them: `Fix the syntax at line 3, column 1.` | | `graphql_validation_failed` | 200 | 400 | an unknown field, a wrong argument type, a subscription anywhere in the document, an unnamed operation beside another. One error per problem, at most 20 | | `query_too_complex` | 200 | 400, or 200 on a field | any limit above; after SQL, more than 5,000 rows read | | `unknown_field`, `invalid_filter_value`, `invalid_operator`, `invalid_cursor`, `invalid_select` | 200 | 400 | REST planner refusals, with `path` set to the root field. `invalid_select` also refuses one relation list read twice in an entry with different arguments (see [Not built yet](#not-built-yet)) | | `count_unavailable` | 200 | 200 | `totalCount` could not be taken (a hop filter matching over 50,000 entries); only `totalCount` is `null` | | `query_timeout` | 200 | 200 | a root field hit the statement timeout; it and every later root field are `null` | | `persisted_query_not_found` | 200 | 200 | the hash is not registered, or the document stored for it does not run with this key as sent. The message is exactly `PersistedQueryNotFound` | | `persisted_query_hash_mismatch` | 400 | 400 | the sha256 of `query` is not the hash you sent | | `mutations_not_enabled` | 405 | 405 | a `mutation` operation anywhere in the document, whichever operation `operationName` picks. The `Allow` header names the methods the route answers, `GET, POST` | | `rate_limit_exceeded` | 429 | 429 | your client already has 3 reads running with the key and 64 waiting (`This client has too many reads running at once with this key.`), your client is the one with the most reads waiting when the key has 4 running and 256 waiting across its clients (a client with fewer waiting takes the place of that client's newest read), your project's keys together have 4 reads running (`This project has too many reads running at once across its keys.`), your project has had 10 introspection answers computed and asks for another within the second (`This project has had too many different introspection answers computed in a short time.`), or a document waited longer than the database budget for a slot while one of those shares was full. GraphQL documents and `/api/entries` reads share them. Another client of the same key is still served. A client is its address, or its /64 for IPv6. `Retry-After` says when to try again | | `route_not_found` | 404 | 404 | GraphQL is switched off on this host, for a GET. A POST then answers `405 mutations_not_enabled` in the REST envelope, as every write to an unknown `/api/` path does | | `service_unavailable` | 503 | 503 | the API process was full of other projects' reads for the whole database budget, the last slot being kept for a project with none running, or the database had no connection free in time. Nothing in the request is wrong: retry after `Retry-After`. There is no `data` | | `internal` | 500, or 200 on a field | 500, or 200 on a field | our bug. No details, quote the `requestId`. A bug met while answering one field nulls that field, with `path` set, and the rest of `data` is still answered with a 200 | ## Caching A GET with a production key is cached at the edge. It carries the document and its variables in the query string, URL-encoded: ```bash curl -G https://cdn.capacms.com/api/graphql \ -H "x-api-key: $CAPA_KEY" \ --data-urlencode 'query=query Latest($first: Int) { articles(first: $first) { nodes { id title } } }' \ --data-urlencode 'variables={"first":2}' \ -D - ``` ```http HTTP/1.1 200 OK cache-control: public, max-age=60 surrogate-control: max-age=60, stale-while-revalidate=86400, stale-if-error=604800 surrogate-key: t:… c:…:1 k:… m:… e:…002e e:…002c etag: "ec0d0bb2483b4f758082c5078cc60420" capa-version: 2026-10-01 ``` Send the `ETag` back as `If-None-Match`, and while the answer is unchanged the response is `304 Not Modified` with no body. Which requests are cached: | Request | `Cache-Control` | Surrogate keys | `ETag` | | ------------------------------------------------------------- | -------------------- | -------------- | ----------------------------- | | any POST | `no-store` | no | no | | GET, production key, no errors | `public, max-age=60` | yes | yes, a 304 on `If-None-Match` | | GET, development key | `no-store, no-cache` | no | no | | GET with any error, or selecting `me`, `__schema` or `__type` | `no-store` | no | no | The surrogate keys are the union of what each root field's REST request would carry (`t:` `c:` `k:` then `m:`, `e:` and `f:` in first-seen order), under the same 12 KB cap and the same `Capa-Cache-Scope: model` fallback. An `entry(id:)` that answered `null` carries `e:` and an `m:` key for every model the id could appear in, so the publish that makes it real purges the cached `null` even past the cap. What purges them: * Publishing an entry purges `e:` and `m:` for its model, so every list of that model goes too. That includes publishing a named version, and a draft save that moves the entry to another folder: the folder is where the entry is filed, which production keys read at once. A draft save's tags wait for the publish, because production keys read the tags the entry had when it was last published. * Unpublishing or deleting an entry, alone or in bulk, purges the same keys hard: the edge drops the bodies at once rather than serving them while it fetches again. That includes unpublishing a named version and a scheduled unpublish. Deleting a model purges its `m:` key hard. * Deleting a project purges `t:` hard. * Editing a file's alt text purges `f:`, the key of every response that renders the file; deleting the file purges it hard. * Deleting or deactivating a key, rotating it, narrowing its scopes or its allowed origins, or setting or bringing forward its expiry purges `k:`, so nothing the key may no longer read keeps serving from the edge. * A key that expires caps the edge lifetime: `Surrogate-Control` ends at the expiry, stale windows included. Use GET, ideally with a persisted query, for anything a CDN should serve. ## Persisted queries A persisted query is sent as the sha256 of its document instead of the document, by GET, so the URL is short and a CDN can answer it: ```bash curl -G https://cdn.capacms.com/api/graphql \ -H "x-api-key: $CAPA_KEY" \ --data-urlencode 'variables={"first":2}' \ --data-urlencode 'extensions={"persistedQuery":{"version":1,"sha256Hash":"835d679d63340f4b6d37577a80a5e40112db519662aec969d4927a6516c4793b"}}' ``` That hash is the sha256 of `query Latest($first: Int) { articles(first: $first) { nodes { id title } } }`, the document of the [Caching](#caching) example. When the document is registered (see [Registering](#registering)), the answer is the one that document gives, cached as it is. When it is not, the answer is a 200 that says so: ```json { "errors": [ { "message": "PersistedQueryNotFound", "extensions": { "type": "invalid_request", "code": "persisted_query_not_found", "param": "extensions", "hint": "Send the query again with the same hash to run it. Production keys do not register documents: register them at build time with a development key.", "docs": "https://docs.capacms.com/errors/persisted_query_not_found", "requestId": "req_f006…" } } ], "extensions": { "persistedQuery": { "registered": false } } } ``` With the SDK, register your documents with `capa persist` at build time, then send only their hashes: ```ts const { data } = await capa.graphql(LatestDocument, { first: 2 }, { persisted: true }); ``` Any client that speaks Apollo's automatic persisted queries works the same way. With Apollo Client: ```ts import { ApolloClient, HttpLink, InMemoryCache } from "@apollo/client"; import { createPersistedQueryLink } from "@apollo/client/link/persisted-queries"; import { sha256 } from "crypto-hash"; export const client = new ApolloClient({ cache: new InMemoryCache(), link: createPersistedQueryLink({ sha256, useGETForHashedQueries: true }).concat( new HttpLink({ uri: "https://cdn.capacms.com/api/graphql", headers: { "x-api-key": process.env.CAPA_KEY!, "Capa-Version": "2026-10-01" }, }), ), }); ``` Every call goes like this: 1. The client sends the hash alone, by GET. If Capa has the document, it runs it, and the CDN can serve the next identical GET. 2. If Capa does not have it, it answers `PersistedQueryNotFound` on a 200. 3. The client sends the document with the hash, by POST. Capa runs it, and stores it only if the key is a **development key**. A production key is meant to ship in a public site bundle, so it runs the document it sends but never stores it (`registered: false`): anyone holding it could otherwise push your documents out of the store with junk ones. Apollo does not remember a miss, so with a production key and a document nobody registered, **every** call is a miss, then a POST that is never cached. Register your documents before the production key ships them, and check that it worked: a GET with the hash alone answers `data`, not `PersistedQueryNotFound`. ### Registering Registration is a POST to `https://cdn.capacms.com/api/graphql` with a development key, the document and its hash. The response says whether it was stored, in `extensions.persistedQuery.registered`. On Capa Cloud, register at the same host you read from: `https://cdn.capacms.com` passes every POST on to the host that stores documents. So `capa persist` needs only the two variables your site already has, `CAPA_API_URL=https://cdn.capacms.com` and `CAPA_DRAFT_KEY`, a development key. `CAPA_ADMIN_URL` is for a self-hosted stack whose read host stores nothing: set it to the API host your Capa admin uses, and it wins over `CAPA_API_URL`. * **Documents you write by hand, or with the SDK's codegen:** run `capa persist` with a development key at build time (see the SDK README). It hashes each operation as it prints it. * **Apollo Client:** Apollo hashes the document it prints after its cache adds `__typename` to every selection, so its hashes are not the ones `capa persist` computes from your source. Register Apollo's own hashes by running every operation once through the same client setup with a development key, for example as a build step: ```ts // register-queries.ts: run on every deploy, with a development key. import { ApolloClient, HttpLink, InMemoryCache } from "@apollo/client"; import { createPersistedQueryLink } from "@apollo/client/link/persisted-queries"; import { sha256 } from "crypto-hash"; import { ARTICLES_PAGE, ARTICLE } from "./queries"; const register = new ApolloClient({ cache: new InMemoryCache(), // the same cache options as the site's client link: createPersistedQueryLink({ sha256, useGETForHashedQueries: true }).concat( new HttpLink({ uri: "https://cdn.capacms.com/api/graphql", headers: { "x-api-key": process.env.CAPA_DRAFT_KEY!, "Capa-Version": "2026-10-01" }, }), ), }); for (const query of [ARTICLES_PAGE, ARTICLE]) { // A document that validates is stored even when its variables are refused, // so an operation with required variables registers with {} too. await register.query({ query, variables: {}, fetchPolicy: "network-only" }).catch(() => undefined); } ``` ### Rules * The hash is the lowercase hex sha256 of the document's UTF-8 bytes, in `extensions.persistedQuery = { version: 1, sha256Hash }`. The document is hashed exactly as sent, so any change to its text, whitespace included, is a different hash. * Documents are registered by POST, with a **development key**. A production key is meant to ship in a public site bundle, so it runs the document it sends but never stores it (`registered: false`): anyone holding it could otherwise push your documents out of the store with junk ones. A GET never registers. * A document is stored only if it parses, validates for the key that sends it and fits in 32 KB. * Documents are stored per project, and every key of the project can run one by its hash. A stored document is validated again against the calling key's schema every time it runs. One that no longer validates, or that a narrower key cannot run, gets exactly the answer a hash with nothing stored gets: `PersistedQueryNotFound`, the same hint, `registered: false`. So a key cannot tell whether a document it guessed is stored for models it cannot read. The client's next request carries its own text, and any refusal then names what it sent. * Each project keeps up to 2,000 documents and 16 MiB of document text, one store shared by every key and every build of the project, preview builds included. Past either limit the documents ranked last are dropped, in this order, newest first within each: 1. the pinned documents of the latest 3 production releases; 2. documents a production key ran in the last 30 days; 3. other pinned documents, such as a preview build's; 4. everything else, such as what a developer registered in the explorer. * Pin a build's documents: send `Capa-Persist: pin` with each registration, and name the build and where it is deployed: `Capa-Persist: pin; release=; env=production` for a production build, `env=preview` for a preview. `release` is the build's own name, such as a commit or a deploy id: 1 to 64 letters, digits, dots, dashes or underscores. `env` is a lowercase name. Send both or neither. A preview build then only ever pushes out other previews and unpinned documents, never a production release's. A pin with no release still ranks as a pin. Re-registering a pinned document without the header keeps it pinned, and a preview registering a production release's document leaves it that release's. The answer says `{ "registered": true, "pinned": true }`. Any other `Capa-Persist` value is `400 invalid_parameter`. * Register your documents at build time with a development key and `Capa-Persist: pin; release=; env=`, so every deploy refreshes them and the site's production key only ever sends hashes. ## `extensions` A site's production reads get the cost, and nothing else they would not use: ```json "extensions": { "cost": { "requestedQueryCost": 4, "actualQueryCost": 4, "budget": { "counted": 4, "limit": 5000 } } } ``` `extensions.capa` is for tools: the Explorer, the MCP server, a debugging session. A development key gets it with every answer. Any key gets it by sending `"capa": true` in the request's `extensions`, by POST or in the GET `extensions` parameter, and `"capa": false` leaves it out, a development key's included. Any other value is `invalid_parameter`. ```bash curl https://cdn.capacms.com/api/graphql \ -H "x-api-key: $CAPA_KEY" \ -H 'content-type: application/json' \ -d '{"query":"{ version }","extensions":{"capa":true}}' ``` | Key | When | What | | ------------------------------------------------- | ----------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `cost` | executed responses, and documents refused for costing more than 5,000 entries; no other refusal | `requestedQueryCost` (the most it can cost), `actualQueryCost` (what it did cost, never more), `budget` (`counted`, what the 5,000-entry limit is checked against, and `limit`) | | `deprecations` | answers to a document that validated and uses a deprecated member | one `{ coordinate, reason }` per member (see [Versions and deprecation](#versions-and-deprecation)) | | `missingReferences` | answers that left a reference out, for every key | one `{ coordinate, model, id }` per relation slot whose entry is deleted, or not published for a production key: a single relation that answered `null`, or an id a connection has no node for. `coordinate` is the relation field (`Footers.places`), and `model` and `id` are what REST prints in that slot as `{ id, model, missing: true }`. Each slot once, the first 100 | | `persistedQuery` | requests that sent a hash | `{ registered }`, and `pinned: true` when the document is pinned | | `capa.requestId`, `capa.version`, `capa.contract` | `capa` answers, except `requestId` on one a shared cache may keep | quote the `requestId` to support | | `capa.environment` | `capa` answers that executed | `production` or `development` | | `capa.cost` | `capa` answers that executed | `depth`, `rootFields`, `connections`, `nodesBound` (the entries the limit counts, a nested list with no `first` as 10 for each entry above it), `scans` (each `totalCount`, each filter or sort through a relation, each root field that filters or sorts by its own fields, each text condition past its first, and each sorted relation list), `nodes` (the entries it read), `fields` | | `capa.rest` | `capa` answers that executed | one `{ field, url }` per entry root field: the exact REST request it ran | `missingReferences` is how you find out why a list came back shorter than the admin shows. The data keeps its shape: GraphQL has no type for a reference without its entry, so a single relation is `null` and a connection leaves the slot out, where REST keeps `{ id, model, missing: true }` in its place. The note says which ones, with any key, and needs nothing in the request (the answer below leaves `cost` out): ```json { "data": { "footer": { "title": "Footer", "locations": { "nodes": [ { "name": "North" }, { "name": "Harbour" }, { "name": "Old Town" }, { "name": "South" } ] } } }, "extensions": { "missingReferences": [ { "coordinate": "Footers.locations", "model": "locations", "id": "…0a1f" }, { "coordinate": "Footers.locations", "model": "locations", "id": "…0a22" } ] } } ``` Every answer names its request in the `X-Request-Id` header, and every error item in its `extensions.requestId`. A cacheable GET leaves `requestId` out of its body, because a CDN serves one stored body to every later caller; its `X-Request-Id` names the request that filled the cache. A refusal before the document is read (a missing or unknown key, an unpaid plan, a refused origin) carries no `extensions` at all. ## Versions and deprecation GraphQL follows `Capa-Version` like every `/api/` route (see [Versions](https://capacms.com/docs/api/versions)). Send the date your code was written against, and `version` answers the one that ran: ```bash curl https://cdn.capacms.com/api/graphql \ -H "x-api-key: $CAPA_KEY" \ -H 'Capa-Version: 2026-10-01' \ -H 'content-type: application/json' \ -d '{"query":"{ version }"}' ``` ```json { "data": { "version": "2026-10-01" }, "extensions": { "cost": { "requestedQueryCost": 0, "actualQueryCost": 0, "budget": { "counted": 0, "limit": 5000 } } } } ``` Every executed answer carries `extensions` (see [`extensions`](#extensions)). The other examples on this page leave it out. A date that is not a version is `400 invalid_version`, and its hint lists the supported dates. The schema can change under a new date without breaking a client pinned to an older one. A member Capa retires goes in three steps, each on its own date: | Your `Capa-Version` | What you see | | ---------------------------------- | ----------------------------------------------------------------------------------------------- | | before the date that deprecates it | the member, unchanged | | from that date on | the member, marked `@deprecated(reason: "Read folder instead.")` with its replacement beside it | | from a later date that removes it | no member; the replacement only | So you are told before anything moves, and nothing moves until you pin the date that moves it. A member a date adds is absent for dates before it. This applies to any member Capa owns: * a system field such as `_folder` on every model type, and with it its sort values and its filter field: when `publishedAt` is deprecated, so are `publishedAt_ASC`, `publishedAt_DESC` and the `publishedAt` filter on every model, and when it goes, they go too; * a field of `PageInfo`, `Media` or `KeyInfo`; * a filter operator such as `contains`; * what every model's generated types share: a sort value such as `createdAt_ASC`, `totalCount` on every connection, an argument such as `before` on every list root, or `first` on every nested connection. A value of an enum that answers carry, `EntryStatus`, `MediaKind`, `KeyEnvironment` or `KeyAction`, is deprecated and never removed: an entry can still be `changed` and a key still `development`, and an answer has to be able to say so. A deprecated value keeps its meaning, and the reason names what replaces it. Your models' own fields are yours, so no Capa version changes them. The [changelog](https://capacms.com/docs/changelog/api) names each date. Introspection shows each deprecation and its reason; ask for deprecated arguments and input fields with `includeDeprecated: true`: ```graphql { __type(name: "Articles") { fields(includeDeprecated: true) { name isDeprecated deprecationReason } } } ``` You are also told without asking. A request whose document uses a deprecated member, by selecting it, passing it as an argument, or writing it in a filter or a sort, inline or in variables, gets each one in `extensions.deprecations` and in the `Capa-Deprecated-Reason` header, by GET and by POST: ```json { "extensions": { "deprecations": [ { "coordinate": "Articles._folder", "reason": "Read folder instead." } ] } } ``` ``` Capa-Deprecated-Reason: "Articles._folder: Read folder instead." ``` The header is a list of quoted strings, one `coordinate: reason` per member. A browser page on another origin cannot read it, so read `extensions.deprecations` there. Capa logs the members each request used beside its key, so before a date removes one, Capa can tell which keys and sites still send it. ## Drafts A development key reads drafts: ```bash curl https://cdn.capacms.com/api/graphql \ -H "x-api-key: $CAPA_DRAFT_KEY" \ -H 'content-type: application/json' \ -d '{"query":"{ articles(first: 3) { nodes { id status title } } }"}' ``` ```json { "data": { "articles": { "nodes": [ { "id": "…002e", "status": "published", "title": "Xi marks the spot" }, { "id": "…002d", "status": "draft", "title": "Nu: unpublished notebook" }, { "id": "…002c", "status": "changed", "title": "Mu on measurable goals (v2 draft)" } ] } } } ``` The same request with a production key answers published entries only: `…002d` is not there, and `…002c` reads as published, with its published title, `Mu on measurable goals`. A development key reads the newest version of every entry, including drafts, with `status` telling you which: `published`, `draft` (never published) or `changed` (published, with a newer draft). This is the REST rule, applied by the same query. A draft is invisible to a production key in every field: `status` is `published`, the data is the published data, and `updatedAt` is when the published version was saved. ## Not built yet * Mutations. A document with a `mutation` operation answers `405 mutations_not_enabled`. * Subscriptions. * `search`, `media`, `schema` and `contract` roots, and reverse relations ("articles that point at this author"). The names are reserved. * A filter, `totalCount`, `last` or `before` on a nested connection. * One relation list read twice in an entry with different arguments, such as `a: coauthors(first: 1)` beside `b: coauthors(first: 3, sort: name_DESC)`. REST expands a relation once per entry, so the document is refused with `invalid_select`, `article: coauthors is selected more than once with different arguments.`, with the hint `Select coauthors once per entry. Aliases of one relation must share first, after and sort.` Aliases with the same arguments read one expansion. For two slices, read the entry twice, under two aliases of its single root. Each root field is its own read: ```graphql { firstCoauthor: article(id: "00000000-0000-4000-8000-000000000023") { coauthors(first: 1) { nodes { name } } } lastThree: article(id: "00000000-0000-4000-8000-000000000023") { coauthors(first: 3, sort: name_DESC) { nodes { name } } } } ``` * Filtering by `_version` or `_folder`, which REST does not offer either. * A per-key cost budget (`throttleStatus`). * `Capa-Page` and `Capa-Schema` are ignored on GraphQL, so GraphQL reads do not appear on the pages screen yet. # API reference Source: https://capacms.com/docs/api 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](https://capacms.com/docs/api/entries), the three page routes are in [Pages](https://capacms.com/docs/preview/pages), and GraphQL is in [GraphQL](https://capacms.com/docs/api/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](https://capacms.com/docs/legacy/october-2026#fixed-on-this-platform). `/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](https://capacms.com/docs/legacy). ## A request ```bash 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](https://capacms.com/docs/api/authentication)) | | `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](https://capacms.com/docs/preview/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](https://capacms.com/docs/preview/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](https://capacms.com/docs/api/entries#telling-capa-which-url-you-are-rendering) | ## 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](https://capacms.com/docs/api/graphql)). This is `GET /api/versions?from=2026-10-01`, which takes no key: ```json { "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](https://capacms.com/docs/api/entries#caching)) | | `ETag`, `Surrogate-Key` | `GET /api/entries/...`, 200 or 304, production key | see [Entries](https://capacms.com/docs/api/entries#caching) | `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](https://capacms.com/docs/api/graphql#errors)). ```json { "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](https://capacms.com/docs/api/entries#errors) 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. ```json { "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](https://capacms.com/docs/api/authentication) | | `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. # Limits Source: https://capacms.com/docs/api/limits Every size, cost, rate and concurrency limit on /api/, REST and GraphQL, in one place. Every limit is checked before any SQL runs, and a refusal states what the request measured and the limit it passed. REST and GraphQL share the read limits, so a page ported from one to the other keeps working. ## Reads | Limit | Value | Over it | | ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | | Entries per request | 5,000, counted before SQL and held to as rows are read | [`query_too_complex`](https://capacms.com/docs/errors/query_too_complex) | | Levels of entries | 5, the root counted, so relations nest 4 deep below it | `query_too_complex` | | Relation expansions | 12 per request on REST, per root field on GraphQL | `query_too_complex` | | Page size (`limit`, `first`) | 1 to 200, 25 by default | [`invalid_parameter`](https://capacms.com/docs/errors/invalid_parameter) | | A related list (`limit:` inside `select`, nested `first`) | up to 200, 100 by default, counted as 10 for each entry above it when not given | `query_too_complex` | | Cost of a scan | 500 entries for each total, filter or sort through a relation, and each text condition past the first | `query_too_complex` | | Sort keys | 3 | `invalid_parameter` | | Conditions in one filter | 50, nested at most 8 deep | `query_too_complex` | | Values in one `in`, `nin`, `hasAny` or `hasAll` | 200 | `query_too_complex` | | Text conditions in one `or` | 3 `contains`, `startsWith`, `endsWith` or `ne` | `query_too_complex` | | Totals (`count=true`, `totalCount`) with a relation filter | the filter may match up to 50,000 entries | [`count_unavailable`](https://capacms.com/docs/errors/count_unavailable) | | Database time | 5 seconds by default, for the whole request | [`query_timeout`](https://capacms.com/docs/errors/query_timeout) | ## GraphQL documents | Limit | Value | | ------------------------ | --------------------------------------------------------------------- | | Document (`query`) | 32,768 bytes | | Variables (JSON) | 32,768 bytes | | POST body | 65,536 bytes | | GET URL | 8,192 bytes | | Depth | 8 levels; `edges`, `node`, `nodes` and `pageInfo` count zero | | Bracket nesting | 128 levels | | Entry root fields | 10 per operation | | Ids in one `nodes(ids:)` | 100 | | Connections | 25 per operation | | Selected fields | 1,000 | | Fragments | 100 defined, 50 spread side by side | | Introspection | `__schema` 1 time and `__type` 10 times per operation, 20 levels deep | Every GraphQL limit, with the exact message and hint for each, is in [GraphQL limits](https://capacms.com/docs/api/graphql#limits), and how a document's cost is counted is in [Cost](https://capacms.com/docs/api/graphql#cost). ## Reads running at once 4 per project, across all of its keys, GraphQL documents and `/api/entries` reads counted together, and at most 7 for the whole API process, so connections always stay free for key checks and health checks. The last of the 7 is kept for a project with no read running, so two busy projects never hold every slot. One client, by its address, runs at most 3 of its key's 4, so a caller flooding a site's public key never takes the slot its other visitors read with. Up to 64 more per client, and 256 per key, wait for a slot as long as the database budget. A client at its 3 or with 64 waiting, a key at its 4 or with 256 waiting, or a project whose keys have 4 running is told `429 rate_limit_exceeded`; a key held off because the process was full of other projects' reads is told `503 service_unavailable`. Each carries `Retry-After: 1`. A read whose caller hangs up is stopped at once, the statement running included Up to 64 reads per client, and 256 per key, wait for a slot. Every refusal carries `Retry-After: 1`. Past a key's or a project's share the answer is [`429 rate_limit_exceeded`](https://capacms.com/docs/errors/rate_limit_exceeded); when the API is full of other projects' reads it is [`503 service_unavailable`](https://capacms.com/docs/errors/service_unavailable). # Versions Source: https://capacms.com/docs/api/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-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: | 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: 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: ```json { "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. ```bash curl -X PATCH https://api.capacms.com/v2/tenants/api-keys/ \ -H 'Authorization: Bearer ' \ -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. ```bash curl https://cdn.capacms.com/api/versions ``` ```json { "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 ```bash 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, `.`. 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: ```json { "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](https://capacms.com/docs/changelog/api) 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. # API changelog Source: https://capacms.com/docs/changelog/api Every dated version of /api/ and what it changed, newest first. Send the date you built against in `Capa-Version`, and a later change cannot move under your site; see [Versions](https://capacms.com/docs/api/versions). The same list is served as JSON by `GET /api/versions`. ## 2026-10-01 Current version. * First dated version of `/api/`. *(behaviour, REST: `2026-10-01.initial`)* * `GET /api/entries` accepts `Capa-Page`, naming the page a read is for. Optional, ignored when malformed, and never part of Vary. *(request, REST: `2026-10-01.capa-page-header`)* * `GET /api/pages` and `GET /api/pages/{page}` list a project's pages, declared by a model route or observed through `Capa-Page`. `GET /api/pages?entry={id}` narrows the list to the pages that read one entry. *(response, REST: `2026-10-01.pages-routes`)* * `GET /api/preview` verifies a preview token and answers the entry, model and path it names. *(response, REST: `2026-10-01.preview-verify`)* * `GET /api/entries` accepts `Capa-Schema`, the checksum of the schema the calling code was generated from. Optional, ignored when malformed, and never part of Vary. *(request, REST: `2026-10-01.capa-schema-header`)* * `GET /api/pages/{page}` gains insights: suggestions drawn from the page's own reads, each with the numbers behind it and the rewritten query where there is one. `GET /api/pages` gains meta.insights holding the tenant-wide unused-entry rows. *(response, REST: `2026-10-01.page-insights`)* * Every queries\[] row on `GET /api/pages/{page}` gains selection, the Selection IR its select parses to against the model's current schema, and selectionError when it no longer parses. *(response, REST: `2026-10-01.page-query-selection`)* * Every queries\[] row on `GET /api/pages/{page}` gains url, the request that group most often made, with its filters, sort and limit rather than its select alone; null once the group has been folded into the daily rollup. `GET /api/pages/{page}` gains lastReadAt, the newest read of the page in the window. *(response, REST: `2026-10-01.page-detail-request-shape`)* * `GET /api/entries/{ns}` and `GET /api/entries/{ns}/{id}` accept shape=flat: every relation is a \{ id, model } reference and every expanded entry appears once, in a top-level included object keyed by model namespace and then id. shape=tree, the default, is unchanged. *(request, REST: `2026-10-01.flat-shape`)* * A system key can be written $name wherever the grammar takes one: select=$tags,tags, where=\{"$tags":\{"has":"news"}}, sort=-$createdAt, author.$id. $name always means the system key; the plain name means a model field of that name when there is one. *(request, REST: `2026-10-01.system-key-sigil`)* * Each item of an array of image, video or file renders as a single media field does: id, url, alt, type, width and height, with alt filled from the file when the field has none. *(response, REST: `2026-10-01.media-array-shape`)* * has, hasAny and hasAll take the type of the array's items: true or false for an array of true_false, ISO 8601 dates for an array of dates, compared as instants as a date field's are, and numbers for an array of numbers. Any other value is 400 invalid_filter_value. *(request, REST and GraphQL: `2026-10-01.typed-array-filters`)* * For a production key, updatedAt is when the published version was saved, in the entry, in a sort and in a filter, so saving a draft changes nothing a production key can read. A development key reads the entry's own updatedAt. *(response, REST and GraphQL: `2026-10-01.served-updated-at`)* * A malformed sort, or one with more than three keys, is 400 invalid_parameter with param sort, as limit is, rather than invalid_select. *(response, REST: `2026-10-01.sort-parameter-code`)* * Every budget refusal states what the request measured and the limit it passed, in entries with thousands separators, such as: This request could return 5,025 entries, over the limit of 5,000. *(response, REST: `2026-10-01.stated-limits`)* * `POST /api/graphql` and `GET /api/graphql` serve typed, key-scoped reads of every model the key can read, with Relay connections, node(id:) and nodes(ids:), typed filters and sorts, persisted queries, and the same limits, error codes and cache keys as `/api/entries`. Fields are phased out by a later `Capa-Version` marking them @deprecated(reason:) before any version removes them. *(response, GraphQL: `2026-10-01.graphql`)* * extensions.cost.requestedQueryCost is the most a document can cost: every relation list at the limit it reads, capped at the 5,000 entries a document may read, plus 500 for each scan. actualQueryCost adds 500 for each scan that ran, so it is never above requestedQueryCost. The 5,000-entry bound checked before the read still counts a list with no first as 10, and is extensions.capa.cost.nodesBound. *(response, GraphQL: `2026-10-01.graphql-cost-bound`)* * A relation list with no limit:, or a nested GraphQL connection with no first, still reads up to 100 entries for each entry above it, but counts as 10 toward the 5,000-entry bound checked before the read, so a read that writes no number is not refused before it runs. A list that holds more than it was counted for stops the read at that list with query_too_complex, which names it. *(request, REST and GraphQL: `2026-10-01.counted-default-limit`)* * A select, and each GraphQL root field, reads up to 5 levels of entries, the root counted, which is what legacy depth=4 reads. A sixth level is refused with query_too_complex, and its hint names the 5 levels of entries. *(request, REST and GraphQL: `2026-10-01.entry-depth`)* * A GraphQL request error (a parse or validation failure, variables that do not coerce, a budget or a planner refusal) answers 200 with errors and no data under application/json, and keeps its 4xx under application/graphql-response+json. 401, 402, 403, 404, 405 and 429 keep their statuses under both. *(response, GraphQL: `2026-10-01.graphql-error-status`)* * Each contains, startsWith, endsWith or ne condition on a model's own field, and each relation list sorted with sort:, costs 500 entries toward the 5,000 a request or document may cost, since each reads every entry it could match. An or may hold 3 such conditions; more is refused with query_too_complex. *(request, REST and GraphQL: `2026-10-01.text-condition-scans`)* * `GET /api/entries/{ns}` accepts before=end: the last limit entries of the list, in list order, with hasNext false and hasPrev set when entries come before them. GraphQL's last with no before, or with before: null, reads the same. *(request, REST and GraphQL: `2026-10-01.list-end`)* * A project's keys together run at most 4 GraphQL documents and `/api/entries` reads at once, and the last slot of the API process is kept for a project with none running. The next read waits for a slot as long as the database budget, then is 429 rate_limit_exceeded: This project has too many reads running at once across its keys. *(behaviour, REST and GraphQL: `2026-10-01.project-read-share`)* * \_\_schema costs one entry for every 100 members of the key's schema, in requestedQueryCost and, when the answer is computed rather than kept, in actualQueryCost. A project may have 10 \_\_schema answers computed at once and 1 a second after that; past it the document is 429 rate_limit_exceeded. A repeated introspection query, comments and whitespace aside, is answered from memory and not counted. *(response, GraphQL: `2026-10-01.schema-cost`)* * `Capa-Persist` accepts pin; release=\; env=\. A project's stored documents are kept in this order: the pins of its latest 3 production releases, the documents a production key ran in the last 30 days, other pins, then the rest. A project keeps up to 2,000 documents and 16 MiB of them. *(request, GraphQL: `2026-10-01.persist-release`)* * An answer to a document that uses a deprecated field, argument, input field or enum value, inline or in variables, names each one in extensions.deprecations as \{ coordinate, reason } and in the `Capa-Deprecated`-Reason header. *(response, GraphQL: `2026-10-01.deprecated-reason`)* * `GET /api/entries/{ns}` and /\{id} read select, filter\[...], where, sort, limit, count, after, before and shape. Any other query parameter is 400 invalid_parameter, and the hint gives the `/api/` form of a legacy one: ?slug=/ is filter\[slug]=/, ?page= is after=\. A name that starts with \_ or utm\_ is ignored. *(request, REST: `2026-10-01.unknown-parameters`)* * extensions.cost carries budget: \{ counted, limit } for every key: the figure the 5,000-entry limit is checked against before any SQL, and the limit. requestedQueryCost stays the most the document can read. *(response, GraphQL: `2026-10-01.cost-budget`)* # SDK changelog Source: https://capacms.com/docs/changelog/sdk Every release of @capacms/sdk, newest first. The [SDK reference](https://capacms.com/docs/sdk) covers the current release. Install it with `pnpm add @capacms/sdk@next`. ## 1.0.0-next.8 (2026-10-02) * 1.0.0-next.8. Preview on a live site, in draft mode on its production domain. `preview()` takes the legacy key a site already holds (`pk_`, `sk_` or unprefixed), as the API does: Capa checks the token with any key that reads the project, and refuses a token made for another project. It used to throw a `TypeError` for a legacy key before any request. Draft reads through the SDK still take a `cap_` key only, and the legacy-key warning says so. * Next.js: `createPreviewRoute` and `exitPreviewRoute` take Next's `cookies`. Given it, the draft cookie is re-set `HttpOnly; Secure; SameSite=None; Partitioned; Path=/; Max-Age=3600` (`maxAge` changes the hour), so Safari 26.2 and later send it inside the editor's frame and it no longer lasts until the browser closes. Exit and a bad link delete it partitioned, which is the only deletion that reaches it. `redirect` is optional now: left out, each route answers with its own 307, marked `X-Robots-Tag: noindex, nofollow`, `Referrer-Policy: no-referrer` and `Cache-Control: private, no-store`, and the preview route's carries the editor's `frame-ancestors`. Given `redirect`, a route calls it as before. * Next.js: `capaHeaders({ adminOrigins })` returns rules for `next.config`'s `headers()` that send `X-Robots-Tag: noindex, nofollow` and `Content-Security-Policy: frame-ancestors 'self' https://app.capacms.com` on a request carrying the draft cookie or a `capa-preview`, `capa-edit` or `capa-view` query, and on nothing else, so a visitor's response is unchanged. Also exported: `draftHeaders`, `frameAncestors` (it throws for a value that is not a bare origin), `frameDraftCookie`, `clearDraftCookie`, `CAPA_ADMIN_ORIGIN`, `DRAFT_ROBOTS_TAG`, `DRAFT_COOKIE_MAX_AGE`, `PREVIEW_PARAM` and `VIEW_PARAM`. * Next.js: `capaMiddleware` marks every edit-mode response, and every request carrying `capa-edit` or `capa-view`, `X-Robots-Tag: noindex, nofollow`, and `resolveEditRequest` returns that value as `robotsTag`. * Docs: "Add preview to a live site without changing it", the steps for a site that keeps its own Capa reads, and why `editMode()` and `getCapaClient()` make a static page dynamic. ## 1.0.0-next.7 (2026-09-29) * 1.0.0-next.7. Browsers: the `/api/` and legacy clients called the platform `fetch` as a method of their config, which a browser refuses ("Failed to execute 'fetch' on 'Window': Illegal invocation"), so every read from a browser failed unless `fetch` was passed in. The default fetch is now called through `globalThis` on each request, which also picks up a fetch a framework patches in later. * `next` is no longer a peer dependency. No range matches every Next canary, so a site on `next@16.3.0-canary.39` could not `npm install` the SDK without `--legacy-peer-deps`. `@capacms/sdk/nextjs/overlay` still imports `next`, which only a Next site loads. * Docs: the edit mark is dropped wherever an entry is serialized to the browser (Pages Router props, SvelteKit and Remix loaders, Nuxt payload); pass the edit flag to `capaAttrs` there. Found testing preview on nine stacks. * Next.js: a GraphQL read that gives neither `tags` nor `revalidate`, through `getCapaClient().graphql()`, its builder or `graphql()`, is no longer kept in Next's data cache. It is sent as `entries.list` and `entries.get` send theirs, with no `cache` and no `next`, so a publish shows on the next render whether the page reads by REST or by GraphQL. It used to be kept under `capa:graphql` until a webhook revalidated it, so a site with no webhook route kept serving the old answer by GraphQL while REST showed the new one. A read that gives `tags` or a `revalidate` is kept as before. To keep a read with no tags as it was kept, pass `revalidate: false`, which keeps it under `capa:graphql` until the next publish. `tags: []` now counts as no tags. ## 1.0.0-next.6 (2026-09-28) * 1.0.0-next.6. GraphQL, on `@capacms/sdk/next` and `@capacms/sdk/nextjs`, and two commands, `capa-codegen --graphql` and `capa persist`. Additive: every call that existed behaves as before. `graphql` is an optional peer dependency that only the two commands load. The package's types now need TypeScript 5.0 or later, with `strict` on or off. Calls and errors. `client.graphql(document, variables?, options?)` runs a query against `/api/graphql` and resolves with `{ data, errors, extensions, cacheTags }` once the API has run it, even when `errors` is not empty: a root field that failed is `null` and the others keep their data. A request the API refuses as a whole, `errors` and no `data`, throws `CapaError` whatever its status: a 4xx, or the 200 that GraphQL over HTTP sends on `application/json`, thrown with `status` 400. 401, 402, 403, 404, 405 and 429 keep their own. `CapaError` carries every error of the refusal in `graphqlErrors`. Each error is a `CapaGraphQLError`, a plain object: `message`, `locations`, `path` and `extensions` as the spec writes them, with `code`, `hint`, `docs`, `param` and `type` lifted out, so `Response.json(result)` and a client component's props keep every field. `isCapaGraphQLError` checks one by its shape. Where GraphQL is switched off the call throws "This Capa deployment does not serve GraphQL." with a hint to read over REST meanwhile. `extensions.cost` (`requestedQueryCost`, `actualQueryCost`, and `budget`, whose `counted` is what the 5,000-entry limit checks) and `extensions.deprecations` (`{ coordinate, reason }` for each deprecated member a document used) are typed. Sending. A read is a GET whenever its URL fits the API's 8,192-byte limit, so a production key's read is cached by the CDN and the API with no option set, and a POST when it does not; no document is refused for its length. `method: "POST"` always sends a POST. A host that serves GraphQL by POST only (the admin host) answers a GET as a path it does not serve; the read is repeated as a POST, and that host is read by POST for five minutes. A 429 (over the API's read limits: 4 running per project, 3 of them per client, and 64 waiting per client or 256 per key, with `/api/entries` reads counted in the same limits) is sent again after its `Retry-After` plus up to 250 ms, up to 3 times; `retries: 0` throws it, and a thrown 429 carries `retryAfter` in seconds, on REST calls too. `createClient` takes the legacy key a site holds for every read, `pk_`, `sk_` or unprefixed as older tenants were minted, and warns once per process without printing any of it; `preview()` and draft reads (`draftClient`, `CAPA_DRAFT_KEY`) take a `cap_` key only. Persisted queries. `persisted: true` sends the document's sha256 as a cacheable GET (a POST when the variables are too long for a URL), and on `PersistedQueryNotFound` one POST with the document. The API stores a document only for a development key; a hash a host declined is remembered for five minutes and sent by POST meanwhile. `capa persist` registers a project's documents at build time, with the development key in `CAPA_DRAFT_KEY`, on `CAPA_API_URL` (`CAPA_ADMIN_URL` for a self-hosted stack whose read host stores nothing), and refuses a production key before it sends anything. Every document is pinned (`Capa-Persist: pin`), so documents registered in the Explorer or a preview never evict it, and one stored without its pin fails the run. A refusal is reported with its code. `--release` and `--env` name the build its pins belong to (`Capa-Persist: pin; release=; env=`), read from Vercel's and Netlify's build variables when not given, so the API keeps the latest production releases' documents before a preview's. Typed documents. `capa-codegen --graphql` checks the project's `.graphql` files and its `#graphql`, `/* capa */` and gql\`\` literals against the key's schema, and writes one module: the schema's types, `CapaQuery`, a `TypedDocument` per operation with `Models`, the models it reads, beside it, a `Fragment` per fragment and `capaTreeLayout`. `tagsFor({ namespace: Models })` tags a Next.js read with every model its query reads, relations and filters through them included, so no model is left out by hand, and with `capa:media`, which `revalidateFromWebhook` revalidates when a file in the media library is edited. `client.graphql(doc, vars)` then infers data and variables with no cast, a literal is typed by its own text, and a document's required variables are required in the call. A literal codegen has not read yet does not compile (`RunCapaCodegen`). A fragment in a literal of its own, spread with `${FRAGMENT}` into a query kept `as const`, works as in Hydrogen. A field, argument or value a later `Capa-Version` phases out is `@deprecated`, and each use is printed with its file, line and reason. `--watch`, `--check`, `--save-schema` and `--schema` are supported. Both commands read `CAPA_API_URL` and `CAPA_KEY` (`CAPA_BASE_URL` and `CAPA_API_KEY` still work) from the shell or the project's `.env` files, as `next dev` does. REST types. `capa-codegen` without `--graphql` takes a `cap_` key: it writes the model interfaces the `/v2` schema gives a legacy key from the key's GraphQL schema, keyed by namespace, with no tenant id, and from a saved schema with `--schema`. Every field is optional there, an enum is a `string` and a field GraphQL leaves out is `unknown`, since GraphQL does not say more; no `CAPA_SCHEMA_CHECKSUM` is written. Whichever key reads it, the module now parses for any namespace: a field that is not an identifier is quoted (`"am/pm_indicator"?: string;`), a model whose PascalCase name is not one is named as GraphQL names it (`2024_events` is `_2024Events`, its references and `Select` and `Attrs` aliases too), and an enum's values are escaped. A read's `fields` is typed as the API returns it: `entries.list` types exactly the fields the select names, each always there and `null` when it was never filled, an expanded relation as the entry (with `fields`) or `{ id, model, missing: true }`, one it does not expand as `{ id, model }`, a relation list as `{ items, pageInfo }` and media as `{ id, url, alt, type, width, height }` (`EntryFields`). A select the compiler cannot read, a string or one typed `Select`, types every field and each relation as any of the three. A flat read's relations are references, `included` holds partial entries, and `inflate` returns the tree read's type. BREAKING for code that compiled against the stored shape (`fields.author.name`, `fields.coauthors[0]`), which read `undefined` or threw at run time. A relation may be named alone in a typed select, as a reference. The typed builder. `client.graphql.query(selection)` builds the document from an object and, with `createClient()`, types the result from exactly what was selected. A misspelled field, argument, filter field or operator, an argument of the wrong type, a sort value outside the enum and a missing required argument (`article` without `args: { id }`) do not compile, and the compiler's error names the key and where it was written (`SelectionError<"titel is not a field of articles.nodes">`). `NodeOf` is one entry of a read, for a component's props, and `QueryResult` the whole of its `data`. An alias reads a field again in the same request: `{ latest: { __aliasFor: "articles", args, nodes } }` is sent as `latest: articles(...)`, and typed and checked as `articles`. A `Date` in `args` is sent as its ISO text. `selectionToDocument` returns the text a selection sends. It takes no `persisted`, and throws a `TypeError` for it before any request: its document is printed when it runs, so `capa persist` never stored it, and it is already a cacheable GET. Paging, tags and descriptions. The builders page back as the API does: REST's `before` with a `limit` is sent as `last` with `before`, and `before=end` as `last` alone, the end of the list. A model's filter takes `_tags`, as REST's `where` takes `$tags`, with the operators the key's schema declares for it. `graphqlSchema()`, the builders and codegen read a field's `Capa field ...` tag from the last line of its description, after the field's label, which is where the API writes it. REST's shape. `toTree(data, selection, layout)` turns a builder result into REST's `shape=tree` data for the same read, typed from the selection: system keys beside `fields`, each field under its namespace, media values in REST's public shape and order (`id`, `url`, `alt`, `type`, `width`, `height`), and each entry's `model` from the layout. A relation list without `pageInfo` selected is `{ items }`, and a missing item of one is left out, where REST keeps a `{ id, model, missing: true }` slot. The layout is the `capaTreeLayout` constant codegen writes, so a server reads no schema for it, or a schema read with `client.graphqlSchema()`. A root alias converts as the field it names; an alias below a root is refused, since REST reads each field once. `selectToSelection` writes a REST read as a builder selection, and `graphqlToSelect` takes a selection back to REST as it takes a tool spec. The tool spec. `buildGraphQLQuery`, `graphqlToSelect` and `selectToGraphQL` move between the spec the Explorer and `@capacms/mcp` share, GraphQL text and the equal REST request, written exactly as the API writes it in `extensions.capa.rest`: the filter as `where` JSON, and a system key a field shadows as `$tags`, `$createdAt` or `author.$id`. They nest at most 4 relations below the root entry, which is the API's 5 levels of entries, and send each filter value as the type its filter input declares, so `has: "true"` on a list of true/false values goes as `true`. They check a filter's names through `and`, `or`, `not` and one hop, refuse a system field GraphQL does not filter (`_version`) and a hop through a relation the key cannot read without naming the hidden model, and refuse a key a spec does not take (`frist`) with the keys it does. `selectToGraphQL` selects what REST returns: all six media fields, and for `*` or no select the system keys, every field and each relation list as a connection of ids. `inflate` and `Select` read `$tags`, `$createdAt` and the other `$` names as system keys (`SystemKey`). The schema summary lists the models GraphQL leaves out in `restOnly`, and the builder refuses one by naming its REST read, never as an unknown model with another model suggested. A read of one entry pages a relation list from a cursor: `after` on the relation's spec, `after:` in a select, `args.after` in a selection. A list read refuses it, as the API does. `sort` takes one value or a list, at the root too. A spec written as REST writes a read (`author.name`, `author(name)`, `*`, a sort of `-publishedAt`) throws with what to write instead. Next.js. `graphql()` on `@capacms/sdk/nextjs` reads `CAPA_API_URL`, `CAPA_KEY` and `CAPA_API_VERSION` (a setting in `config` wins), works out draft and edit mode from `{ draftMode, headers }` as `getCapaClient` does, and keeps a published read that answered with no `errors` in Next's data cache, through `unstable_cache`, under its `tags`: until one of them is revalidated, or for `revalidate` seconds. A read with no `tags` is tagged `capa:graphql` (`GRAPHQL_TAG`), which `revalidateFromWebhook` revalidates on every content and media event; `tagsFor({ namespace })` tags a read by the models it reads. Drafts are read uncached, by GET. It is not persisted by default. `draftClient`, `getCapaClient` and `getPublishedClient` take `CapaQuery` like `createClient`, and `getCapaClient` keeps its GraphQL reads, a document or the typed builder, in Next's data cache the same way, under the `tags` and `revalidate` each call gives. The package root. `createClient` from `@capacms/sdk`, the legacy `/v2/api` client, refuses a `cap_` key with a `TypeError` that names `@capacms/sdk/next`, before it asks for a tenant id or sends anything, where every read failed as a 401 "Invalid API key". Its `CapaError` message joins the body with a colon. The README opens with which import is which and links the API reference, and `homepage` is `https://docs.capacms.com/api`. Edit mode. A GraphQL read in edit mode is marked like a REST read: each object that selected `id` and `model` is an entry, and `capaAttrs(node, field)` and `fieldAttrs(node)` tag a field GraphQL renamed by its namespace. To know which fields those are, the client reads the key's type and field names once a minute, beside the page's read rather than after it; a document that never says `model` reads none. `toTree` keeps the mark, and `markGraphQLEntries` is exported. With next.5's `FieldAttrs`: `fieldAttrs` also takes a GraphQL node and, for a REST entry typed with `Model`, still returns exactly `FieldAttrs`, so the `Attrs` aliases codegen writes keep working. `TaggableField` is exported beside it. ## 1.0.0-next.5 (not published; its changes shipped in next.6) * 1.0.0-next.5. `` from the new entry point `@capacms/sdk/nextjs/overlay`: the live preview overlay as one Next.js client component, refreshing with `router.refresh()` on save (M6). `react` and `next` are optional peer dependencies, needed only for that entry. The `FieldAttrs` type is exported from `@capacms/sdk/next`, and `capa-codegen` writes a `Attrs` alias beside each `Select`, so `const a: ArticleAttrs = fieldAttrs(entry)` catches a wrong field name at compile time (M5). ## 1.0.0-next.4 (2026-09-24) * 1.0.0-next.4. Edit mode. BREAKING for sites that call `capaAttrs(entry, field)` with no third argument: it now tags only entries read in edit mode, so a published page ships no `data-capa-` attributes. Create the client with `editMode: true` (work it out with `editMode({ draftMode, headers })` from `@capacms/sdk/nextjs`) and every entry it reads, related entries included, is marked. Sites that pass a flag keep working unchanged. New in `@capacms/sdk/nextjs`: `editMode`, `resolveEditRequest` for middleware (checks a `capa-edit` token with Capa, strips a forged `x-capa-edit`, returns the `private, no-store` Cache-Control to set), and the constants `EDIT_PARAM`, `EDIT_HEADER`, `DRAFT_COOKIE`, `EDIT_CACHE_CONTROL`. New on the client: a `path` option (config and per call) sent as `Capa-Path` beside `Capa-Page`, so Capa can list the concrete URLs an entry appears on. `markEditEntries`, `isEditEntry` and `CAPA_EDIT` are exported from `@capacms/sdk/next`. Five-minute integration (M6) in `@capacms/sdk/nextjs`: `getCapaClient` (keys and edit mode from env), `getPublishedClient`, `createPreviewRoute`, `exitPreviewRoute` and `capaMiddleware` (preview links, the Published view, edit mode and `no-store` in one line). Typed `fieldAttrs(entry).title` on `@capacms/sdk/next` (M5): a wrong field name is a compile error. Flat responses: `entries.list` and `entries.get` take `shape: "flat"`, which sends `?shape=flat` and returns every relation as a `{ id, model }` reference with each expanded entry once in `included`, typed by the select. The result carries the select it sent. `inflate(result)` turns it back into the tree result, deep-equal to a `shape=tree` read of the same request; it returns copies and stops where the select stops, so cycles end. New types: `FlatPage`, `FlatSingle`, `FlatListOptions`, `FlatGetOptions`, `Included`, `ExpandedTargets`, `ResponseShape`, `FlatResponse`. Additive: a read without `shape` sends the same URL and returns the same result as before. ## 1.0.0-next.3 (2026-09-23) * 1.0.0-next.3. Reads made from a layout are no longer charged to `/`. `routeOf` returned the route a file sits at, so `app/layout.tsx` came out as `/` and every Site singleton or nav read in a root layout was recorded as a read of the home page, on every page of the site. `routeOf` now returns `"(layout)"` (exported as `LAYOUT_PAGE` from `@capacms/sdk/next` and `@capacms/sdk/nextjs`) for an app-router `layout.*` or `template.*`, and a read whose page is `"(layout)"` sends no `Capa-Page`, even when the client was created with a `page`. The return type is still `string`, so layout code that already passes `routeOf(import.meta.url)` needs no change: upgrading fixes it. No API change: the API already records nothing for a read without the header. In the pages router, `pages/layout.tsx` is now the page `/layout` rather than `/`. ## 1.0.0-next.2 (2026-09-23) * 1.0.0-next.2. The overlay reports `visible { entryId, field }`, the tagged element at the centre of the viewport, while the page scrolls: at most every 150ms and only when it changes (the topmost visible element at the very top of a page, the bottommost at the very bottom). The Capa editor's "Follow the page" scrolls the form to match. Additive and still protocol `v: 1`, so an older admin ignores it and an older overlay simply never sends it. `pickCentred` and `scrollEdge`, the pure choice behind it, and `visibleMessage` are exported for tests. ## 1.0.0-next.1 (2026-09-23) * 1.0.0-next.1. Live preview. `capaAttrs(entry, field, enabled)` on `@capacms/sdk/next` tags an element with the entry and field it renders, typed so `field` is one of the entry's data keys. The new entry point `@capacms/sdk/overlay` exports `startOverlay({ adminOrigins, onRefresh })`, which, inside the Capa editor's preview frame, outlines the field being edited, reports clicks on tagged elements back to the editor and re-renders the draft after a save. It does nothing outside a frame and has no dependencies. `acceptMessage` is exported for tests. See "Live preview" in the README. * `routeOf(import.meta.url)` now decodes the file URL. A real `import.meta.url` percent-encodes brackets, so a dynamic route such as `app/blog/[slug]/page.tsx` came out as `/blog/%5Bslug%5D` and every read with that `page` threw a `TypeError`. * The package is published as `@capacms/sdk`. The `capa` scope on npm was already taken, so the org is `capacms`; entry points are `@capacms/sdk` (legacy `/v2` client), `@capacms/sdk/next` and `@capacms/sdk/nextjs`. Nothing else about the package changed. Earlier notes below that named `@capa/sdk` were written before the first publish and mean this package. * Added `page` to `CapaNextConfig` and to every call's options, sending the `Capa-Page` header so Capa can report which of a site's pages read which entries. Telemetry only: it does not change a response, a cache key or an `ETag`. A malformed value throws a `TypeError`, because the API ignores a header it cannot store and a typo should surface where it is written. * Added `client.pages.list()` and `client.pages.get(page)` for the page list and one page's detail. `list({ entry })` narrows to the pages that read one entry and adds `entryReads` to each row; `get` returns `null` for an unknown page. * Added `client.preview(token)`, which verifies a preview token minted by the Capa admin and returns the claim, or `null` when the token is invalid or expired. Every other failure throws. * Added `schemaChecksum` to `CapaNextConfig`, sending the `Capa-Schema` header on every call so Capa can tell a site built against the current models from one built against an older set. Telemetry only, like `page`: it changes no response, cache key or `ETag`. A malformed value throws a `TypeError`, because a stamp the API drops in silence looks exactly like a site that is up to date. * `capa-codegen` now writes `export const CAPA_SCHEMA_CHECKSUM` beside the checksum comment it already wrote, so the value can be imported and handed to `createClient`. That constant is the only new byte in the generated file. * `client.pages.get(page)` now carries `insights`, the suggestions Capa draws from that page's own reads (`overfetch`, `fanout`, `cache`, `drift`), each with the numbers behind it and a copyable rewrite where there is one, and every `queries[]` row carries `selection`, the parsed Selection IR of its `select`, or `selectionError` when it no longer parses. * Every `queries[]` row on `client.pages.get(page)` also carries `url`, the request that group most often made, so a caller can print the real call with its filters, sort and limit instead of reconstructing one from `select`. It is `null` once the group has been folded into the daily rollup, which keeps counts rather than requests. The detail itself gains `lastReadAt`. * `client.pages.list()` now carries `meta.insights`, the tenant-wide `unused` rows: entries no page has read in 30 days and nobody has edited in 90. * Added `routeOf(file)`, `preview(token, client)` and `pagesFor(client)` to `@capacms/sdk/nextjs`. `routeOf` turns a Next route file into a page string, dropping route groups, parallel slots, leaf file names and extensions, and throws rather than guessing for files Next does not route. ## 1.0.0-next.0 (2026-09-22) * Added `@capacms/sdk/next`, the dependency-free `/api/` read client for `cap_` keys, typed selects, cursor iteration, structured `/api/` errors, and surrogate cache tags. * Added `@capacms/sdk/nextjs` helpers for Next fetch caching, surrogate tag construction, webhook revalidation, and server-only draft client selection. * Kept the legacy `/v2/api` client as the root export. * Added relation-aware `capa-codegen` output while preserving byte-identical output for schemas without relations. # contract_not_found Source: https://capacms.com/docs/errors/contract_not_found 404 not_found. Capa-Contract names a contract this project does not have. | HTTP status | `type` | Surfaces | | ----------- | ----------- | ------------- | | 404 | `not_found` | REST, GraphQL | ## What it means Capa-Contract names a contract this project does not have. Only contract 1 exists today. | When | What the `hint` tells you | | -------------------------- | ------------------------- | | `Capa-Contract` is not `1` | the contracts that exist | ## What to do Drop the Capa-Contract header or send 1. ## The response HTTP 404: ```json { "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…" } } ``` ## On GraphQL `/api/graphql` answers the same `code` inside `errors[].extensions`. The status depends on the `Accept` header you send ([status codes](https://capacms.com/docs/api/graphql#status-codes)): | `application/json` | `graphql-response+json` | When | | ------------------ | ----------------------- | ---------------------------- | | 404 | 404 | `Capa-Contract` other than 1 | ## See also * [Versions](https://capacms.com/docs/api/versions) # count_unavailable Source: https://capacms.com/docs/errors/count_unavailable 400 invalid_request. totalCount could not be computed cheaply for this filter (a relation filter matching over 50,000 entries). | HTTP status | `type` | Surfaces | | ----------- | ----------------- | ------------- | | 400 | `invalid_request` | REST, GraphQL | ## What it means totalCount could not be computed cheaply for this filter (a relation filter matching over 50,000 entries). | When | What the `hint` tells you | | ---------------------------------------------------- | --------------------------------------------- | | `count=true` with a relation filter over 50,000 rows | drop the relation filter or drop `count=true` | ## What to do Drop totalCount, or narrow the filter. ## The response HTTP 400: ```json { "error": { "type": "invalid_request", "code": "count_unavailable", "message": "This filter matches too many entries to count.", "param": "count", "docs": "https://docs.capacms.com/errors/count_unavailable" }, "meta": { "version": "2026-10-01", "contract": 1, "requestId": "req_0f3c…" } } ``` ## On GraphQL `/api/graphql` answers the same `code` inside `errors[].extensions`. The status depends on the `Accept` header you send ([status codes](https://capacms.com/docs/api/graphql#status-codes)): | `application/json` | `graphql-response+json` | When | | ------------------ | ----------------------- | -------------------------------------------------------------------------------------------------------- | | 200 | 200 | `totalCount` could not be taken (a hop filter matching over 50,000 entries); only `totalCount` is `null` | ## See also * [Totals](https://capacms.com/docs/api/entries#totals) # edge_only Source: https://capacms.com/docs/errors/edge_only 403 permission. The request reached the origin directly. | HTTP status | `type` | Surfaces | | ----------- | ------------ | ------------- | | 403 | `permission` | REST, GraphQL | ## What it means The request reached the origin directly. `/api/` answers only through Capa's CDN. | When | What the `hint` tells you | | ------------------------------------------------------------------ | --------------------------- | | the request reached the origin directly instead of through the CDN | send it to the API hostname | ## What to do Send the request to `https://cdn.capacms.com`, the API's public host, not to an origin address. ## The response HTTP 403: ```json { "error": { "type": "permission", "code": "edge_only", "message": "Direct origin access is not allowed. Request this through the CDN hostname.", "docs": "https://docs.capacms.com/errors/edge_only" }, "meta": { "version": "2026-10-01", "contract": 1, "requestId": "req_0f3c…" } } ``` ## On GraphQL `/api/graphql` answers the same `code` inside `errors[].extensions`. The status depends on the `Accept` header you send ([status codes](https://capacms.com/docs/api/graphql#status-codes)): | `application/json` | `graphql-response+json` | When | | ------------------ | ----------------------- | ----------------------------------------------------------- | | 403 | 403 | the key's origin rules or the edge lock refused the request | ## See also * [API reference](https://capacms.com/docs/api) # entry_not_found Source: https://capacms.com/docs/errors/entry_not_found 404 not_found. No entry with that id is visible to this key. | HTTP status | `type` | Surfaces | | ----------- | ----------- | -------- | | 404 | `not_found` | REST | ## What it means No entry with that id is visible to this key. A production key sees published entries only. | When | What the `hint` tells you | | -------------------------- | ------------------------------------------------------------------------- | | no such entry for this key | development keys read drafts, production keys read published entries only | ## What to do Check the id, or use a development key to read drafts. ## The response HTTP 404: ```json { "error": { "type": "not_found", "code": "entry_not_found", "message": "articles has no entry 8f1c1f8e-6d3a-4a9e-9a3b-2f0a6b7c1d20.", "docs": "https://docs.capacms.com/errors/entry_not_found" }, "meta": { "version": "2026-10-01", "contract": 1, "requestId": "req_0f3c…" } } ``` ## See also * [Drafts and environments](https://capacms.com/docs/api/entries#drafts-and-environments) # graphql_parse_failed Source: https://capacms.com/docs/errors/graphql_parse_failed 400 invalid_request. The GraphQL document has a syntax error. | HTTP status | `type` | Surfaces | | ----------- | ----------------- | -------- | | 400 | `invalid_request` | GraphQL | ## What it means The GraphQL document has a syntax error. ## What to do Fix the text at the line and column given. ## On GraphQL `/api/graphql` answers the same `code` inside `errors[].extensions`. The status depends on the `Accept` header you send ([status codes](https://capacms.com/docs/api/graphql#status-codes)): | `application/json` | `graphql-response+json` | When | | ------------------ | ----------------------- | -------------------------------------------------------------------------------------------------- | | 200 | 400 | a syntax error, with `locations` and a hint that names them: `Fix the syntax at line 3, column 1.` | ## See also * [GraphQL](https://capacms.com/docs/api/graphql) # graphql_validation_failed Source: https://capacms.com/docs/errors/graphql_validation_failed 400 invalid_request. The document parses but asks for something the key's schema does not have: a field, an argument, a type or a missing variable. | HTTP status | `type` | Surfaces | | ----------- | ----------------- | -------- | | 400 | `invalid_request` | GraphQL | ## What it means The document parses but asks for something the key's schema does not have: a field, an argument, a type or a missing variable. ## What to do Use the names the message suggests; the schema only holds models this key can read. ## On GraphQL `/api/graphql` answers the same `code` inside `errors[].extensions`. The status depends on the `Accept` header you send ([status codes](https://capacms.com/docs/api/graphql#status-codes)): | `application/json` | `graphql-response+json` | When | | ------------------ | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | 200 | 400 | an unknown field, a wrong argument type, a subscription anywhere in the document, an unnamed operation beside another. One error per problem, at most 20 | ## See also * [GraphQL](https://capacms.com/docs/api/graphql) # Errors Source: https://capacms.com/docs/errors The /api/ error envelope and every code it can carry, with what each one means and what to do. Every refusal on `/api/` is one JSON envelope, and every envelope carries a `code` to branch on, a `hint` that says what to do next, and a `docs` link to the page for its code. `/api/graphql` sends the same fields inside each error's `extensions`. ```json { "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…" } } ``` Branch on `code`, never on `message`: messages are written for people and may be reworded. `type` is the coarse class, stable for each status. A `500` never carries details; quote `meta.requestId` to Capa support. The full envelope is in the [API reference](https://capacms.com/docs/api#errors). ## All 31 codes | Code | Status | `type` | Meaning | | ----------------------------------------------------------------------------- | ------ | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [`persisted_query_not_found`](https://capacms.com/docs/errors/persisted_query_not_found) | 200 | `invalid_request` | No document is stored for that hash. | | [`invalid_version`](https://capacms.com/docs/errors/invalid_version) | 400 | `invalid_request` | Capa-Version names a date Capa does not serve. | | [`unknown_field`](https://capacms.com/docs/errors/unknown_field) | 400 | `invalid_request` | A select, filter or sort names a field the model does not have. | | [`invalid_filter_value`](https://capacms.com/docs/errors/invalid_filter_value) | 400 | `invalid_request` | A filter value does not fit the field's type, for example text for a number or a non-ISO date. | | [`invalid_operator`](https://capacms.com/docs/errors/invalid_operator) | 400 | `invalid_request` | The filter or sort uses an operator this field type does not support. | | [`invalid_cursor`](https://capacms.com/docs/errors/invalid_cursor) | 400 | `invalid_request` | The after or before cursor is malformed, was minted for another sort, or belongs to another parent entry. | | [`invalid_select`](https://capacms.com/docs/errors/invalid_select) | 400 | `invalid_request` | The selection cannot be planned, for example a relation modifier where it does not apply. | | [`invalid_parameter`](https://capacms.com/docs/errors/invalid_parameter) | 400 | `invalid_request` | A request parameter is missing or out of range: first outside 1 to 200, more than 3 sort values, a non-UUID id, bad JSON in variables. | | [`query_too_complex`](https://capacms.com/docs/errors/query_too_complex) | 400 | `invalid_request` | The query is over a budget, and the message says which, what it measured and the limit. | | [`count_unavailable`](https://capacms.com/docs/errors/count_unavailable) | 400 | `invalid_request` | totalCount could not be computed cheaply for this filter (a relation filter matching over 50,000 entries). | | [`graphql_parse_failed`](https://capacms.com/docs/errors/graphql_parse_failed) | 400 | `invalid_request` | The GraphQL document has a syntax error. | | [`graphql_validation_failed`](https://capacms.com/docs/errors/graphql_validation_failed) | 400 | `invalid_request` | The document parses but asks for something the key's schema does not have: a field, an argument, a type or a missing variable. | | [`persisted_query_hash_mismatch`](https://capacms.com/docs/errors/persisted_query_hash_mismatch) | 400 | `invalid_request` | The sha256 sent does not match the query text sent. | | [`missing_key`](https://capacms.com/docs/errors/missing_key) | 401 | `authentication` | The request carried no x-api-key header. | | [`invalid_key`](https://capacms.com/docs/errors/invalid_key) | 401 | `authentication` | The key is unknown, revoked or expired. | | [`preview_token_invalid`](https://capacms.com/docs/errors/preview_token_invalid) | 401 | `authentication` | The preview token is forged, mangled or for another project. | | [`preview_token_expired`](https://capacms.com/docs/errors/preview_token_expired) | 401 | `authentication` | The preview token was real but has expired. | | [`subscription_required`](https://capacms.com/docs/errors/subscription_required) | 402 | `payment` | The project's plan does not include API access right now. | | [`scope_missing`](https://capacms.com/docs/errors/scope_missing) | 403 | `permission` | The key is valid but lacks the scope this route needs. | | [`origin_refused`](https://capacms.com/docs/errors/origin_refused) | 403 | `permission` | The key is restricted to other origins than the one this request came from. | | [`edge_only`](https://capacms.com/docs/errors/edge_only) | 403 | `permission` | The request reached the origin directly. | | [`contract_not_found`](https://capacms.com/docs/errors/contract_not_found) | 404 | `not_found` | Capa-Contract names a contract this project does not have. | | [`model_not_found`](https://capacms.com/docs/errors/model_not_found) | 404 | `not_found` | The model in the path does not exist, or this key cannot read it. | | [`entry_not_found`](https://capacms.com/docs/errors/entry_not_found) | 404 | `not_found` | No entry with that id is visible to this key. | | [`page_not_found`](https://capacms.com/docs/errors/page_not_found) | 404 | `not_found` | No model declares that page and no read has reported it. | | [`route_not_found`](https://capacms.com/docs/errors/route_not_found) | 404 | `not_found` | No route under `/api/` matches the method and path you sent. | | [`mutations_not_enabled`](https://capacms.com/docs/errors/mutations_not_enabled) | 405 | `method` | The request tried to write. | | [`rate_limit_exceeded`](https://capacms.com/docs/errors/rate_limit_exceeded) | 429 | `rate_limited` | Capa refused the request to protect the service, for one of two reasons the message names: too many reads running at once (the API's read gate, per client, per key or per project), or too many uncached requests from this key in a minute (the CDN's limit). | | [`internal`](https://capacms.com/docs/errors/internal) | 500 | `api_error` | Capa hit an unexpected error. | | [`service_unavailable`](https://capacms.com/docs/errors/service_unavailable) | 503 | `api_error` | The API was busy with other projects' reads for the whole database budget, or had no database connection free in time. | | [`query_timeout`](https://capacms.com/docs/errors/query_timeout) | 504 | `api_error` | The database stopped the query at the statement timeout. | # internal Source: https://capacms.com/docs/errors/internal 500 api_error. Capa hit an unexpected error. | HTTP status | `type` | Surfaces | | ----------- | ----------- | ------------- | | 500 | `api_error` | REST, GraphQL | ## What it means Capa hit an unexpected error. The body has no details, by design. | When | What the `hint` tells you | | --------- | ------------------------------- | | our fault | no hint: quote `meta.requestId` | ## What to do Retry once. If it repeats, report the requestId to Capa support. ## The response HTTP 500: ```json { "error": { "type": "api_error", "code": "internal", "message": "Internal error.", "docs": "https://docs.capacms.com/errors/internal" }, "meta": { "version": "2026-10-01", "contract": 1, "requestId": "req_0f3c…" } } ``` ## On GraphQL `/api/graphql` answers the same `code` inside `errors[].extensions`. The status depends on the `Accept` header you send ([status codes](https://capacms.com/docs/api/graphql#status-codes)): | `application/json` | `graphql-response+json` | When | | ---------------------- | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 500, or 200 on a field | 500, or 200 on a field | our bug. No details, quote the `requestId`. A bug met while answering one field nulls that field, with `path` set, and the rest of `data` is still answered with a 200 | # invalid_cursor Source: https://capacms.com/docs/errors/invalid_cursor 400 invalid_request. The after or before cursor is malformed, was minted for another sort, or belongs to another parent entry. | HTTP status | `type` | Surfaces | | ----------- | ----------------- | ------------- | | 400 | `invalid_request` | REST, GraphQL | ## What it means The after or before cursor is malformed, was minted for another sort, or belongs to another parent entry. | When | What the `hint` tells you | | ------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- | | a tampered, foreign-version, wrong-sort or wrong-parent cursor, or `after` and `before` together | request the first page again without a cursor; for `after` and `before` together, send one of them | ## What to do Pass a cursor from a page of the same query, unchanged, with the same sort: its endCursor as after, or its startCursor as before. ## The response HTTP 400: ```json { "error": { "type": "invalid_request", "code": "invalid_cursor", "message": "This cursor is not valid.", "param": "after", "docs": "https://docs.capacms.com/errors/invalid_cursor" }, "meta": { "version": "2026-10-01", "contract": 1, "requestId": "req_0f3c…" } } ``` ## On GraphQL `/api/graphql` answers the same `code` inside `errors[].extensions`. The status depends on the `Accept` header you send ([status codes](https://capacms.com/docs/api/graphql#status-codes)): | `application/json` | `graphql-response+json` | When | | ------------------ | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 200 | 400 | REST planner refusals, with `path` set to the root field. `invalid_select` also refuses one relation list read twice in an entry with different arguments (see [Not built yet](https://capacms.com/docs/api/graphql#not-built-yet)) | ## See also * [Paging](https://capacms.com/docs/api/entries#paging) # invalid_filter_value Source: https://capacms.com/docs/errors/invalid_filter_value 400 invalid_request. A filter value does not fit the field's type, for example text for a number or a non-ISO date. | HTTP status | `type` | Surfaces | | ----------- | ----------------- | ------------- | | 400 | `invalid_request` | REST, GraphQL | ## What it means A filter value does not fit the field's type, for example text for a number or a non-ISO date. | When | What the `hint` tells you | | ------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | | a value of the wrong shape, `where` that is not a JSON object, or a part of `where` of the wrong shape, which the message names (`where.not is null.`) | the expected form: a number, `true` or `false`, an ISO 8601 date, a UUID, a comma-separated list; for `where`, a worked example of the object | ## What to do Send numbers for number fields, true or false for true_false fields, and ISO 8601 dates. ## On GraphQL `/api/graphql` answers the same `code` inside `errors[].extensions`. The status depends on the `Accept` header you send ([status codes](https://capacms.com/docs/api/graphql#status-codes)): | `application/json` | `graphql-response+json` | When | | ------------------ | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 200 | 400 | REST planner refusals, with `path` set to the root field. `invalid_select` also refuses one relation list read twice in an entry with different arguments (see [Not built yet](https://capacms.com/docs/api/graphql#not-built-yet)) | ## See also * [Filtering](https://capacms.com/docs/api/entries#filtering) # invalid_key Source: https://capacms.com/docs/errors/invalid_key 401 authentication. The key is unknown, revoked or expired. | HTTP status | `type` | Surfaces | | ----------- | ---------------- | ------------- | | 401 | `authentication` | REST, GraphQL | ## What it means The key is unknown, revoked or expired. | When | What the `hint` tells you | | ------------------------------- | ------------------------------------------------------------------------------- | | unknown, revoked or expired key | no hint on REST: check the key's expiry and active flag under Developers > Keys | ## What to do Use a current key from Developers > Keys in the Capa admin. The answer is the same for an unknown, revoked or expired key, so read the key's row there rather than the body. ## The response HTTP 401: ```json { "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…" } } ``` ## On GraphQL `/api/graphql` answers the same `code` inside `errors[].extensions`. The status depends on the `Accept` header you send ([status codes](https://capacms.com/docs/api/graphql#status-codes)): | `application/json` | `graphql-response+json` | When | | ------------------ | ----------------------- | ----------------------------------- | | 401 | 401 | no key, or a key Capa does not know | ## See also * [Keys and scopes](https://capacms.com/docs/api/authentication) # invalid_operator Source: https://capacms.com/docs/errors/invalid_operator 400 invalid_request. The filter or sort uses an operator this field type does not support. | HTTP status | `type` | Surfaces | | ----------- | ----------------- | ------------- | | 400 | `invalid_request` | REST, GraphQL | ## What it means The filter or sort uses an operator this field type does not support. | When | What the `hint` tells you | | -------------------------------------------------------------------- | -------------------------------------------------------- | | an operator the type does not take, or an unsortable field in `sort` | the operators that type does take, or the sortable types | ## What to do Use one of the operators the hint lists for that field. ## On GraphQL `/api/graphql` answers the same `code` inside `errors[].extensions`. The status depends on the `Accept` header you send ([status codes](https://capacms.com/docs/api/graphql#status-codes)): | `application/json` | `graphql-response+json` | When | | ------------------ | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 200 | 400 | REST planner refusals, with `path` set to the root field. `invalid_select` also refuses one relation list read twice in an entry with different arguments (see [Not built yet](https://capacms.com/docs/api/graphql#not-built-yet)) | ## See also * [Filtering](https://capacms.com/docs/api/entries#filtering) * [Sorting](https://capacms.com/docs/api/entries#sorting) # invalid_parameter Source: https://capacms.com/docs/errors/invalid_parameter 400 invalid_request. A request parameter is missing or out of range: first outside 1 to 200, more than 3 sort values, a non-UUID id, bad JSON in variables. | HTTP status | `type` | Surfaces | | ----------- | ----------------- | ------------- | | 400 | `invalid_request` | REST, GraphQL | ## What it means A request parameter is missing or out of range: first outside 1 to 200, more than 3 sort values, a non-UUID id, bad JSON in variables. | When | What the `hint` tells you | | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | a query parameter the route does not read, `limit` or `count` malformed, a malformed `sort` or more than three sort keys, a repeated query key, or a paging parameter on a single entry | for a legacy parameter, the `/api/` form; for any other name, the parameters there are; the accepted range, the sort grammar, or that only `select` applies here | ## What to do Change the argument the message names to a value the hint allows. ## The response HTTP 400: ```json { "error": { "type": "invalid_request", "code": "invalid_parameter", "message": "limit was sent more than once.", "param": "limit", "docs": "https://docs.capacms.com/errors/invalid_parameter" }, "meta": { "version": "2026-10-01", "contract": 1, "requestId": "req_0f3c…" } } ``` ## On GraphQL `/api/graphql` answers the same `code` inside `errors[].extensions`. The status depends on the `Accept` header you send ([status codes](https://capacms.com/docs/api/graphql#status-codes)): | `application/json` | `graphql-response+json` | When | | ------------------ | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 400 | 400 | the request itself: no `query`; a body that is not JSON; a batch; wrong content type; a body, URL or variables over the limit; `variables` that is not an object; a malformed `extensions.persistedQuery`; an `extensions.capa` that is not `true` or `false`; a `Capa-Persist` other than `pin` or `pin; release=; env=` | | 200 | 400 | the document: variables that do not match their types; `operationName` missing or unknown with several operations; `first`, `last` or `sort` out of range; `first` with `before`, or `last` without `before`, with `first` or with `after`; an id that is not a UUID | ## See also * [Entries](https://capacms.com/docs/api/entries) * [Limits](https://capacms.com/docs/api/limits) # invalid_select Source: https://capacms.com/docs/errors/invalid_select 400 invalid_request. The selection cannot be planned, for example a relation modifier where it does not apply. | HTTP status | `type` | Surfaces | | ----------- | ----------------- | ------------- | | 400 | `invalid_request` | REST, GraphQL | ## What it means The selection cannot be planned, for example a relation modifier where it does not apply. | When | What the `hint` tells you | | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | unbalanced parentheses, an empty item, a duplicate item, parentheses on something that is not a relation, a modifier on a single relation, a malformed `sort:` inside `select`, or an expansion into a model [this key may not read](https://capacms.com/docs/api/entries#per-model-keys) | the grammar, with a worked example; for a name that holds a space or a character the grammar uses, its [quoted form](https://capacms.com/docs/api/entries#field-names) | ## What to do Follow the hint; nest at most 4 relations below the root entry (5 levels of entries). ## On GraphQL `/api/graphql` answers the same `code` inside `errors[].extensions`. The status depends on the `Accept` header you send ([status codes](https://capacms.com/docs/api/graphql#status-codes)): | `application/json` | `graphql-response+json` | When | | ------------------ | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 200 | 400 | REST planner refusals, with `path` set to the root field. `invalid_select` also refuses one relation list read twice in an entry with different arguments (see [Not built yet](https://capacms.com/docs/api/graphql#not-built-yet)) | ## See also * [Choosing fields](https://capacms.com/docs/api/entries#choosing-fields-select) # invalid_version Source: https://capacms.com/docs/errors/invalid_version 400 invalid_request. Capa-Version names a date Capa does not serve. | HTTP status | `type` | Surfaces | | ----------- | ----------------- | ------------- | | 400 | `invalid_request` | REST, GraphQL | ## What it means Capa-Version names a date Capa does not serve. | When | What the `hint` tells you | | -------------------------------------- | ------------------------- | | `Capa-Version` is not a supported date | the versions that exist | ## What to do Use one of the dates in the hint. ## The response HTTP 400: ```json { "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…" } } ``` ## On GraphQL `/api/graphql` answers the same `code` inside `errors[].extensions`. The status depends on the `Accept` header you send ([status codes](https://capacms.com/docs/api/graphql#status-codes)): | `application/json` | `graphql-response+json` | When | | ------------------ | ----------------------- | ------------------------------- | | 400 | 400 | `Capa-Version` names no version | ## See also * [Versions](https://capacms.com/docs/api/versions) # missing_key Source: https://capacms.com/docs/errors/missing_key 401 authentication. The request carried no x-api-key header. | HTTP status | `type` | Surfaces | | ----------- | ---------------- | ------------- | | 401 | `authentication` | REST, GraphQL | ## What it means The request carried no x-api-key header. | When | What the `hint` tells you | | --------------------- | ------------------------------------------------------------------- | | no `x-api-key` header | no hint on REST: send the header, with a key from Developers > Keys | ## What to do Send the key in x-api-key. ## The response HTTP 401: ```json { "error": { "type": "authentication", "code": "missing_key", "message": "An API key is required. Send it in the x-api-key header.", "docs": "https://docs.capacms.com/errors/missing_key" }, "meta": { "version": "2026-10-01", "contract": 1, "requestId": "req_0f3c…" } } ``` ## On GraphQL `/api/graphql` answers the same `code` inside `errors[].extensions`. The status depends on the `Accept` header you send ([status codes](https://capacms.com/docs/api/graphql#status-codes)): | `application/json` | `graphql-response+json` | When | | ------------------ | ----------------------- | ----------------------------------- | | 401 | 401 | no key, or a key Capa does not know | ## See also * [Keys and scopes](https://capacms.com/docs/api/authentication) # model_not_found Source: https://capacms.com/docs/errors/model_not_found 404 not_found. The model in the path does not exist, or this key cannot read it. | HTTP status | `type` | Surfaces | | ----------- | ----------- | -------- | | 404 | `not_found` | REST | ## What it means The model in the path does not exist, or this key cannot read it. | When | What the `hint` tells you | | ---------------------------- | -------------------------------- | | no model with that namespace | the namespaces this key can read | ## What to do Use a namespace from the model list. ## The response HTTP 404: ```json { "error": { "type": "not_found", "code": "model_not_found", "message": "No model products.", "docs": "https://docs.capacms.com/errors/model_not_found" }, "meta": { "version": "2026-10-01", "contract": 1, "requestId": "req_0f3c…" } } ``` ## See also * [Entries](https://capacms.com/docs/api/entries) # mutations_not_enabled Source: https://capacms.com/docs/errors/mutations_not_enabled 405 method. The request tried to write. | HTTP status | `type` | Surfaces | | ----------- | -------- | ------------- | | 405 | `method` | REST, GraphQL | ## What it means The request tried to write. `/api/` reads only for now, and Capa's GraphQL API reads only. | When | What the `hint` tells you | | ---------------------------- | ------------------------------------------- | | a POST, PUT, PATCH or DELETE | no hint on REST: writes are not enabled yet | ## What to do Send a GET, or a GraphQL query rather than a mutation. Content is edited in the Capa admin, or through the session routes for scheduling, webhooks and workspaces. ## The response HTTP 405: ```json { "error": { "type": "method", "code": "mutations_not_enabled", "message": "Writes are not enabled on /api/ yet.", "docs": "https://docs.capacms.com/errors/mutations_not_enabled" }, "meta": { "version": "2026-10-01", "contract": 1, "requestId": "req_0f3c…" } } ``` ## On GraphQL `/api/graphql` answers the same `code` inside `errors[].extensions`. The status depends on the `Accept` header you send ([status codes](https://capacms.com/docs/api/graphql#status-codes)): | `application/json` | `graphql-response+json` | When | | ------------------ | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 405 | 405 | a `mutation` operation anywhere in the document, whichever operation `operationName` picks. The `Allow` header names the methods the route answers, `GET, POST` | ## See also * [API reference](https://capacms.com/docs/api) # origin_refused Source: https://capacms.com/docs/errors/origin_refused 403 permission. The key is restricted to other origins than the one this request came from. | HTTP status | `type` | Surfaces | | ----------- | ------------ | ------------- | | 403 | `permission` | REST, GraphQL | ## What it means The key is restricted to other origins than the one this request came from. | When | What the `hint` tells you | | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | the key is bound to origins and this `Origin` is not one | no hint on REST; the message names the refused origin. Add it to the key under Developers > Keys, or send the request from a server | ## What to do Call from an allowed origin, or use a key without an origin list for server-side reads. ## On GraphQL `/api/graphql` answers the same `code` inside `errors[].extensions`. The status depends on the `Accept` header you send ([status codes](https://capacms.com/docs/api/graphql#status-codes)): | `application/json` | `graphql-response+json` | When | | ------------------ | ----------------------- | ----------------------------------------------------------- | | 403 | 403 | the key's origin rules or the edge lock refused the request | ## See also * [Keys and scopes](https://capacms.com/docs/api/authentication) # page_not_found Source: https://capacms.com/docs/errors/page_not_found 404 not_found. No model declares that page and no read has reported it. > This code comes from preview. See [Preview](https://capacms.com/docs/preview). | HTTP status | `type` | Surfaces | | ----------- | ----------- | -------- | | 404 | `not_found` | REST | ## What it means No model declares that page and no read has reported it. | When | | ------------------------------------------------------------------------------------ | | `GET /api/pages/{page}` names a page that no model declares and no read has reported | ## What to do Use a page from the page list. # persisted_query_hash_mismatch Source: https://capacms.com/docs/errors/persisted_query_hash_mismatch 400 invalid_request. The sha256 sent does not match the query text sent. | HTTP status | `type` | Surfaces | | ----------- | ----------------- | -------- | | 400 | `invalid_request` | GraphQL | ## What it means The sha256 sent does not match the query text sent. ## What to do Hash the exact query string, UTF-8, lowercase hex. ## On GraphQL `/api/graphql` answers the same `code` inside `errors[].extensions`. The status depends on the `Accept` header you send ([status codes](https://capacms.com/docs/api/graphql#status-codes)): | `application/json` | `graphql-response+json` | When | | ------------------ | ----------------------- | ---------------------------------------------- | | 400 | 400 | the sha256 of `query` is not the hash you sent | ## See also * [Persisted queries](https://capacms.com/docs/api/graphql#persisted-queries) # persisted_query_not_found Source: https://capacms.com/docs/errors/persisted_query_not_found 200 invalid_request. No document is stored for that hash. | HTTP status | `type` | Surfaces | | ----------- | ----------------- | -------- | | 200 | `invalid_request` | GraphQL | ## What it means No document is stored for that hash. Only a development key stores one; a production key never does. ## What to do Send the query text beside the same hash: it runs either way, and the SDK and Apollo do this for you. To stop the miss, register the documents at build time with a development key (capa persist). ## On GraphQL `/api/graphql` answers the same `code` inside `errors[].extensions`. The status depends on the `Accept` header you send ([status codes](https://capacms.com/docs/api/graphql#status-codes)): | `application/json` | `graphql-response+json` | When | | ------------------ | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | | 200 | 200 | the hash is not registered, or the document stored for it does not run with this key as sent. The message is exactly `PersistedQueryNotFound` | ## See also * [Persisted queries](https://capacms.com/docs/api/graphql#persisted-queries) * [capa persist](https://capacms.com/docs/sdk/cli) # preview_token_expired Source: https://capacms.com/docs/errors/preview_token_expired 401 authentication. The preview token was real but has expired. > This code comes from preview. See [Preview](https://capacms.com/docs/preview). | HTTP status | `type` | Surfaces | | ----------- | ---------------- | -------- | | 401 | `authentication` | REST | ## What it means The preview token was real but has expired. | When | | ------------------ | | older than an hour | ## What to do Open a fresh preview link from the Capa editor. # preview_token_invalid Source: https://capacms.com/docs/errors/preview_token_invalid 401 authentication. The preview token is forged, mangled or for another project. > This code comes from preview. See [Preview](https://capacms.com/docs/preview). | HTTP status | `type` | Surfaces | | ----------- | ---------------- | -------- | | 401 | `authentication` | REST | ## What it means The preview token is forged, mangled or for another project. | When | | -------------------------------------------------------------- | | not a Capa token, tampered with, or minted for another project | ## What to do Open a fresh preview link from the Capa editor. # query_timeout Source: https://capacms.com/docs/errors/query_timeout 504 api_error. The database stopped the query at the statement timeout. | HTTP status | `type` | Surfaces | | ----------- | ----------- | ------------- | | 504 | `api_error` | REST, GraphQL | ## What it means The database stopped the query at the statement timeout. Later root fields were not run. | When | What the `hint` tells you | | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | | the read hit the server-side statement timeout (5 seconds by default) | narrow the filter, expand fewer relations or request a smaller `limit`, then retry | ## What to do Ask for fewer entries or fewer nested relations, or filter on fewer relation hops. ## The response HTTP 504: ```json { "error": { "type": "api_error", "code": "query_timeout", "message": "The query took too long.", "docs": "https://docs.capacms.com/errors/query_timeout" }, "meta": { "version": "2026-10-01", "contract": 1, "requestId": "req_0f3c…" } } ``` ## On GraphQL `/api/graphql` answers the same `code` inside `errors[].extensions`. The status depends on the `Accept` header you send ([status codes](https://capacms.com/docs/api/graphql#status-codes)): | `application/json` | `graphql-response+json` | When | | ------------------ | ----------------------- | -------------------------------------------------------------------------------- | | 200 | 200 | a root field hit the statement timeout; it and every later root field are `null` | ## See also * [Limits](https://capacms.com/docs/api/limits) # query_too_complex Source: https://capacms.com/docs/errors/query_too_complex 400 invalid_request. The query is over a budget, and the message says which, what it measured and the limit. | HTTP status | `type` | Surfaces | | ----------- | ----------------- | ------------- | | 400 | `invalid_request` | REST, GraphQL | ## What it means The query is over a budget, and the message says which, what it measured and the limit. The budgets: 5,000 entries (each totalCount and each filter or sort through a relation costs 500 more), 10 root fields, 25 lists, 1,000 fields, depth 8, 32 KB documents, entries 5 levels deep (4 relations below the root), 12 relations expanded per root field, 200 values per list operator, 50 filter conditions nested at most 8 deep. | When | What the `hint` tells you | | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | past 5 levels of entries, 12 expansions or 5,000 nodes | for depth, the 5 levels and to read the deeper entries with a second request; for nodes, the list to change and a `limit:` that fits; otherwise the three caps, and to lower a `limit:` or expand fewer relations | ## What to do Ask for fewer fields, a smaller first, or fewer nested relations; split one query into several. ## The response HTTP 400: ```json { "error": { "type": "invalid_request", "code": "query_too_complex", "message": "This request could return 5,025 entries, over the limit of 5,000.", "docs": "https://docs.capacms.com/errors/query_too_complex" }, "meta": { "version": "2026-10-01", "contract": 1, "requestId": "req_0f3c…" } } ``` ## On GraphQL `/api/graphql` answers the same `code` inside `errors[].extensions`. The status depends on the `Accept` header you send ([status codes](https://capacms.com/docs/api/graphql#status-codes)): | `application/json` | `graphql-response+json` | When | | ------------------ | ----------------------- | ----------------------------------------------------- | | 200 | 400, or 200 on a field | any limit above; after SQL, more than 5,000 rows read | ## See also * [Limits](https://capacms.com/docs/api/limits) * [GraphQL limits](https://capacms.com/docs/api/graphql#limits) * [GraphQL cost](https://capacms.com/docs/api/graphql#cost) # rate_limit_exceeded Source: https://capacms.com/docs/errors/rate_limit_exceeded 429 rate_limited. Capa refused the request to protect the service, for one of two reasons the message names: too many reads running at once (the API's read gate, per client, per key or per project), or too many uncached requests from this key in a minute (the CDN's limit). | HTTP status | `type` | Surfaces | | ----------- | -------------- | ------------- | | 429 | `rate_limited` | REST, GraphQL | ## What it means Capa refused the request to protect the service, for one of two reasons the message names: too many reads running at once (the API's read gate, per client, per key or per project), or too many uncached requests from this key in a minute (the CDN's limit). | When | What the `hint` tells you | | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- | | one client with 3 reads running with a key and 64 more waiting, the client with the most reads waiting when a key has 4 running and 256 waiting across its clients (a client with fewer waiting takes that client's newest place), a project whose keys together have 4 reads running (`This project has too many reads running at once across its keys.`), or a read that waited out the database budget behind one of those shares. GraphQL documents count toward the same shares, and another client of the key is still served. A client is its address, or its /64 for IPv6 | retry after `Retry-After` | ## What to do Send fewer reads at once and retry after the Retry-After seconds; if the message counts requests in a minute, also cache GET responses, since cached reads are not counted. ## The response HTTP 429: ```json { "error": { "type": "rate_limited", "code": "rate_limit_exceeded", "message": "This client has too many reads running at once with this key.", "docs": "https://docs.capacms.com/errors/rate_limit_exceeded" }, "meta": { "version": "2026-10-01", "contract": 1, "requestId": "req_0f3c…" } } ``` ## On GraphQL `/api/graphql` answers the same `code` inside `errors[].extensions`. The status depends on the `Accept` header you send ([status codes](https://capacms.com/docs/api/graphql#status-codes)): | `application/json` | `graphql-response+json` | When | | ------------------ | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 429 | 429 | your client already has 3 reads running with the key and 64 waiting (`This client has too many reads running at once with this key.`), your client is the one with the most reads waiting when the key has 4 running and 256 waiting across its clients (a client with fewer waiting takes the place of that client's newest read), your project's keys together have 4 reads running (`This project has too many reads running at once across its keys.`), your project has had 10 introspection answers computed and asks for another within the second (`This project has had too many different introspection answers computed in a short time.`), or a document waited longer than the database budget for a slot while one of those shares was full. GraphQL documents and `/api/entries` reads share them. Another client of the same key is still served. A client is its address, or its /64 for IPv6. `Retry-After` says when to try again | ## See also * [Limits](https://capacms.com/docs/api/limits) * [Caching](https://capacms.com/docs/api/entries#caching) # route_not_found Source: https://capacms.com/docs/errors/route_not_found 404 not_found. No route under /api/ matches the method and path you sent. | HTTP status | `type` | Surfaces | | ----------- | ----------- | ------------- | | 404 | `not_found` | REST, GraphQL | ## What it means No route under `/api/` matches the method and path you sent. | When | | --------------------------------------------------- | | no such route under `/api/` on the host you reached | ## What to do Check the path against the [API reference](https://capacms.com/docs/api). ## The response HTTP 404: ```json { "error": { "type": "not_found", "code": "route_not_found", "message": "No route GET /api/entry.", "docs": "https://docs.capacms.com/errors/route_not_found" }, "meta": { "version": "2026-10-01", "contract": 1, "requestId": "req_0f3c…" } } ``` ## On GraphQL `/api/graphql` answers the same `code` inside `errors[].extensions`. The status depends on the `Accept` header you send ([status codes](https://capacms.com/docs/api/graphql#status-codes)): | `application/json` | `graphql-response+json` | When | | ------------------ | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 404 | 404 | GraphQL is switched off on this host, for a GET. A POST then answers `405 mutations_not_enabled` in the REST envelope, as every write to an unknown `/api/` path does | ## See also * [API reference](https://capacms.com/docs/api) # scope_missing Source: https://capacms.com/docs/errors/scope_missing 403 permission. The key is valid but lacks the scope this route needs. | HTTP status | `type` | Surfaces | | ----------- | ------------ | -------- | | 403 | `permission` | REST | ## What it means The key is valid but lacks the scope this route needs. | When | What the `hint` tells you | | ---------------------------------------------------- | -------------------------------------------- | | the key does not hold `instance:read` for this model | the scope to add, unscoped or for this model | ## What to do The hint names the scope. Use a key that holds it, or ask for one. ## The response HTTP 403: ```json { "error": { "type": "permission", "code": "scope_missing", "message": "This key cannot do instance:read.", "hint": "This key needs instance:read, or instance:read:00000000-0000-4000-8000-000000000010 for authors.", "docs": "https://docs.capacms.com/errors/scope_missing" }, "meta": { "version": "2026-10-01", "contract": 1, "requestId": "req_0f3c…" } } ``` ## See also * [Keys and scopes](https://capacms.com/docs/api/authentication) # service_unavailable Source: https://capacms.com/docs/errors/service_unavailable 503 api_error. The API was busy with other projects' reads for the whole database budget, or had no database connection free in time. | HTTP status | `type` | Surfaces | | ----------- | ----------- | ------------- | | 503 | `api_error` | REST, GraphQL | ## What it means The API was busy with other projects' reads for the whole database budget, or had no database connection free in time. Nothing in the request is wrong. | When | What the `hint` tells you | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | | the API process was full of other projects' reads for the whole database budget, the last slot being kept for a project with none running, or had no database connection free in time | nothing in the request is wrong: retry after `Retry-After` | ## What to do Retry after the `Retry-After` seconds. ## The response HTTP 503: ```json { "error": { "type": "api_error", "code": "service_unavailable", "message": "The API is busy with other reads.", "docs": "https://docs.capacms.com/errors/service_unavailable" }, "meta": { "version": "2026-10-01", "contract": 1, "requestId": "req_0f3c…" } } ``` ## On GraphQL `/api/graphql` answers the same `code` inside `errors[].extensions`. The status depends on the `Accept` header you send ([status codes](https://capacms.com/docs/api/graphql#status-codes)): | `application/json` | `graphql-response+json` | When | | ------------------ | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 503 | 503 | the API process was full of other projects' reads for the whole database budget, the last slot being kept for a project with none running, or the database had no connection free in time. Nothing in the request is wrong: retry after `Retry-After`. There is no `data` | ## See also * [Limits](https://capacms.com/docs/api/limits) # subscription_required Source: https://capacms.com/docs/errors/subscription_required 402 payment. The project's plan does not include API access right now. | HTTP status | `type` | Surfaces | | ----------- | --------- | ------------- | | 402 | `payment` | REST, GraphQL | ## What it means The project's plan does not include API access right now. | When | What the `hint` tells you | | -------------------------------------- | ------------------------------------ | | the project has no active subscription | check the plan on Settings > Billing | ## What to do The project owner has to renew or upgrade the plan. Nothing in the request can change this. ## On GraphQL `/api/graphql` answers the same `code` inside `errors[].extensions`. The status depends on the `Accept` header you send ([status codes](https://capacms.com/docs/api/graphql#status-codes)): | `application/json` | `graphql-response+json` | When | | ------------------ | ----------------------- | --------------------------------------- | | 402 | 402 | the project's plan does not allow reads | ## See also * [Pricing](https://capacms.com/pricing) # unknown_field Source: https://capacms.com/docs/errors/unknown_field 400 invalid_request. A select, filter or sort names a field the model does not have. | HTTP status | `type` | Surfaces | | ----------- | ----------------- | ------------- | | 400 | `invalid_request` | REST, GraphQL | ## What it means A select, filter or sort names a field the model does not have. | When | What the `hint` tells you | | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | | a name in `select`, `filter`, `where` or `sort` is not a field of that model, or a `filter` or `sort` hop into a model [this key may not read](https://capacms.com/docs/api/entries#per-model-keys) | every field of the model, and the system keys at the root | ## What to do Use the field names the hint lists. ## The response HTTP 400: ```json { "error": { "type": "invalid_request", "code": "unknown_field", "message": "articles has no field \"subtitle\" in contract 1.", "param": "select", "hint": "Fields: title, body, views, featured, tags, author, coauthors. System keys: id, model, status, createdAt, updatedAt, publishedAt, version, folder, $tags.", "docs": "https://docs.capacms.com/errors/unknown_field" }, "meta": { "version": "2026-10-01", "contract": 1, "requestId": "req_0f3c…" } } ``` ## On GraphQL `/api/graphql` answers the same `code` inside `errors[].extensions`. The status depends on the `Accept` header you send ([status codes](https://capacms.com/docs/api/graphql#status-codes)): | `application/json` | `graphql-response+json` | When | | ------------------ | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 200 | 400 | REST planner refusals, with `path` set to the root field. `invalid_select` also refuses one relation list read twice in an entry with different arguments (see [Not built yet](https://capacms.com/docs/api/graphql#not-built-yet)) | ## See also * [Choosing fields](https://capacms.com/docs/api/entries#choosing-fields-select) * [Filtering](https://capacms.com/docs/api/entries#filtering) # Legacy API: /v2/api and /v3/api Source: https://capacms.com/docs/legacy The frozen read API existing sites use: keys, routes, parameters, the response shape, caching and errors. `/v2/api` and `/v3/api` are the read API every Capa site in production uses today. This page is its whole contract: keys, routes, every parameter, the response shape, caching, errors, and the edge cases that surprise people. The surface is frozen. Its responses are byte for byte what the legacy platform served, apart from the privacy and tenant-isolation fixes listed under [Fixed on this platform](https://capacms.com/docs/legacy/october-2026#fixed-on-this-platform). This page describes what it does. Where that is odd, the page says so rather than papering over it, because your site already depends on the odd part. Starting something new? Use [`/api/`](https://capacms.com/docs/api) instead. See [Moving to the new API](#moving-to-the-new-api). | Route | What it answers | | ------------------------------- | ------------------------------------------------- | | `GET /v2/api/{namespace}` | a page of entries of one model | | `GET /v2/api/{entryId}` | one entry, by its id | | `GET /v2/api/{modelId}` | a page of entries, with the model named by its id | | `GET /v2/api/{namespace}/types` | the model's field names and types | | `GET /v2/api/search` | full-text search across the project | Every route exists under `/v3/api` too, with the same parameters. The few differences are in [v2 and v3](#v2-and-v3). The examples read a small blog. `articles` has `title`, `slug`, `published_date`, `excerpt`, `body` and `author`, a relation to `authors`. `authors` has `name`, `slug` and `bio`. Where a rule needs a field type the blog does not have, the example names one: a number field `views`, a true/false field `featured`, a list field `tags`, and `coauthors`, a list of `authors`. ## A first request ```bash export CAPA_KEY=... # your pk_ key, from your secret store curl 'https://cdn.capacms.com/v2/api/articles?slug=a-preview-you-can-trust' \ -H "x-api-key: $CAPA_KEY" ``` Send every `/v2/api` and `/v3/api` request to `cdn.capacms.com`, Capa's CDN. Once the origin lock is enforced, `/v2/api`, `/v3/api` and `/files` answer only through the CDN, and a request sent straight to Capa's servers gets the [`403`](#errors) for direct origin access. ```json { "data": [ { "id": "61001acc-e032-48cb-aeff-0f2d0bb69e6b", "modelId": "3a96922b-d031-41fb-955b-7ca6434d10cd", "data": { "body": { "type": "markdown", "value": "Plenty of editors have learned to…", "sortOrder": 5 }, "slug": { "type": "string", "value": "a-preview-you-can-trust", "sortOrder": 1 }, "title": { "type": "string", "value": "A Preview You Can Trust", "sortOrder": 0 }, "author": { "type": "relation", "value": "854e6620-778b-4750-a93c-556e2e1516c4", "sortOrder": 4 }, "excerpt": { "type": "string", "value": "A preview is only useful if it is the real page…", "sortOrder": 3 }, "published_date": { "type": "date", "value": "2026-08-18", "sortOrder": 2 } }, "draft": null, "createdAt": "2026-09-23T04:36:07.033Z", "updatedAt": "2026-09-25T18:32:14.190Z", "tags": [], "title": { "type": "string", "value": "A Preview You Can Trust", "sortOrder": 0 }, "deletedAt": null, "indexed": true, "integrationGenerated": false, "sortOrder": 0, "body": { "type": "markdown", "value": "Plenty of editors have learned to…", "sortOrder": 5 }, "slug": { "type": "string", "value": "a-preview-you-can-trust", "sortOrder": 1 } /* …author, excerpt and published_date again, at the top level */ } ], "relations": {}, "meta": { "total": 1, "totalPages": 1, "currentPage": 1, "hasNextPage": false, "hasPrevPage": false, "limit": 50, "environment": "production", "nestedLimit": 100, "nestedPage": 1, "depth": 0 } } ``` Three things to notice before anything else: * Each field is an object. The content is its `value`. * Every field appears twice: once under `data`, and again at the top level of the entry. Read from `data`. [Why](#the-top-level-copy). * `author` holds an id. Add `depth=1` to get the author's entry, in `relations`. ## Keys Send your key in the `x-api-key` header on every request. Nothing else authenticates: not a query parameter, not `Authorization`. ```bash curl https://cdn.capacms.com/v2/api/articles -H "x-api-key: $CAPA_KEY" ``` A key belongs to one project and has an environment. The environment, and nothing in the request, decides what you can see. | Key | Environment | Sees | Responses are | | ------ | ------------------------------------- | --------------------------------------------- | ------------------------ | | `pk_…` | `production` | published entries, published data | cacheable for 60 seconds | | `sk_…` | anything else. The admin uses `draft` | every entry, the newest data, drafts included | never cached | The prefix is set when the key is minted from its environment, so in practice `pk_` means production and `sk_` means drafts. A key minted before the prefixes existed has none: it is 40 hexadecimal characters, and it sees what its environment allows, as the table says. `meta.environment` on a list response tells you which one you sent. A key's permission (`read`, `write` and so on) does not matter here. Any active legacy key of the project, prefixed or not, can read every model. ### Getting a key ```bash curl -X POST https://api.capacms.com/v2/tenants/api-keys \ -H 'Authorization: Bearer ' \ -H 'content-type: application/json' \ -d '{"environment":"production","permission":"read"}' ``` ```json { "apiKey": "pk_…", "environment": "production", "permission": "read" } ``` This is an admin request, so it goes to `api.capacms.com`, as in [Keys and scopes](https://capacms.com/docs/api/authentication), not to the CDN. The answer is `201`. Send `"environment":"draft"` for an `sk_` key. Leave `scopes` out: with it, the request can mint a `cap_` key, and this API refuses those. On a project with scoped keys switched on, **New key** in the admin mints `cap_` keys only, so mint a `pk_` or `sk_` key with the request above. Rotate, deactivate and origin-bind existing keys in the admin, or through the routes in [Keys and scopes](https://capacms.com/docs/api/authentication). ### What gets refused | You send | You get | | ------------------------------------------------------------- | --------------------------------------------------------------------------- | | no `x-api-key` header | `401 {"error":"API key required"}` | | an unknown, deactivated or expired key | `401 {"error":"Invalid API key"}` | | a `cap_` key from [Keys and scopes](https://capacms.com/docs/api/authentication) | `401 {"error":"Invalid API key"}`. Scoped keys work on `/api/` only | | a key bound to origins, from an `Origin` it does not list | `403 {"error":"This API key is not allowed from https://evil.example.org"}` | | a key whose project has no active subscription | `402`, see [Limits](#limits) | The three `401` bodies for an unknown, deactivated and expired key are identical on purpose. If a key that worked yesterday stops working, look at the key under Developers > Keys, not at the response. Origin binding reads `Origin`, or `Referer` when there is no `Origin`. A request that carries neither still gets an answer: that is a server-side fetch, and binding does not restrict it. `Origin: null` is refused. ### Keys in the browser A `pk_` key reads published content only, which is why sites ship one in client code. Anyone holding it can read every published entry of every model. Treat an `sk_` key as a secret: it reads every unpublished draft. Every successful content response echoes the key it was sent with in an `API-Key` response header. Keep that in mind before you log response headers anywhere public. CORS reflects the calling origin. A browser preflight for `x-api-key` answers `204` with `Access-Control-Allow-Origin` set to your origin and `Access-Control-Allow-Credentials: true`. A `pk_` response does not vary on `Origin`, so the CDN caches the `Access-Control-Allow-Origin` of the first site that asked and serves it to the next. When two sites call from the browser with the same key, the second one's requests can fail CORS until the cached copy expires. Give each site its own key, or fetch on your server. ## List entries ```bash curl 'https://cdn.capacms.com/v2/api/articles?limit=2&page=2&sort=-published_date' \ -H "x-api-key: $CAPA_KEY" ``` `{namespace}` is the model's namespace, matched exactly and case-sensitively. `/v2/api/Articles` is `404 {"error":"Model not found"}` when the model is `articles`. There is no trailing slash: `/v2/api/articles/` is a `404` route error. | Parameter | Default | Range | Notes | | -------------------------------- | ----------- | ------------------- | -------------------------------------------------------------------------------- | | `limit` | `50` | 1 to 500 | above 500 is 500. `0` or text is 50. A negative number is 1 | | `page` | `1` | 1 and up | `0` or text is 1 | | `sort` | none | | see [Sorting](#sorting) | | `ids` | none | | comma-separated entry ids, see [Choosing entries by id](#choosing-entries-by-id) | | `id` | none | | one entry id: switches the route to [one entry](#read-one-entry) | | `depth` | `0` | 0 to 4 | above 4 is 4. See [Related entries](#related-entries) | | `nestedLimit` | `100` | 1 to 500 | items per array relation | | `nestedPage` | `1` | 1 and up | which slice of each array relation | | `structure` | `relations` | `relations`, `tree` | v2 only. Anything else is `relations` | | `relationFilter..` | none | | filters the items inside an array relation | | `relatedFilters` | none | | the same, as one JSON object | | any other name | none | | a [field filter](#filtering) if it names a field of the model, otherwise ignored | Out-of-range numbers are corrected, never refused. Nothing on this surface answers `400` for a bad `limit`, `page` or `depth`. ### Paging `meta` on a list tells you where you are. For the request above: ```json "meta": { "total": 6, "totalPages": 3, "currentPage": 2, "hasNextPage": true, "hasPrevPage": true, "limit": 2, "environment": "production", "nestedLimit": 100, "nestedPage": 1, "depth": 0 } ``` `total` counts every entry that matches, across all pages. Walk pages until `hasNextPage` is `false`, or fetch up to 500 at once with `limit=500`. * A page past the end answers `200` with `data: []`. `currentPage` then reports the last page that exists, not the one you asked for, and `hasPrevPage` is `false` from two pages past the end. * A filter that matches nothing answers `total: 0`, `totalPages: 0` and `currentPage: 0`. * Send whole numbers. `meta.limit` echoes `limit=1.5` as `1.5` and `totalPages` is computed from it. The list reads one entry, or two when it is sorted. ### Order **With no `sort`, entries come back in entry-id order.** Ids are random, so for real content that is an arbitrary but stable order, not creation order. If order matters on your page, pass `sort`. ## Read one entry ```bash curl 'https://cdn.capacms.com/v2/api/61001acc-e032-48cb-aeff-0f2d0bb69e6b?depth=1' \ -H "x-api-key: $CAPA_KEY" ``` When the path segment is an entry id, you get that entry. The answer has the same shape as a list, with the entry as the only item in `data`, and a shorter `meta`: ```json "meta": { "nestedLimit": 100, "nestedPage": 1, "depth": 1 } ``` `depth`, `nestedLimit`, `nestedPage`, `structure`, `relationFilter.*`, `relatedFilters` and the `relationSort.` form of `sort` apply. Every other list parameter, and every field filter, is ignored. The same read works as a query parameter on a namespace: ``` GET /v2/api/articles?id=61001acc-e032-48cb-aeff-0f2d0bb69e6b ``` That form also checks the entry belongs to `articles`. A single entry carries more system keys than a list item: `tenantId`, `lastUpdatedBy`, `versionCount`, `currentVersionId`, `publishedVersionId`, `deletedBy`, `deleted` and `folderId`. A list removes them. | You ask for | You get | | --------------------------------------------------------------------------------------------- | -------------------------------------------------- | | an entry id that does not exist, is deleted, or belongs to another project | `404 {"error":"Model not found"}` | | a draft-only entry, with a `pk_` key | `404 {"error":"Model instance version not found"}` | | `?id=` with a value that is not an entry of your project, another project's entry id included | `404 {"error":"Model instance version not found"}` | | `?id=` naming an entry of a different model of your project | `404 {"error":"Model instance not found"}` | The first row answers "Model not found" because the path is tried as an entry id first and then as a model id. ## Read a model by id `GET /v2/api/{modelId}` is the list route with the model named by its id instead of its namespace. Every list parameter works. It is useful when a namespace might be renamed and the id will not. ## Field types ```bash curl https://cdn.capacms.com/v2/api/articles/types -H "x-api-key: $CAPA_KEY" ``` ```json { "type": { "title": "string", "slug": "string", "published_date": "date", "excerpt": "string", "author": "relation", "body": "markdown" } } ``` The keys are the fields' names as shown in the admin, not their namespaces. In this model the two are the same. A field named "Published on" would appear as `"Published on"`. A relation array reports `array`, not its item type. The namespace in the path is lowercased before the lookup, so `/v2/api/ARTICLES/types` works. A model id in the path is not accepted. An unknown model is `404 {"error":"Model not found"}`. This route sends no cache headers of its own. ## The response ### An entry One entry of the `authors` model, from `GET /v2/api/authors?slug=maya-lindqvist`: ```json { "id": "854e6620-778b-4750-a93c-556e2e1516c4", "modelId": "858f79c2-69f1-4d2e-9724-b8115fd62d99", "data": { "bio": { "type": "markdown", "value": "Maya leads the web platform team…", "sortOrder": 3 }, "name": { "type": "string", "value": "Maya Lindqvist", "sortOrder": 1 }, "slug": { "type": "string", "value": "maya-lindqvist", "sortOrder": 2 }, "title": { "type": "string", "value": "Maya Lindqvist", "sortOrder": 0 } }, "draft": null, "createdAt": "2026-09-23T04:35:57.054Z", "updatedAt": "2026-09-23T04:35:57.444Z", "tags": [], "title": { "type": "string", "value": "Maya Lindqvist", "sortOrder": 0 }, "deletedAt": null, "indexed": true, "integrationGenerated": false, "sortOrder": 0, "bio": { "type": "markdown", "value": "Maya leads the web platform team…", "sortOrder": 3 }, "name": { "type": "string", "value": "Maya Lindqvist", "sortOrder": 1 }, "slug": { "type": "string", "value": "maya-lindqvist", "sortOrder": 2 } } ``` | Key | Meaning | | ------------------------ | ---------------------------------------------------------------------------------------------- | | `id` | the entry | | `modelId` | the model it belongs to | | `data` | the fields, by namespace. See [Field values](#field-values) | | `draft` | a legacy column. Ignore it | | `createdAt`, `updatedAt` | ISO 8601, when the entry was created and last saved | | `tags` | the entry's own tags, an array of strings. `[]` when it has none | | `title` | a legacy column, replaced by your `title` field. See [The top-level copy](#the-top-level-copy) | | `deletedAt` | `null` in practice: deleted entries are never returned | | `indexed` | whether the entry is in the search index | | `integrationGenerated` | whether an integration, such as the Shopify sync, created it | | `sortOrder` | an integer the admin uses for manual ordering. `0` by default | `data` is in storage order, not in the order of your model. Each field's `sortOrder` is its position in the model, so sort on it to lay fields out. ### The top-level copy Every key of `data` is also copied onto the entry itself, after the system keys. `entry.data.slug` and `entry.slug` are the same object. The copy wins over a system key of the same name. The admin gives every model a `title` field, so the top-level `title` is your field, not the legacy column. A model with a `tags` field replaces the entry's own tags the same way. A field named `id`, `data` or `createdAt` replaces those. Read from `data`: it holds only your fields, and it is where [`structure=tree`](#structuretree) leaves plain arrays intact. The exception is a model with a field named `data`, whose top-level copy replaces it. Read that model's fields from the top level. The top-level copy is where one extra thing lives: the paging `meta` and `count` of an array relation, when `depth` is at least 1. See [Array relations](#array-relations). ### Field values Entries saved from the Capa admin store each field as `{ "type", "value", "sortOrder" }`. Read `value`, and ignore any key you do not recognise: content imported by other means can carry fewer keys. | Field type | `value` | | ----------------------------------------- | ------------------------------------------------- | | string, markdown, html, code, enum, color | a string | | number | a JSON number | | true_false | `true` or `false` | | date | the string you stored, for example `"2026-08-18"` | | array | a JSON array | | relation | the related entry's id, or `null` | | array of relations | an array of entry ids | | image, video, file | the media record, or `null` | A field can also be `null` itself rather than an object. For example, a field added to a model after an entry was saved can read `null` on that entry. Guard the read: `entry.data.excerpt?.value`. A media value is the file record as the admin stored it: ```json "cover": { "type": "image", "value": { "id": "00000000-0000-4000-8000-000000000051", "alt": "Logo", "url": "https://cdn.capacms.com/file/brand/logo.png", "name": "logo.png", "type": "image", "preview_url": null /* …the rest of the file record */ }, "sortOrder": 9 } ``` When an image or video value has an empty `alt`, the response fills it from the alt text saved on the file in the media library. ## Related entries ```bash curl 'https://cdn.capacms.com/v3/api/articles?slug=a-preview-you-can-trust&depth=1' \ -H "x-api-key: $CAPA_KEY" ``` `depth=1` loads every entry your entries point at, into one map keyed by id. The relation fields keep holding ids, and you look each id up in `relations`: ```json { "data": [ { "id": "61001acc-…", "data": { "author": { "type": "relation", "value": "854e6620-778b-4750-a93c-556e2e1516c4", "sortOrder": 4 } /* … */ } /* … */ } ], "relations": { "854e6620-778b-4750-a93c-556e2e1516c4": { "id": "854e6620-778b-4750-a93c-556e2e1516c4", "modelId": "858f79c2-69f1-4d2e-9724-b8115fd62d99", "data": { "bio": { "type": "markdown", "value": "Maya leads the web platform team…", "sortOrder": 3 }, "name": { "type": "string", "value": "Maya Lindqvist", "sortOrder": 1 }, "slug": { "type": "string", "value": "maya-lindqvist", "sortOrder": 2 }, "title": { "type": "string", "value": "Maya Lindqvist", "sortOrder": 0 } }, "createdAt": "2026-09-23T04:35:57.054Z", "updatedAt": "2026-09-23T04:35:57.444Z", "tags": [], "sortOrder": 0, "modelType": "authors" } }, "meta": { /* … */ "depth": 1 } } ``` ```js const res = await fetch( "https://cdn.capacms.com/v3/api/articles?slug=a-preview-you-can-trust&depth=1", { headers: { "x-api-key": process.env.CAPA_KEY } }, ).then((r) => r.json()); const article = res.data[0]; const author = res.relations[article.data.author.value]; console.log(author?.data.name.value); // "Maya Lindqvist" ``` * `modelType` is the related entry's model namespace. * `relations` is flat. `depth=2` adds the entries those entries point at, into the same map, not nested under their parents. * Each level costs another round of queries, and the map grows with every level. Ask for the depth your page actually renders. * Related entries follow the same draft rule as the entries you asked for. A `pk_` key never sees an unpublished related entry: it has no key in `relations`. In a single relation its id stays in the field, so check for the key before you read it. In an array relation at `depth=1` or more, its id is dropped from `value`. * On `/v2/api`, each related entry is the full stored row: it also carries `tenantId`, `draft`, `lastUpdatedBy`, `versionCount`, `currentVersionId`, `publishedVersionId`, `title`, `deletedAt`, `deletedBy`, `deleted`, `folderId`, `indexed`, `integrationGenerated`, `modelInstanceVersions` (the one version it serves, every field again), `dataModel` (the full model row) and `_processedDepth`. `/v3/api` trims each related entry to the keys shown above. ### Array relations When `depth` is at least 1, each array relation that holds at least one id gains a `meta` and a `count` on the top-level copy. For `coauthors`, with `?depth=1&nestedLimit=1`: ```json "coauthors": { "type": "array", "value": ["048497e0-5ec4-48fd-84d2-83e961824fc5"], "sortOrder": 6, "meta": { "total": 2, "page": 1, "limit": 1, "totalPages": 2, "isPaginated": true, "isEmpty": false }, "count": 2 } ``` The same field under `data` has the sliced `value` and no `meta`. `nestedLimit` and `nestedPage` slice every array relation of every entry the same way, one page at a time. There is no per-field paging. * `meta.total` is the number of items the key can see, before slicing. `count` is the number of ids in the field, or the number a relation filter kept. * `isEmpty` is `true` when `value` came out empty although the field holds ids: a slice past the end, or items the key cannot see. * `relations` still holds every related entry, including the ones outside the current slice. * At `depth=0` nothing is sliced and there is no `meta`: `nestedLimit` and `nestedPage` do nothing. ### Filtering the items of an array relation ``` GET /v2/api/articles?depth=1&relationFilter.coauthors.name=Maya%20Lindqvist ``` This keeps every article and removes from each article's `coauthors` the authors whose `name` is not `Maya Lindqvist`. `relations` loses them too. It never removes an article. The same filter as one JSON value, `{"coauthors.name":"Maya Lindqvist"}`, URL-encoded: ``` GET /v2/api/articles?depth=1&relatedFilters=%7B%22coauthors.name%22%3A%22Maya%20Lindqvist%22%7D ``` Keys are `.`, optionally with an operator: `{"coauthors.name[contains]":"*Lind*"}`. | Operator | Matches | | ------------------------ | ----------------------------------------------------------------------- | | none, or `equals` | exactly | | `notEquals` | anything else | | `contains` | exactly, or as a substring when the value is `*text*`. `a\|b` is either | | `gt`, `gte`, `lt`, `lte` | numbers and dates as numbers and dates, everything else as text | | `range` | `min,max`, inclusive | Four rules decide whether a relation filter does anything: * **It needs `depth=1` or more.** At `depth=0` it clears every relation field of every entry: single relations become `null` and arrays become `[]`. * It applies to array relations only. `relationFilter.author.name=…` on a single relation changes nothing. * It skips a relation field whose target model is recorded by id rather than by namespace. Nearly every field records the namespace. * The nested JSON form `{"coauthors":{"name":"Maya Lindqvist"}}` is not understood. It removes every item. Use the dotted key. ### `structure=tree` On `/v2/api`, `structure=tree` puts the related entries inside the fields that point at them, and drops `relations`: ```js const res = await fetch( "https://cdn.capacms.com/v2/api/articles?slug=a-preview-you-can-trust&depth=1&structure=tree", { headers: { "x-api-key": process.env.CAPA_KEY } }, ).then((r) => r.json()); const article = res.data[0]; article.author.value.data.name.value; // "Maya Lindqvist": the top-level copy holds the entry article.data.author.value; // "854e6620-…": data still holds the id ``` It is shallower than it looks: * The nesting is on the [top-level copy](#the-top-level-copy) only. `data` keeps ids. * It nests one level. At `depth=2` the second level is fetched and then lost, because there is no `relations` map to look it up in. * **It empties every plain array on the top-level copy.** `tags: ["news"]` becomes `tags: []`, because each item is looked up as a related entry and dropped when none is found. Read plain arrays from `data`. * At `depth=0` it empties every array relation on the top-level copy too. * A related entry the key cannot see disappears from an array and stays an id in a single relation. `structure=tree` on `/v2/api/search?extended=true` behaves differently again: it replaces `data` itself, so the nesting is under `data`, the plain arrays under `data` are emptied, and there is no top-level copy. `/v3/api` has no tree format and ignores the parameter. ## Sorting ``` GET /v2/api/articles?sort=-published_date,title ``` Comma-separated field namespaces, `-` for descending. Numbers sort as numbers, `true_false` fields as booleans, everything else as text in a language-aware order. Entries with no value sort last in both directions. | You write | You get | | ---------------------- | ----------------------------------------------------------- | | `sort=title` | by your `title` field, A to Z | | `sort=-published_date` | newest date first, because ISO dates sort correctly as text | | `sort=-featured,views` | featured first, then fewest views | | `sort=author.name` | by a field of the entry a single relation points at | | `sort=createdAt` | nothing useful: system keys are not sortable, only fields | A name that is not a field is not an error. The list comes back in an arbitrary order instead. Entries with equal values also come back in no fixed order, so add a field that is unique, such as `slug`, when you page through a sort. A sorted list shows the same data as the unsorted one. With a `pk_` key that is each entry's published data, and the list is ordered by the published values, including the value `sort=.` reads from the related entry. An unpublished draft never appears and never moves an entry. An `sk_` key sees, and sorts by, each entry's newest data. ### Sorting the items of an array relation ``` GET /v2/api/articles?depth=1&sort=relationSort.coauthors.name GET /v2/api/articles?depth=1&sort=-relationSort.coauthors.name ``` `relationSort..` orders the ids inside that relation field on every entry, and the slices that `nestedLimit` takes. The `-` for descending goes in front of `relationSort`, not in front of the field. * Only the first segment after `relationSort.` and the last one are read. `relationSort.a.b.name` sorts `a` by `name` and ignores `b`. * It applies wherever a relation field of that name appears, at every depth. * A `sort` parameter that contains only `relationSort.` items still counts as a sorted list. The entries themselves come back in an arbitrary order. Add a plain sort in front if order matters: `sort=-published_date,relationSort.coauthors.name`. * It also orders the items a [relation filter](#filtering-the-items-of-an-array-relation) keeps. ## Filtering Any query parameter that names a field of the model is a filter. Filters are ANDed. ``` GET /v2/api/articles?slug=a-preview-you-can-trust GET /v2/api/articles?author=854e6620-778b-4750-a93c-556e2e1516c4&published_date[gte]=2026-08-10 ``` `=` is equality. `[]=` picks an operator. A parameter that names nothing on the model is ignored, so a typo returns every entry instead of an error. ### Looking up by slug The most common read on this API is one entry by a field you control: ```bash curl 'https://cdn.capacms.com/v2/api/articles?slug=a-preview-you-can-trust&limit=1&depth=2' \ -H "x-api-key: $CAPA_KEY" ``` Read `data[0]`. When nothing matches, `data` is `[]` and the status is still `200`, so test for an empty array rather than for a `404`. Values can contain `/`: `?slug=services/implants/all-on-4` matches that exact string. ### Equality | You write | Matches | | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- | | `slug=a-preview-you-can-trust` | text fields equal to that string, case-sensitively | | `featured=true` | `true_false` fields that are `true`. Any other value, including `1`, means `false` | | `views=120` | **nothing**: equality does not convert numbers. Use `views[equals]=120` | | `title=*Preview*` | text containing `Preview`, case-sensitively | | `title=Can*` or `title=*Can` | text containing `Can` anywhere. A `*` at either end means "contains", not "starts with", so `title=Can*` finds "A Preview You Can Trust" | In a `*` match, `%` and `_` inside the value are wildcards too: `_` matches any one character. A `*` match never matches an array field. ### Operators | Operator | Example | Matches | | -------------------------------------- | ------------------------------------------------- | ------------------------------------------------------------------------- | | `equals` | `views[equals]=120` | equality, converting the value to a number on a number field | | `not` | `title[not]=The%20Edit%20Is%20the%20Product` | anything else. An entry without the field does not match | | `gt`, `gte`, `lt`, `lte` | `views[gt]=100`, `published_date[gte]=2026-09-01` | numbers as numbers, dates and text as text | | `range` | `published_date[range]=2026-08-10,2026-08-25` | between two values, inclusive. Anything but exactly two values is ignored | | `contains` | `title[contains]=*Preview*` | see below | | `string_contains` | `title[string_contains]=Content` | text containing the value, case-sensitively | | `string_starts_with` | `title[string_starts_with]=The` | text starting with the value | | `string_ends_with` | `title[string_ends_with]=Tree` | text ending with the value | | `array_contains` | `tags[array_contains]=news` | arrays holding the value | | `array_starts_with`, `array_ends_with` | `tags[array_starts_with]=news` | arrays whose first or last item is the value | `contains` depends on the value and the field: | You write | On a text field | On an array field | | -------------------------- | ---------------------------------- | ----------------- | | `[contains]=news` | equal to `news`, not containing it | holds `news` | | `[contains]=*news*` | containing `news` | never matches | | `[contains]=news,product` | equal to both, which never matches | holds both | | `[contains]=news\|product` | equal to either | holds either | URL-encode `|` as `%7C` if your client does not. **Every other operator is a `500`.** That includes `ne`, `in`, `nin`, `notEquals`, `eq` and `exists`. So is a non-number on a number field with `gt`, `gte`, `lt` or `lte`, and a repeated `[range]` or `string_*` parameter. Date comparisons are text comparisons of the stored string. They work for dates stored as `YYYY-MM-DD` and send the same form. `filter[title][eq]=…` is not part of this API. It names no field, so it is ignored and every entry comes back. A field whose namespace is also a parameter name (`limit`, `page`, `sort`, `id`, `ids`, `depth`, `structure`, `nestedLimit`, `nestedPage`, `relatedFilters`) cannot be filtered on. The parameter wins. ### Filtering on a related entry's field ``` GET /v2/api/articles?author.name=Maya%20Lindqvist ``` **This does not select Maya's articles.** It is all or nothing: when any entry in the list points at a related entry that matches, every entry that passes the other filters comes back. When none does, none comes back. The request above returns all six articles, two of them Maya's. It also changes paging. `total` and `totalPages` then describe the current page only, so `hasNextPage` is `false` and `currentPage` can be lower than the page you asked for. Page on until a page comes back shorter than `limit`. The order is not the default id order. Only put a relation field before the dot. Any other name is not checked, and what you get depends on your content: a `500` when your entries point at entries of two or more models, otherwise a filter on the one related model. To find the articles by one author, filter on the relation field itself, with the author's id. For an array relation, use `[contains]`: ``` GET /v2/api/articles?author=854e6620-778b-4750-a93c-556e2e1516c4 GET /v2/api/articles?coauthors[contains]=854e6620-778b-4750-a93c-556e2e1516c4 ``` ### Choosing entries by id ``` GET /v2/api/articles?ids=f52b0204-9b6e-4e84-ba2c-15db014dad19,61001acc-e032-48cb-aeff-0f2d0bb69e6b ``` `ids` limits the list to those entries and combines with every other filter and parameter. * The order you list them in is not kept. Pass `sort`, or reorder on your side. * An id that exists in another model or another project, or that a `pk_` key cannot see, is skipped. * Any id that is not a UUID is `400 {"error":"One or more instance ids are invalid"}`. So is a trailing comma. * `ids=` with nothing after it is ignored. ## Drafts | | `pk_` key | `sk_` key | | ---------------------------------- | --------------------------------------------------- | --------------------------------- | | Published entry | its published data | its newest data | | Published entry with a newer draft | its published data | the draft | | Draft-only entry | absent. One entry by id is `404` | the draft | | Related entries in `relations` | published only | newest, drafts included | | Search | published entries, minus those with a pending draft | everything, newest text | | Filters match | the published version | any version, including older ones | | Caching | 60 seconds | none | The last filter row means a draft key can return an entry whose current data no longer matches the filter, because an older version did. ## v2 and v3 `/v3/api` is `/v2/api` with a lighter response. It serves the same data with the same parameters, except: | | `/v2/api` | `/v3/api` | | --------------------------------- | ------------------------------------------------------------------ | ----------------------------------------------------------------------------------- | | `structure=tree` | supported | ignored, always `relations` | | Related entries in `relations` | the full stored row, including the version it serves and the model | `id`, `modelId`, `data`, `createdAt`, `updatedAt`, `tags`, `sortOrder`, `modelType` | | Extended search hits with `depth` | carry internal `_` keys | clean | | `sort=.` | numeric | as text, so `10` sorts before `9` | | `/{namespace}/types` | answers even when the subscription is inactive | `402` when the subscription is inactive | | `X-Response-Time` header | sent | not sent | The entries in a list or one-entry `data`, `meta`, filters, paging and every error body are the same on both. ## Caching A `pk_` response is built to be cached at the edge, and a draft never is. | Header | `pk_` key, `200` | `sk_` key, `200` | | ------------------------------ | ------------------------------------------------------- | -------------------- | | `Cache-Control` | `public, max-age=60` | `no-store, no-cache` | | `Surrogate-Control` | `max-age=…, stale-while-revalidate=…, stale-if-error=…` | `no-store` | | `Surrogate-Key` | ` api` | absent | | `Vary` | `x-api-key` | `Origin` | | `Cloudflare-CDN-Cache-Control` | `no-store` | `no-store` | These are the list and single-entry routes. `/types`, `/search` and every error send no cache headers of their own. What that means for your site: * A browser or your own server may reuse a `pk_` response for 60 seconds. * The CDN may keep it longer, for as long as `Surrogate-Control` says, and separately for each key. The durations are server configuration, not a promise. Unconfigured, `max-age` is 604800 seconds. This platform sends `max-age=60, stale-while-revalidate=86400, stale-if-error=604800` today. * When an entry is published, Capa purges every CDN copy that shows an entry of its model. After a publish, allow for the 60 seconds your side may still hold. * The `` in `Surrogate-Key` is fixed for one project, URL and set of query parameters. It is what the purge targets. * The CDN in front of the API removes `Surrogate-Key` and `Surrogate-Control` and replaces `Vary` before the response reaches you. They are listed so you know they exist. Other response headers on a content `200`: | Header | Value | | ----------------- | ---------------------------------------------------------- | | `X-Tenant-Id` | your project's id | | `API-Key` | the key you sent | | `X-Request-ID` | an id for this request. Quote it when you report a problem | | `X-Response-Time` | milliseconds, `/v2/api` only | ## Limits There is no per-key rate limit on `/v2/api` or `/v3/api`, and no `X-RateLimit-*` header. Your plan's API call allowance is not checked on these reads either. The one check is that the project's subscription is active: `active` or `trialing`, within its current billing period. A project on permanent free access skips it. When it fails, every route except `/v2/api/{namespace}/types` answers `402`: ```json { "success": false, "error": "No active subscription found. Please subscribe to a plan to access this resource." } ``` The `error` sentence names the reason: no subscription, canceled, past due, unpaid, incomplete, paused, or a billing period that has not started or has ended. ## Errors Every error is a JSON object with an `error` sentence. There are no error codes. Branch on the status, and on `data` being empty for "nothing matched". | Status | Body | When | | ------ | ------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------- | | 400 | `{"error":"One or more instance ids are invalid"}` | an `ids` value is not a UUID | | 401 | `{"error":"API key required"}` | no `x-api-key` header | | 401 | `{"error":"Invalid API key"}` | unknown, deactivated, expired, or a `cap_` key | | 402 | `{"success":false,"error":"…"}` | the subscription is not active | | 403 | `{"error":"This API key is not allowed from "}` | an origin-bound key, from another origin | | 403 | `{"error":"Direct origin access is not allowed. Request this through the CDN hostname."}` | the request reached Capa's servers without passing through the CDN | | 404 | `{"error":"Model not found"}` | no model with that namespace or id, or no entry with that id | | 404 | `{"error":"Model instance version not found"}` | an entry a `pk_` key cannot see, or an `?id=` that is not an entry of your project | | 404 | `{"error":"Model instance not found"}` | `?id=` names an entry of another model of your project | | 404 | `{"error":"Tenant not found"}` | the key's project was deleted | | 404 | `{"message":"Route GET:/v2/api/articles/ not found","error":"Not Found","statusCode":404}` | no such route: a trailing slash, an extra path segment, or `POST`, `PUT`, `PATCH` or `DELETE` | | 500 | `{"error":"Internal Server Error","details":{…}}` | an unsupported operator, a bad filter value, or a fault on our side | | 500 | `{"error":"Failed to fetch search results","details":"…"}` | search without `q`, or a search fault | | 500 | `{"error":"Failed to fetch model type definitions","details":{…}}` | a `/types` fault | Do not parse `details`. Its content is not part of the contract. A filter that matches nothing, a page past the end, and a slug that does not exist are all `200` with `data: []`. ## Moving to the new API `/api/` is the dated, documented successor. Your legacy integration keeps working unchanged, and you can move one page at a time. * [Entries](https://capacms.com/docs/api/entries) is the read contract, and its section [Coming from `/v2/api` or `/v3/api`](https://capacms.com/docs/api/entries#coming-from-v2api-or-v3api) is the parameter-by-parameter translation. * [GraphQL](https://capacms.com/docs/api/graphql) covers reading the same content through GraphQL. * [Keys and scopes](https://capacms.com/docs/api/authentication) explains why a new `cap_` key works on `/api/` only, and how to run both keys while you move. The legacy quirks on this page are the ones the new API was built to remove: all-or-nothing related-field filters, `500`s for unknown operators, and paging totals that break under those filters. # Legacy search Source: https://capacms.com/docs/legacy/search Full-text search across a project with /v2/api/search and /v3/api/search. ```bash curl 'https://cdn.capacms.com/v2/api/search?q=preview' -H "x-api-key: $CAPA_KEY" ``` ```json { "data": [ { "id": "61001acc-e032-48cb-aeff-0f2d0bb69e6b", "title": "A Preview You Can Trust" }, { "id": "7dd101af-5a84-4d46-8c52-f2227ec42f82", "title": "Pages Are a Map, Not a Tree" }, { "id": "49583221-3d95-440a-9ea0-962100e1629d", "title": "The Edit Is the Product" } ], "meta": { "total": 3, "totalPages": 1, "currentPage": 1, "limit": 50, "hasNextPage": false, "hasPrevPage": false } } ``` Search is full text with typo tolerance (`q=previw` finds the same three), ranked by relevance. It covers every model marked **Searchable Model** in the admin, which a new model is by default. Each hit is the entry id and its `title` value, or `""` when the entry has no `title` field. | Parameter | Default | Range | Notes | | --------------------------- | ----------- | ------------------- | ---------------------------------------------------------------------------------------------------------- | | `q` | required | | trimmed. Empty or blank answers `200` with no results. Missing answers `500` | | `size` | `50` | 1 to 500 | results per page. Above 500 is 500. `0` or text is 50 | | `page` | `1` | 1 and up | | | `modelNamespace` | all models | | limit hits to one model. An unknown namespace finds nothing | | `extended` | off | | any non-empty value, even `false`, returns full entries. Leave it out or empty to get `{ id, title }` hits | | `depth` | `0` | 0 to 4 | with `extended`, as on a list | | `nestedLimit`, `nestedPage` | `100`, `1` | | with `extended`, as on a list | | `structure` | `relations` | `relations`, `tree` | v2 only, with `extended` | | `relatedFilters` | none | | with `extended`: JSON only. The `relationFilter.` form is not read here | A `pk_` key finds published entries only. An `sk_` key also finds drafts, and matches their newest text. Things to know before you build on it: * **A published entry that has a newer, unpublished draft is not found by a `pk_` key.** Search indexes the newest version, and that version is a draft. The entry comes back once the draft is published or discarded. * **With `extended`, `meta` stops describing the whole result.** `total` is the number of hits on this page and `hasNextPage` is `false`. Page through extended results by asking for the next `page` until one comes back shorter than `size`. * **With `extended`, results are not in relevance order.** They come back in database order. Search without `extended` when order matters. * Extended results carry the entry's full `dataModel` and a `lastUpdatedByUser` key that is always `null`. The key stays so existing parsers keep working. No editor's email, name or avatar is returned. `lastUpdatedBy`, the id of whoever last saved the entry, is still there. * On `/v2/api`, an extended hit read with `depth=1` or more also carries internal keys that start with `_`, such as `_includedRelationIds: {}`. Ignore them. `/v3/api` removes them. * Search responses carry no cache headers of their own. # Preview in Next.js Source: https://capacms.com/docs/preview/nextjs Turn on edit mode, start the overlay and accept preview links in a Next.js site. Preview needs `@capacms/sdk` 1.0.0-next.8 or later. The `latest` tag is older and has no `capaHeaders`, so install the `next` tag: ```sh pnpm add @capacms/sdk@next ``` Set `CAPA_API_URL`, `CAPA_KEY` (`cap_live_`, or the legacy key your site holds) and `CAPA_DRAFT_KEY` (`cap_test_`). Draft reads through the SDK take a `cap_` key only (see Keys). Then: ```ts // middleware.ts import { NextResponse } from "next/server"; import { capaMiddleware } from "@capacms/sdk/nextjs"; export const middleware = capaMiddleware({ NextResponse }); // app/api/capa/preview/route.ts (and exit/route.ts with exitPreviewRoute) import { cookies, draftMode } from "next/headers"; import { createPreviewRoute } from "@capacms/sdk/nextjs"; export const GET = createPreviewRoute({ draftMode, cookies }); // next.config.mjs import { capaHeaders } from "@capacms/sdk/nextjs"; export default { async headers() { return capaHeaders(); } }; // in a page: getCapaClient({ draftMode, headers }), then

// in the root layout: const edit = await editMode({ draftMode, headers }) from @capacms/sdk/nextjs, // then {edit ? : null} from @capacms/sdk/nextjs/overlay ``` In Capa, set the project's preview URL to your site, and editors can click your page. The sections below explain each piece. The Capa editor can show your site beside the form: focus a field and its spot on the page is outlined, click the page and the editor jumps to the field, save and the draft re-renders in place. It needs three things on your side. ## 1. Turn on edit mode and tag what an editor can click Edit mode is on when Next draft mode is on, or when the request carries a `capa-edit` token the Capa editor's Published view sends. Work it out once per request and build the client with it: ```ts // lib/capa.ts import { draftMode, headers } from "next/headers"; import { createClient } from "@capacms/sdk/next"; import { editMode } from "@capacms/sdk/nextjs"; export async function capa() { const draft = (await draftMode()).isEnabled; return createClient({ baseUrl, version: "2026-10-01", apiKey: draft ? process.env.CAPA_DRAFT_KEY! : process.env.CAPA_KEY!, editMode: await editMode({ draftMode, headers }), }); } ``` Then tag fields with `capaAttrs(entry, field)` from `@capacms/sdk/next`. `field` is the field's namespace, its key in `entry.fields`, and it autocompletes when the entry is typed. There is no flag to pass: an entry read by an edit-mode client carries a hidden mark (related entries too), and `capaAttrs` tags only marked entries. A visitor's page therefore ships no `data-capa-` attributes. ```tsx import { capaAttrs } from "@capacms/sdk/next";

{article.fields.title}

``` The mark does not survive a spread copy or being passed to a client component, so tag in the server component that read the entry. `capaAttrs(entry, field, true)` still forces the tags on and `false` forces them off. **When the entry crosses to the browser as data, pass the flag.** The mark is a hidden symbol, so anything that serializes the entry drops it silently: the Next Pages Router's `getServerSideProps`, SvelteKit and Remix loaders, Nuxt's payload, or your own JSON endpoint. The page then shows the draft with no `data-capa-` tags, and the editor has nothing to point at. Nothing errors. Send the draft or edit state alongside the entry and hand it to `capaAttrs`: ```tsx // pages/articles/[id].tsx on the Pages Router. A +page.server.ts load or // useAsyncData on the server hands over the flag the same way. import type { Entry } from "@capacms/sdk/next"; import type { Articles } from "./capa-types"; export async function getServerSideProps({ draftMode = false }) { const entry = (await capa.entries.get("articles", "entry-id"))!.data; return { props: { entry, edit: draftMode } }; } // in the component export default function Page({ entry, edit }: { entry: Entry; edit: boolean }) { return

{entry.fields.title}

; } ``` Or re-mark the entries where they arrive with `markEditEntries(data)` when the page is in edit mode. Tested on the Pages Router, SvelteKit and Nuxt. GraphQL reads are marked the same way, from `getCapaClient`, an edit-mode `createClient` or `graphql()` given `{ draftMode, headers }`. A node is an entry when it selected `id` and `model`, and `field` is the field as you selected it: ```tsx const { data } = await capa.graphql.query({ articles: { nodes: { id: true, model: true, title: true } } });

{data!.articles!.nodes[0].title}

``` A field GraphQL renamed (`hero_image` for `hero-image`) is tagged by its namespace, which the editor knows it by: in edit mode the client reads the names of the key's types and fields once a minute to know which fields those are. That read starts beside the page's read, so the page waits for the slower of the two, and a document that never says `model` reads no names. With a typed client, tagging a node that did not select `model` does not compile. `toTree` keeps the mark, so its entries tag like REST entries, by namespace. To accept `capa-edit`, verify it in middleware. The token is checked with Capa, any forged `x-capa-edit` header is removed, and edit-mode responses are marked `private, no-store`: ```ts // middleware.ts import { NextResponse, type NextRequest } from "next/server"; import { resolveEditRequest } from "@capacms/sdk/nextjs"; export async function middleware(request: NextRequest) { const edit = await resolveEditRequest(request, publishedClient()); const response = NextResponse.next({ request: { headers: edit.headers } }); if (edit.cacheControl) response.headers.set("Cache-Control", edit.cacheControl); if (edit.robotsTag) response.headers.set("X-Robots-Tag", edit.robotsTag); return response; } ``` ## 2. Start the overlay in edit mode ```tsx // app/layout.tsx import { CapaOverlay } from "@capacms/sdk/nextjs/overlay"; {edit ? : null} ``` `@capacms/sdk/nextjs/overlay` is a client component and needs `react` and `next`. Without Next, call `startOverlay({ adminOrigins, onRefresh })` from `@capacms/sdk/overlay` in your own effect. Render it from your root layout only in edit mode (`await editMode({ draftMode, headers })`), so a visitor never downloads it. `startOverlay` returns a disposer and is safe to call twice. Outside a frame it does nothing at all, and inside one it only listens to a parent window at one of `adminOrigins`. Without `onRefresh` a save reloads the page; the scroll position is kept either way. ## 3. Accept the preview link on any page The editor loads `?capa-preview=`. Honour the token on the request itself, not only on a dedicated route: rewrite any request carrying it to your draft route, verify it with `preview`, enable draft mode and redirect back. ```ts // middleware.ts export function middleware(request: NextRequest) { // The editor's Published view: render this one request without draft mode. if (request.nextUrl.searchParams.get("capa-view") === "published") { const headers = new Headers(request.headers); const cookies = request.cookies.getAll().filter((c) => c.name !== "__prerender_bypass"); headers.set("cookie", cookies.map((c) => `${c.name}=${encodeURIComponent(c.value)}`).join("; ")); return NextResponse.next({ request: { headers } }); } const token = request.nextUrl.searchParams.get("capa-preview"); if (!token) return NextResponse.next(); const target = request.nextUrl.clone(); target.pathname = "/api/draft"; target.search = `?token=${encodeURIComponent(token)}&path=${encodeURIComponent(request.nextUrl.pathname)}`; return NextResponse.rewrite(target); } ``` `/api/draft` is the route handler shown under "Open a draft in your own site" above, reading `token` and `path`. **Cookies in a frame.** The editor frames your site from another site. A browser sends a cookie into a cross-site frame only when it is `SameSite=None; Secure`, and Safari 26.2 and later only when it is `Partitioned` too. Next sets the draft-mode cookie with neither `Partitioned` nor `Max-Age`, so it is dropped in Safari's frame and, everywhere else, opens every draft on the site until the browser closes. Pass Next's `cookies` to `createPreviewRoute` and `exitPreviewRoute` and they re-set it as `HttpOnly; Secure; SameSite=None; Partitioned; Path=/; Max-Age=3600`, and delete it the same way. `maxAge` changes the hour. In your own route, call `frameDraftCookie(cookies)` after `enable()` and `clearDraftCookie(cookies)` after `disable()`. A browser that drops the cookie still gets a fresh token on every preview load, which step 3 honours on any page. Leave `redirect` out of both routes. Each then answers with its own 307, marked `X-Robots-Tag: noindex, nofollow`, `Referrer-Policy: no-referrer` (the token is in the URL) and `Cache-Control: private, no-store`. Next's `redirect()` cannot carry headers, so passing it keeps the old redirect. **Mark drafts, and let Capa frame them.** `capaHeaders()` returns rules for `next.config`'s `headers()` that send `X-Robots-Tag: noindex, nofollow` and `Content-Security-Policy: frame-ancestors 'self' https://app.capacms.com` on a request carrying the draft cookie or a `capa-preview`, `capa-edit` or `capa-view` query, and on nothing else. A visitor's response, cached or not, is unchanged. Pass `adminOrigins` to name another admin, such as one running locally, and pass the same list to `createPreviewRoute`. A browser applies every CSP it is sent, so if your site sends its own `frame-ancestors` or `X-Frame-Options`, leave them off draft responses with `missing: [{ type: "cookie", key: DRAFT_COOKIE }]` on that rule. `capaMiddleware` marks its edit-mode responses noindex too, and `resolveEditRequest` returns the value as `robotsTag`. ## Add preview to a live site without changing it A live Next.js site that reads Capa with its own code keeps that code. Preview adds a branch that only draft mode switches on, and draft mode is off for every visitor, at build time and during ISR. Reading `(await draftMode()).isEnabled` leaves a static page static, so a visitor gets the same pages, headers and cache as before. 1. **Read drafts in draft mode.** In the helper that fetches from Capa, before the existing fetch, read the same URL with a draft key and no cache. The data has the same shape, so every page renders unchanged. ```ts // lib/capa-draft.ts import "server-only"; import { draftMode } from "next/headers"; export async function isDraft(): Promise { // draftMode() throws outside a request (generateStaticParams, some build steps). try { return (await draftMode()).isEnabled; } catch { return false; } } // in the fetch helper, before the existing fetch, which stays as it is if (await isDraft()) { return fetch(`https://cdn.capacms.com/v2/api/${endpoint}${query}`, { headers: { "x-api-key": process.env.CAPA_DRAFT_KEY! }, cache: "no-store", }).then(parse); // the helper's existing parse } ``` The draft key is any key of yours whose environment is not `production` (see Preview is a key, not a flag). Keep it server-only, never in a `NEXT_PUBLIC_` variable, and keep the branch in a `server-only` module: a helper a client component also imports would bundle it. 2. **Add the preview and exit routes** with `createPreviewRoute({ draftMode, cookies })` and `exitPreviewRoute({ draftMode, cookies })`, as in the Quick start. Set `CAPA_API_URL` to `https://cdn.capacms.com` and `CAPA_KEY` to the production key the site already reads with, server-only: the routes check the editor's token with it. 3. **Add the middleware behind a matcher**, so a visitor's request never runs it. Next reads `config` from the file itself, so write the matcher out: ```ts // middleware.ts (proxy.ts on Next 16) import { NextResponse } from "next/server"; import { capaMiddleware } from "@capacms/sdk/nextjs"; export const middleware = capaMiddleware({ NextResponse }); export const config = { matcher: [ { source: "/:path*", has: [{ type: "query", key: "capa-preview" }] }, { source: "/:path*", has: [{ type: "query", key: "capa-edit" }] }, { source: "/:path*", has: [{ type: "query", key: "capa-view" }] }, { source: "/:path*", has: [{ type: "cookie", key: "__prerender_bypass" }] }, ], }; ``` 4. **Add `capaHeaders()`** to `next.config`'s `headers()`, as in the Quick start: drafts are marked noindex and only the Capa admin can frame them. 5. **Start the overlay and tag fields in draft mode.** In the root layout: ```tsx const draft = await isDraft(); {draft ? : null} ``` Tag an editable field with `{...capaAttrs({ id: entry.id }, "title", draft)}`. With `draft` false it returns `{}`, so a visitor's HTML gains no attribute. The field is its key in the entry, which is its namespace. 6. **Route handlers that set their own `Cache-Control`** must send `private, no-store` in draft mode. Otherwise a CDN keeps a draft fetched by the editor's browser and serves it to everyone. Do not call `editMode()`, `getCapaClient()` or `resolveEditRequest()` from a static or ISR page or layout. Each reads `headers()`, which makes every route that calls it dynamic: the HTML stays the same, but the site loses ISR and renders every view. `isDraft()` above is the static-safe check. The editor's Published view then shows the static page without the overlay. In Capa, set the project's preview URL to the site's production origin, and give each model with a page a route pattern, or Preview has no link to open. # Pages Source: https://capacms.com/docs/preview/pages Which of your pages read which entries, what Capa suggests about them, and how preview opens a draft. Capa stores content. Your site renders pages. Until now nothing connected the two, so Capa could not answer the question editors ask most often before changing anything: > What breaks if I unpublish this? This page is how Capa answers it, and how an editor opens an unpublished draft in your own site. Start with [API reference](https://capacms.com/docs/api) for how a `/api/` request is shaped and [Keys and scopes](https://capacms.com/docs/api/authentication) for how to mint a key. These routes need the `instance:read` scope, the same one the entry routes need. | Route | What it answers | | --------------------------- | ----------------------------------------------------------------------------------- | | `GET /api/pages` | every page Capa knows about, busiest first | | `GET /api/pages?entry={id}` | only the pages that read one entry | | `GET /api/pages/{page}` | one page: its entries, its queries, its traffic and its [suggestions](#suggestions) | | `GET /api/preview` | is this preview token good, and what does it open | ## What a page is A page is a string that starts with `/`. Capa learns about it in two ways, and a page can arrive by either or by both. **Declared.** A model carries a route: `/blog/[slug]` for a collection whose entries each get a page, `/pricing` for a single page. You set this in the Capa admin, on the model. A declared page exists whether or not anyone has ever loaded it, which is the point: a route you just set up should show up immediately, not after the first visitor. **Observed.** A read arrived carrying a `Capa-Page` header (see [Entries](https://capacms.com/docs/api/entries#telling-capa-which-page-you-are-rendering)). An observed page exists whether or not any model declares it, which is also the point: most sites have pages Capa knows nothing about, and the first useful thing Capa can say is "here they are". Only `/api/entries` reads are recorded. GraphQL reads and the legacy `/v2/api` and `/v3/api` reads ignore the header, so a page that reads only through them never shows up as observed. The two join on the string itself, so `/blog/[slug]` declared and `/blog/[slug]` observed are one page, reported as `kind: "both"`. That is the state a fully wired site reaches. Until then you will see `declared` pages with no traffic (nothing is sending the header yet) and `observed` pages with no route (your site has pages Capa does not model). ### The grammar A **route** you declare on a model is strict: * starts with `/`; `/` alone is the home page * lowercase letters, digits and hyphens between the slashes * at most one `[name]` segment, named with letters, digits and underscores * no trailing slash, except the root * at most 200 characters, and no `..`, `?` or `#` `/blog/[slug]`, `/pricing`, `/docs/[pageId]`, `/` are routes. `/Blog` is not (a URL path is compared byte for byte, and `/Blog` and `/blog` are two pages to every cache in the world). `/blog/[year]/[slug]` is not: a model has one slug field, so a route has one dynamic segment. A **page identity** on the wire, which is what `Capa-Page` carries and what `{page}` in the path names, is looser: it accepts the concrete path your site actually served (`/blog/hello`) as well as the pattern. Refusing the concrete form would silently discard every site that reports the URL it is rendering. ### The slug field A route with a `[name]` segment needs to know which field holds the value, so the model also carries a slug field. It must be a field the model actually has, and its type must be one a URL segment can hold: a string, an enum, or a number. A rich-text or relation field cannot be a slug, and Capa refuses to set one rather than producing links that do not work. Setting the route and the slug field is what makes Preview able to say which page an entry is published at. ## `GET /api/pages` ```bash curl https://cdn.capacms.com/api/pages \ -H "x-api-key: $CAPA_KEY" \ -H 'Capa-Version: 2026-10-01' ``` ```json { "data": [ { "id": "/blog/[slug]", "pattern": "/blog/[slug]", "kind": "both", "models": [ { "id": "…", "namespace": "articles", "name": "Article" } ], "reads30d": 812, "lastReadAt": "2026-09-21T09:14:02.000Z", "slugField": "slug" }, { "id": "/pricing", "pattern": "/pricing", "kind": "observed", "models": [{ "id": "…", "namespace": "landing", "name": "Landing page" }], "reads30d": 96, "lastReadAt": "2026-09-21T08:50:11.000Z", "slugField": null } ], "meta": { "version": "2026-10-01", "contract": 1, "environment": "production", "requestId": "req_…", "since": "2026-08-23T00:00:00.000Z", "cap": 500, "truncated": false, "entry": null, "entrySince": null, "insights": [] } } ``` | Field | Read it as | | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | `id`, `pattern` | the page string. They are equal: the string is the identity | | `kind` | `declared`, `observed`, or `both` | | `models` | the models this page reads: each one's `id`, `namespace` and `name`, the name the admin shows. A model deleted since the read is left out | | `reads30d` | origin reads in the window `meta.since` opens. See [what is not counted](#what-is-not-counted) | | `lastReadAt` | the most recent read, or `null` for a declared page nobody has loaded | | `slugField` | the field the route's `[name]` segment is filled from, or `null` | **The list is not paged.** A project has tens or hundreds of pages, not thousands, and the list is a whole-site view whose first use is "show me all of them, sorted by traffic". Paging it would mean walking a cursor before you could sort, since the ordering is by a number the last page can change. It is capped at `meta.cap` instead, and `meta.truncated` says whether the cap cut anything. Ordering is busiest first, then by page string, so two calls a second apart return the same order and you can diff them. ### `?entry={id}`: which pages read this entry ```bash curl 'https://cdn.capacms.com/api/pages?entry=00000000-0000-4000-8000-000000000021' \ -H "x-api-key: $CAPA_KEY" ``` Returns only the pages that read that entry, each row carrying an extra `entryReads` count. This is the answer to "what breaks if I unpublish this?", and it is what "appears on 4 pages" in the Capa admin is reading. A row is otherwise identical to the same row in the unfiltered list, `reads30d` and all, so the two lists never disagree about a page's traffic depending on how you arrived at it. `entryReads` covers a **shorter window** than `reads30d`: seven days, reported as `meta.entrySince`. Entry ids live only on the raw read rows and those are kept for seven days (see [retention](#retention)). A count that is right for seven days beats one that is wrong for thirty. An entry no page has read, and an id that never existed, both answer with an empty list. Telling them apart would make this route a way to find out which ids exist. ## `GET /api/pages/{page}` The page identity contains slashes, so it is URL-encoded whole into one path segment: ```bash curl "https://cdn.capacms.com/api/pages/$(printf %s '/blog/[slug]' | jq -sRr @uri)" \ -H "x-api-key: $CAPA_KEY" ``` ```json { "data": { "page": "/blog/[slug]", "kind": "both", "models": [{ "id": "…", "namespace": "articles", "name": "Article" }], "reads30d": 812, "readsByDay": [{ "day": "2026-09-20T00:00:00.000Z", "reads": 31 }], "entries": [ { "id": "00000000-0000-4000-8000-000000000021", "modelId": "…", "namespace": "articles", "title": "Alpha ships today", "reads": 44 } ], "queries": [ { "modelId": "…", "namespace": "articles", "selectText": "title,slug", "url": "/api/entries/articles?select=title,slug&limit=10&sort=-publishedOn", "reads": 812, "lastAt": "2026-09-21T09:14:02.000Z", "keyIds": ["…"], "selection": { "model": "…", "system": ["id", "model", "status"], "fields": [{ "name": "title" }, { "name": "slug" }] }, "selectionError": null } ], "since": "2026-08-23T00:00:00.000Z", "lastReadAt": "2026-09-21T09:14:02.000Z", "slugField": "slug", "insights": [] }, "meta": { "…": "…" } } ``` `entries` is the last seven days and is capped, for the reason `entryReads` is: the ids are only on the raw rows. An entry whose `modelId` and `namespace` are empty strings is one Capa no longer holds a row for. It is kept in the list on purpose, because "this page reads something that is gone" is the most useful thing the list can say. `title` is the title stored on the entry's own row, which many entries do not have, so a `null` title on its own means nothing more than that. `queries` is one row per distinct `(model, select)` this page asked for, which is how you find a page that is fetching far more than it renders. | Field | Read it as | | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `url` | the request this group most often made, with its filters, sort and limit. `null` once the group has been folded into the daily rollup, which keeps counts rather than requests | | `selection` | the `select` parsed into the Selection IR against the model's **current** schema, so you do not have to parse it yourself | | `selectionError` | `{ code, message }` when the stored `select` no longer parses, and `null` otherwise. Exactly one of the two is set | A `selectionError` is usually not a bug. A query recorded last week naming a field the project has since removed is exactly the drift this screen exists to show, so it is reported rather than swallowed. `lastReadAt` on the detail is the newest read of the page in the window, or `null` when nothing has read it. A folded row knows the day and not the instant, so it claims the day's end: the latest moment the read could have happened, and never a future one. A page nobody has declared and nobody has read answers `404 page_not_found`. ## Suggestions Capa runs a handful of rules over a page's own reads and returns what they found as `insights`. **They are computed on the API, and that is the point.** Three clients want this answer: the Capa admin, `@capacms/sdk` and `@capacms/mcp`, which has no dependencies and cannot parse a `select` at all. A second implementation of "this page over-fetches" would drift from the first the moment a threshold moved, so the rules run in one place and every client renders one answer. `GET /api/pages/{page}` carries every insight about that page in `data.insights`. `GET /api/pages` carries **only** `unused` insights, and carries them in `meta.insights`: "no page reads this entry" is a claim about every page at once, so hanging it off one row would invite the reading "this page does not read it", which is true of nearly everything. (`GET /v2/pages` answers a naked object and carries the same rows at its top level.) ```json { "kind": "overfetch", "severity": "warn", "title": "This page asks for the whole entry.", "detail": "This read sends no select, so every field comes back and each relation is expanded as well. Naming the fields the page renders stops the relation rows being fetched at all.", "rewrite": { "select": "title,slug,body,summary,views,published" }, "evidence": { "fieldCount": 9, "relationCount": 1, "reads": 812 }, "modelId": "…", "queryKey": "…:" } ``` | Field | Read it as | | ---------- | -------------------------------------------------------------------------------------------------------------------------------------- | | `kind` | which rule found it: `overfetch`, `fanout`, `cache`, `drift`, `unused` | | `severity` | `warn` is worth acting on, `info` is worth knowing | | `title` | one sentence, present tense. The numbers are in `evidence`, not hidden in here | | `detail` | why it matters, in one or two sentences | | `rewrite` | the copyable fix, when the rule can build one. Absent when it cannot, because a suggestion nobody can act on is noise wearing a button | | `evidence` | every number the rule used, so you can check the claim rather than trust it | | `modelId` | the model it is about, when it is about one | | `queryKey` | the `queries[]` row it is about: `${modelId}:${selectText ?? ""}` | Insights are sorted `warn` first, then in the kind order of the table below, which is the order you can act in: a `select` is a one-line change, a fan-out is a refactor of one component, a cache miss is a header, drift is a command, and unused entries are a decision about content. ### `overfetch` **Fires when** a query sends no `select` at all, or `*`, on a model with more than 8 fields **or** any relation field. `warn` when the model has a relation, `info` when it is only wide. The relation is what makes it worse than wasted bytes: with no `select` every relation expands at up to 100 rows per hop, so one entry read can become a hundred rows of a second model. **Rewrite:** `select` naming the model's scalar fields in schema order, rendered canonically so it re-parses to itself. Relations are left out; that is the change. There is no rewrite when the model has nothing but relations, because a `select` naming only the system keys would be a worse read, not a better one. **Evidence:** `fieldCount`, `relationCount`, `reads`. This is a v1 approximation and is meant to be. The real question is which fields the component renders, which needs a usage signal the SDK does not send yet. "Asked for everything" is the common case and the one worth fixing first, so a page that sent a considered `select` is left alone. ### `fanout` **Fires when**, over the last 7 days, a page made single-entry reads (`/api/entries/{ns}/{id}`) to one model covering at least 3 distinct entries, and its single reads were at least 3 times its list reads to that model. Always `warn`. A page with **no** list reads passes the ratio, which is correct: fetching five entries by id and never listing them is the clearest form of the pattern. **Rewrite:** `filter` with `filter[id][in]` set to up to 20 of the ids, and `url`, the whole call built from it with the `select` those reads most often sent. The ids are read from the request path rather than from the entries a read returned, so a page fetching ids that no longer exist still shows up. **Evidence:** `singleReads`, `listReads`, `distinctEntries`, `sampleEntryIds` (at most 5). ### `cache` **Fires when**, over the page's 10 busiest URLs in the last 7 days, `(miss + pass) / total` is above 0.2 with at least 20 edge lines. Always `warn`. Misses and passes are counted together because they cost the same thing, an origin request, and `detail` says which of the two dominated and what that usually means: a pass is the edge being told not to store the response, which is what a per-user request header or a development key produces; a miss is the edge having nothing stored, which is what a publish-all purge or a short surrogate lifetime leaves behind. **Evidence:** `total`, `hit`, `miss`, `pass`, `dominantState`, `dominantReason`. `dominantReason` is Fastly's `response_reason`, which is the **HTTP reason phrase** ("OK", "Not Found") rather than a cache reason. It is carried because it is what the edge logged; the explanation in `detail` is derived from the cache state, which is the field that carries that meaning. A page with no edge lines at all gets no insight rather than a bad ratio. If the edge log cannot be read, an `info` insight with an empty `evidence` says the cache data is not available, which is visibly different from "this page caches fine". ### `drift` **Fires when** the newest `Capa-Schema` a page has sent is not the project's current schema checksum. Always `warn`. Both halves have to be present. A page that never sent the header says nothing about its build, and that is not evidence of a stale one. **Evidence:** `seenChecksum`, `currentChecksum`, `lastSeenAt`. See [`Capa-Schema`](#capa-schema) below for how the stamp gets there. ### `unused` **Fires when** entries have been read by no page in 30 days **and** last edited more than 90 days ago, grouped by model. Always `info`, and only on the list. Computed by a nightly job rather than on request: the question is an anti-join between everything a project owns and every entry id its pages have read, and asking it on a page load would make the cheapest screen the most expensive one. **A project with no observed page gets no `unused` insights at all.** Without that gate every entry of every uninstrumented project would qualify, which is a description of the instrumentation and not a finding. **Evidence per model:** `count`, `sampleEntryIds` (at most 5, the stalest ones), `namespace`. ### What is not a rule A legacy `depth=2` call that should become a `select` with one expansion is in the plan and is **not built**, because it cannot be: `depth` is a legacy `/v2/api` parameter and `/api/` has no such thing, so no read on this surface could ever trigger it. It belongs to a `capa convert-url` command, which is not built yet. ## `Capa-Schema` Send the checksum of the schema your code was generated from and Capa can tell a site built against the current models from one built against an older set: ``` GET /api/entries/articles?limit=3 Capa-Page: /blog/[slug] Capa-Schema: 7199153b8f2bd4cf ``` The value is the `checksum` `GET /v2/schema` returns, which is also its `ETag` and which `capa-codegen` writes into your generated types as `CAPA_SCHEMA_CHECKSUM`. With `@capacms/sdk/next` you pass it once: ```ts import { CAPA_SCHEMA_CHECKSUM } from "./capa-types"; const capa = createClient({ baseUrl, apiKey, version, schemaChecksum: CAPA_SCHEMA_CHECKSUM }); ``` It obeys the same three rules as `Capa-Page`: 1. **A value Capa cannot parse is ignored, never refused.** The shape is 8 to 64 lower-case hex characters. Anything else is dropped and the request is served exactly as if the header had never been sent. 2. **It never varies the response.** Not in `Vary`, not echoed, and the body, the `ETag` and the `Surrogate-Key` are byte for byte what they would have been without it. 3. **It is recorded only alongside `Capa-Page`.** The stamp is stored on the page read row, so a request that names no page records nothing. Why it is worth sending: a site that has never been rebuilt keeps issuing perfectly valid requests, so a stale build is otherwise invisible. This is the only signal that can see it. ## Preview An editor presses Preview in the Capa admin and gets a link to **your** site: ``` https://yoursite.example.com/blog/alpha-ships-today?capa-preview= ``` The base of that URL is the preview URL set on the project in the Capa admin. Without one the editor sees the path but no link to open. Your site takes the token and asks Capa whether it is good: ```bash curl 'https://cdn.capacms.com/api/preview?token=' \ -H "x-api-key: $CAPA_KEY" ``` ```json { "data": { "entryId": "00000000-0000-4000-8000-000000000021", "modelId": "…", "namespace": "articles", "path": "/blog/alpha-ships-today", "expiresAt": "2026-09-21T10:14:02.000Z" }, "meta": { "…": "…" } } ``` A good token means: enable your framework's draft mode and render `path`. With `@capacms/sdk/nextjs` that is a six-line route handler. **Why a round trip rather than a token you check yourself.** Your site holds an API key, not a Capa signing secret, and it should stay that way: a signing secret on a web server is a secret that can mint preview links for every project it can reach. Verifying through Capa costs one request per preview click, which is a click a human just made. **The path is resolved fresh, never baked into the token.** Fix a route or correct a slug and the next preview link lands in the right place, rather than an hour later when the old token expires. `path` and `namespace` are `null` when the model has since lost its route or the entry's slug was emptied; the claim is still valid and your own routing is the fallback. | Status | `code` | When | | ------ | ----------------------- | -------------------------------------------------------------- | | 401 | `preview_token_invalid` | not a Capa token, tampered with, or minted for another project | | 401 | `preview_token_expired` | older than an hour | A token minted for another project answers the same `preview_token_invalid` a forged one gets, so the refusal is not a way to learn that a token is real but not yours. Preview responses are always `Cache-Control: no-store`. A claim names one draft of one entry for one hour, and a shared cache holding it would serve it to the next visitor after the editor closed the tab. ## What is not counted `reads30d` counts times a page asked **Capa** for data. It is not a visitor count and must not be read as one. A page served from a CDN cache never reaches Capa, so a popular page behind a warm cache can report far fewer reads than it has visitors. A page rebuilt at deploy time reports one read per build. A page rendered per request reports one read per request. What the number is good for is relative: which pages read which entries, which pages are fetching more than they need, and which declared routes nothing is reading at all. ## Retention | Table | Kept for | What it holds | | -------------- | -------------------------- | -------------------------------------------------------------------------------- | | raw reads | 7 days | one row per read, with the entry ids it returned and the schema stamp it carried | | daily rollup | 90 days | one row per page, model, key, select and day, with the last schema stamp seen | | unused entries | until the next nightly run | one row per entry nothing renders, replaced per project each night | Raw rows are folded into the daily rollup once a day is complete, and a raw day is never deleted before it has been folded. This is why `reads30d` reaches back thirty days while anything involving entry ids reaches back seven: only the raw rows carry the ids, and the daily rows keep a capped sample rather than a full list. Today is folded too, so the numbers move during the day rather than waiting for midnight, but the rollup does not mark today as covered until it is complete. A read is counted from exactly one of the two tables: the rollup's own days come from the daily rows, and today comes from the raw rows. `reads30d`, `readsByDay` and `queries[].reads` all split at that same point, so the parts always sum to the whole. `entries[]`, `entryReads` and the `fanout` suggestion do not use that split at all. They can only be answered from the raw rows, so their window is the seven days those are kept, whatever the rollup has already folded. ## Turning it on 1. **Declare your routes.** In the Capa admin, give each model that publishes pages a route and, for `[name]` routes, a slug field. This alone fills the `declared` half of the list and makes Preview work. 2. **Send `Capa-Page`.** Add the header to your entry reads, naming the page you are rendering. With `@capacms/sdk/next` that is the `page` option. This fills the `observed` half and is what makes "which pages read this entry" answerable. 3. **Send `Capa-Schema`.** Pass `CAPA_SCHEMA_CHECKSUM` from your generated types to `createClient`. This is what makes the `drift` suggestion possible. 4. **Set your preview URL.** Project settings, so preview links have somewhere to point. Each step is useful on its own, and none of them changes a byte of what your site is already served. ## From the SDK Capa can tell you which of **your** pages read which entries, and it can open a draft in your own site. Both are opt in and both live on `@capacms/sdk/next` and `@capacms/sdk/nextjs`. ### Tell Capa which page a read is for Set `page` and every read sends a `Capa-Page` header. Capa records it and answers exactly as it would have without it: same body, same `ETag`, same cache key. Nothing about your site changes except that Capa can now answer "what breaks if I unpublish this?". ```ts import { createClient } from "@capacms/sdk/next"; import { routeOf } from "@capacms/sdk/nextjs"; const capa = createClient({ baseUrl, apiKey, version: "2026-10-01" }); // In app/blog/[slug]/page.tsx const posts = await capa.entries.list("articles", { page: routeOf(import.meta.url) }); ``` Send `path` beside it, the concrete path being rendered, and Capa can list the real URLs an entry appears on, not only the route patterns: ```ts await capa.entries.list("articles", { page: "/blog/[slug]", path: `/blog/${slug}` }); ``` `path` goes out as `Capa-Path`, only when `page` is also set. `routeOf` turns a Next route file into the page string: `/blog/[slug]`. It drops route groups `(marketing)`, parallel slots `@modal`, the leaf file name and the extension. Write the string out by hand if you prefer; `routeOf` exists so that moving a folder cannot silently split one page's telemetry in two. Set `page` on the config instead when a client serves exactly one page; a value on the call wins over one on the config. A layout is not a page. `app/layout.tsx` (and any nested `layout.*` or `template.*`) renders around every page below it, and Next does not tell it which one, so its reads cannot be charged to the page being rendered. `routeOf` returns `"(layout)"` for these files, exported as `LAYOUT_PAGE`, and a read that names it sends no `Capa-Page` header at all, even when the client was created with a `page`. So a Site singleton or a nav read in your root layout is simply not attributed, instead of making `/` look as if it read everything: ```ts // In app/layout.tsx: same call as in a page, and no page is recorded. const site = await capa.entries.list("site", { page: routeOf(import.meta.url) }); ``` A malformed value throws a `TypeError`. Capa itself ignores a header it cannot store, because a mangled page identity must never take a blog down, so the SDK is the place a typo surfaces. ### Read the page list ```ts const { data: pages } = await capa.pages.list(); // [{ id: "/blog/[slug]", kind: "both", models: [...], reads30d: 812, ... }] const appearsOn = await capa.pages.list({ entry: "entry-id" }); // only the pages that read that entry, each with entryReads ``` A page is `declared` when a model carries a route for it, `observed` when a read arrived carrying it as `Capa-Page`, and `both` when a correctly wired site has done both. `capa.pages.get("/blog/[slug]")` adds the entries the page reads, the queries it makes and a per-day read count, and returns `null` for a page Capa has never heard of. ### Tell Capa which schema you built against Pass `CAPA_SCHEMA_CHECKSUM` from your generated types and every read sends a `Capa-Schema` header: ```ts import { CAPA_SCHEMA_CHECKSUM } from "./capa-types"; const capa = createClient({ baseUrl, apiKey, version: "2026-10-01", schemaChecksum: CAPA_SCHEMA_CHECKSUM, }); ``` `capa-codegen` writes that constant into the generated file on every run, so it is always the schema the committed types describe. It buys one thing nothing else can work out: whether your deployed site was built against the models the project has now. A site that has never been rebuilt keeps sending perfectly valid requests, so without the stamp a stale build is invisible, and with it the page detail says "the site was generated from an older schema" and tells you to re-run `capa-codegen`. Telemetry only, like `page`: it does not change a response, a cache key or an `ETag`, and Capa ignores a value it cannot parse. A bad value throws a `TypeError` where the client is built, because a stamp dropped in silence looks exactly like a site that is up to date. ### Read the suggestions `capa.pages.get(page)` carries an `insights` array: suggestions drawn from that page's own reads, computed by Capa so that this SDK, the Capa admin and Capa's MCP server all read one answer. ```ts const detail = await capa.pages.get("/blog/[slug]"); for (const insight of detail?.data.insights ?? []) { console.log(insight.severity, insight.title, insight.evidence); if (insight.rewrite?.select) console.log("try select=" + insight.rewrite.select); } ``` | kind | what it found | | ----------- | ---------------------------------------------------------------- | | `overfetch` | the page sends no `select` on a wide model or one with relations | | `fanout` | the page reads one model an entry at a time instead of filtering | | `cache` | most of the page's reads miss or bypass the edge | | `drift` | the `Capa-Schema` the page sent is not the project's current one | | `unused` | entries no page reads, on `pages.list()` under `meta.insights` | Each one carries `title` (one sentence), `detail` (why it matters), `evidence` (every number the rule used, so you can check the claim) and, where there is one, `rewrite` with a canonical `select`, a set of query parameters or a whole URL you can paste. Every `queries[]` row on the detail also carries `selection`, the parsed Selection IR for its `select`, or `selectionError` when the stored `select` no longer parses against the model's current schema. That second case is usually not a bug: it is a query naming a field the project has since removed. `unused` insights live on the LIST rather than on a page, because "no page reads this" is a claim about every page at once. They are in `meta.insights` on `capa.pages.list()`. ### Open a draft in your own site An editor presses Preview in Capa and gets a link to **your** site carrying a signed token. Your site asks Capa whether the token is good, enables draft mode and redirects to the page. The token is short lived and names one entry. ```ts // app/api/preview/route.ts import { draftMode } from "next/headers"; import { redirect } from "next/navigation"; import { createClient } from "@capacms/sdk/next"; import { preview } from "@capacms/sdk/nextjs"; export async function GET(request: Request) { const token = new URL(request.url).searchParams.get("capa-preview") ?? ""; const claim = await preview(token, createClient({ baseUrl, apiKey, version })); if (!claim) return new Response("Invalid or expired preview link", { status: 401 }); (await draftMode()).enable(); redirect(claim.path ?? "/"); } ``` `preview` returns `null` for an invalid or an expired token, because a preview route does the same thing for both: do not enable draft mode. Anything else throws, so a Capa outage is never mistaken for a stale link. `claim.path` is resolved fresh on every call rather than baked into the token, so fixing a route or a slug takes effect immediately; it is `null` when the model has no route, and your own routing is the fallback. Draft mode still needs a draft key. `draftClient` selects one: ```ts const capa = await draftClient({ production: { baseUrl, apiKey: PUBLISHED_KEY, version }, draft: { baseUrl, apiKey: PREVIEW_KEY, version }, isDraft: async () => (await draftMode()).isEnabled, }); ``` Set the preview base URL for your project in Capa (Settings), otherwise the editor sees the path without a link to open it. # The preview protocol Source: https://capacms.com/docs/preview/protocol The messages between the Capa editor and your page, for stacks other than Next.js. Every message is `{ source, v: 1, type, ...fields }`. `source` is `capa-admin` on the admin's messages and `capa` on the site's. The admin sends `hello`, `highlight { entryId, field }` (`entryId: ""` clears), `outline { on }` and `refresh`. The site answers `ready { path, entries }` after every hello and every navigation, `select { entryId, field }` on a click, and `hover`. `acceptMessage(event, allowedOrigins, parent)` is the site's filter, exported for your own tests. The site also sends `visible { entryId, field }` while the page scrolls (at most every 150ms, and only when it changes): the tagged element at the centre of the viewport, chosen by `pickCentred`. At the very top of a page, where a heading can never reach the centre, it is the topmost visible element instead, and at the very bottom the bottommost. The editor's "Follow the page" scrolls the form to that field. An overlay older than 1.0.0-next.2 never sends it, and the editor then does not follow. # CLI: capa-codegen and capa persist Source: https://capacms.com/docs/sdk/cli Generate types from your models with capa-codegen, and register persisted queries with capa persist. ## Typed select and codegen `Select` is exported from `@capacms/sdk/next`. `capa-codegen` keeps the legacy `/v2/schema/types` shape for schemas without relations. When a schema has relations, codegen wraps them in branded helpers, which describe the model; a read's `fields` is typed from them as the API returns it (see Typed reads): ```ts export type CapaRelation = T & { readonly __capaRelation: "one"; readonly __capaRelationTarget: T }; export type CapaRelationList = T[] & { readonly __capaRelation: "many"; readonly __capaRelationTarget: T }; export interface Article { title?: string; author?: CapaRelation; coauthors?: CapaRelationList; } export type ArticleSelect = import("@capacms/sdk/next").Select
; ``` Those brands let TypeScript tell scalar fields from relation fields, so misspelled fields and invalid nested selects fail in consumer typechecks. A relation named alone (`"author"`) is read as a reference. Every name is written so the module parses, whatever the namespace holds. A field that is not an identifier is quoted (`"am/pm_indicator"?: string;`, read as `fields["am/pm_indicator"]`), and a model whose name is not one is named as GraphQL names it: `2024_events` is `_2024Events`, with `_2024EventsSelect` beside it. Two models whose names give one interface name, such as `twin_a` and `twin-a`, are each named from the whole namespace instead: `Model_twin_a` and `Model_twin$2da`, each character that is not a letter, digit or `_` written as `$` and its hex code. An enum's values are written as stored, a quote or a backslash included. A select names a field by its namespace as saved, whatever it holds: `["price.usd", { "at.place": ["zip.code"] }]`. The client writes a name holding `,` `(` `)` `:` `.` `"` `*` `[` `]` or a space, or starting with `-`, quoted, as REST's grammar reads it: `select="price.usd","at.place"("zip.code")`. A string select, `sort` and `where` take REST's own text, so write such a name quoted there yourself: `sort: ['-"price.usd"']`. A select also takes the entry's system keys by their `$` names, which mean the system key even on a model with a field of the same plain name (`SystemKey`): `["title", "$tags", { coauthors: { select: ["name"], sort: "-$createdAt" } }]`. A `$` name that is no system key fails to compile. A field whose namespace starts with `$` cannot be named, so `select: "*"` is how to read it. ## Codegen ```sh CAPA_API_URL=... CAPA_KEY=pk_... CAPA_TENANT_ID=... capa-codegen --out src/capa-types.ts ``` Without `--graphql`, `capa-codegen` reads the legacy `/v2` schema, which takes a `pk_` key and `CAPA_TENANT_ID`. A `cap_` key, which `/v2` refuses, writes the same interfaces from the key's GraphQL schema, with no tenant id, and so does `--schema `, a schema `capa-codegen --graphql --save-schema` wrote. GraphQL does not say three things, so those types say less: every field is optional, an enum is a `string`, and a field GraphQL leaves out is `unknown`. There is no `CAPA_SCHEMA_CHECKSUM`, since that is the `/v2` schema's checksum. Where the API does not serve GraphQL, use a `pk_` key. It reads `.env.local` and `.env` too, as `next dev` does. Exit codes: `0` wrote or already current, `1` failed, `2` `--check` found a diff. `--check` is the CI mode: it fails when the committed file is stale. **Commit the output.** Generating at install needs credentials during `npm install`, which breaks CI images and Docker builds, and generating at build time makes every build depend on the network. A committed file also turns "a model changed, so the build fails" into a reviewable diff instead of a wall of `tsc` errors. Re-running when nothing changed costs one conditional request that returns no body: codegen stamps the schema's checksum in the file and fetches the types only when the schema moved. Models are written in the order of their interface names, so renaming a model's display name does not reorder the file. ## Persisted queries Register your documents at build time, with a development key: ```sh CAPA_API_URL=https://cdn.capacms.com CAPA_DRAFT_KEY=cap_test_... capa persist --manifest persisted.json ``` Then send only their hash, with any key, including the production key your site ships: ```ts import { ArticlesPageDocument } from "./capa-graphql"; const { data } = await capa.graphql(ArticlesPageDocument, { first: 10 }, { persisted: true }); ``` With `persisted: true` the client sends the document's sha256 as one small GET, which the CDN and the API cache for a production key, and the document never travels. Variables too long for a GET URL (8,192 bytes) go with the hash in a POST instead, which works the same way but is not cached. `capa persist` prints ` stored, pinned` per operation, hashing the same text `capa-codegen --graphql` exports, so run both from the same documents. A document written as a literal is stored twice, as its `Document` text and as written (` (literal)`), since a call with the literal sends that text. `capa persist` registers every document with `Capa-Persist: pin`. The API drops unpinned documents first when a project's store is full, so the documents developers register while trying queries in the Explorer or a draft preview never push your site's documents out. A document the API stores without its pin is reported as `stored, not pinned` and the command exits 3, since it is exposed to exactly that. `--manifest` writes each operation's `sha256`, `stored` and `pinned`. Name the build too, so a run of preview deploys never pushes production's documents out: ```sh capa persist --release "$GIT_COMMIT" --env production ``` That sends `Capa-Persist: pin; release=; env=production`. A project keeps up to 2,000 documents and 16 MiB, and when it is full the API keeps the pins of the latest 3 production releases first, then what a production key ran in the last 30 days, then other pins, such as a preview build's. On Vercel and Netlify the command reads both from the build (`VERCEL_GIT_COMMIT_SHA` and `VERCEL_ENV`, or `COMMIT_REF` and `CONTEXT`), so there is nothing to pass. Elsewhere, pass the flags or set `CAPA_RELEASE` and `CAPA_RELEASE_ENV`. A release is 1 to 64 letters, digits, dots, dashes or underscores, such as a commit or a deploy id, and an environment is a lowercase name such as `production` or `preview`. The two go together, and the command checks them before it sends anything. The manifest records them as `release` and `env`. Two things decide whether a document is stored: * The key. `capa persist` reads the development key from `CAPA_DRAFT_KEY`, the name `/nextjs` reads for drafts (`CAPA_KEY` when that is unset), and refuses a production key before it sends anything. A production key ships in your site's bundle, so it may run a document but never register one. * The host. `capa persist` sends each document to `CAPA_API_URL`, the URL your site already reads. `https://cdn.capacms.com` passes every POST on to the host that stores documents. On a self-hosted stack whose read host stores nothing, set `CAPA_ADMIN_URL` to the API host your Capa admin uses; it wins over `CAPA_API_URL`. When a host stores nothing, the command says so and names the variable to set. When a hash is not stored yet, a production client still gets its data, with no error: the API answers the GET with `PersistedQueryNotFound`, the client sends one POST carrying the document and the hash, and the API runs it and answers `extensions.persistedQuery.registered: false`. The client then remembers that hash for five minutes and sends POST straight away, so a missing registration costs one extra GET per five minutes rather than one per call. `registered: false` on a production read is how to spot a document `capa persist` did not register. `graphql()` from `/nextjs` is not persisted by default for the same reason: until `capa persist` has run, every hash misses. A builder read (`capa.graphql.query`) cannot be persisted, and `persisted: true` there throws a `TypeError` before any request. Its document is printed when it runs, so `capa persist` never sees it, and a production key never stores one. It is already a cacheable GET. # The /api/ client Source: https://capacms.com/docs/sdk/client createClient from @capacms/sdk/next: entries, typed reads, the flat shape, inflate and errors. Create one client per key and pin the platform version in code: ```ts import { createClient, CapaError } from "@capacms/sdk/next"; const capa = createClient({ baseUrl: process.env.CAPA_API_URL!, apiKey: process.env.CAPA_KEY!, // cap_live_..., cap_test_..., or the legacy key your site has version: "2026-10-01", contract: 1, }); ``` The `/api/` client sends `x-api-key`, `Capa-Version`, optional `Capa-Contract`, and `Accept: application/json`. It never sends `X-Tenant-Key`; the tenant comes from the key. ## Entries ```ts const page = await capa.entries.list("articles", { select: [ "title", "views", { author: ["name"] }, { coauthors: { select: ["name"], limit: 5, sort: "-name" } }, ], filter: { views: { gte: 10 }, "author.name": { eq: "Ada Vale" }, }, sort: ["-views"], limit: 25, count: true, }); for await (const entry of capa.entries.iterate("articles", { select: ["title"] })) { console.log(entry.fields.title); } const one = await capa.entries.get("articles", "entry-id", { select: ["title", { author: "*" }], }); ``` `select` may be the grammar string the API reference describes ([capacms.com/docs/api/entries](https://capacms.com/docs/api/entries)) or the object form above. Lists return `{ data, page, meta, cacheTags }`; singles return `{ data, meta, cacheTags }`. `cacheTags` is parsed from the `Surrogate-Key` header. `get()` returns `null` for `404 entry_not_found` and throws every other error. Filters use the `/api/` operators: ```ts await capa.entries.list("articles", { filter: { id: { in: ["00000000-0000-4000-8000-000000000021", "00000000-0000-4000-8000-000000000022"] }, views: { gte: 10 }, tags: { hasAny: ["news", "launch"] }, }, where: { or: [{ featured: { eq: true } }, { views: { gt: 100 } }] }, }); ``` Unknown filter operators throw a local `TypeError` before any request is sent. Per-call `{ signal }` is forwarded to `fetch`. ### Typed reads `capa-codegen` writes an interface per model (see Codegen). Pass it, and the select's own type, and `fields` is typed as the API returns it: ```ts import type { Articles, ArticlesSelect } from "./capa-types"; // written by capa-codegen const select = [ "title", { author: ["name"] }, { coauthors: { select: ["name"], limit: 3 } }, ] as const satisfies ArticlesSelect; const typed = await capa.entries.list("articles", { select }); for (const article of typed.data) { const { title, author, coauthors } = article.fields; if (author && "fields" in author) console.log(title, author.fields.name); for (const coauthor of coauthors.items) { if ("fields" in coauthor) console.log(coauthor.fields.name); } article.fields.body; // compile error: the select does not name it } ``` * A relation the select expands is the related entry, with its own `fields`, or `{ id, model, missing: true }` when that entry was deleted, is unpublished for a production key, or is in a model the key cannot read. `"fields" in author` tells them apart. * A relation the select names without expanding it (`"author"`, or every relation under `*`) is a reference, `{ id, model }`. * A relation list is `{ items, pageInfo }`, expanded or not. * Media is `{ id, url, alt, type, width, height }`. * A field the select names is always there, and `null` when it was never filled, as the API writes it. `title?: string` in the model reads as `string | null`. * A field the select does not name is not there, so reading it does not compile. Without `typeof select`, or with a select written as a string, every field is typed, and each relation as whichever of the three it may be. `get` and `iterate` take the same two types, and `EntryFields` names the type of `fields`. ## Flat responses: each related entry once By default an expanded relation is nested where you selected it, so twenty articles by one author carry that author twenty times. Pass `shape: "flat"` and every relation comes back as a `{ id, model }` reference, with each expanded entry once in `included`, keyed by model namespace and then id: ```ts const select = ["title", { author: ["name"] }] as const satisfies Select
; const flat = await capa.entries.list("articles", { select, shape: "flat", }); flat.data[0].fields.author; // { id: "…", model: "authors" } flat.included.authors[authorId].fields; // { name: "Ada Vale" }, typed from Author ``` `included` is typed by the select: the union of the entry types it expands, at any depth, each field of which may be absent, since an entry holds what every path that reached it selected. A relation in `data` or in `included` is a reference. A select written as a plain string types `included` as `Record`. `get` takes `shape: "flat"` the same way. `iterate` reads the tree shape only. `inflate` turns a flat result back into the tree result, deep-equal to what the same request without `shape` returns: ```ts import { inflate } from "@capacms/sdk/next"; const tree = inflate(flat); // { data, page, meta, cacheTags }, typed as the tree read ``` * It walks the select the request sent. A result from this client carries it (`flat.select`); for a body you fetched yourself, pass it: `inflate(body, "title,author(name)")`. * `$tags`, `$createdAt` and the other `$` names in the select are the system keys, as the API reads them, so `select=$tags,tags` on a model with its own `tags` field comes back with both. * It returns copies. The same author under twenty articles is twenty equal, independent objects, as when a tree body is parsed. The input is not changed. * Cycles end where the select ends: `a` related to `b` related to `a` is inflated to the depth you wrote and no further. * An entry reached by two paths holds the union of what they selected, and one value per field. If two paths expand the same array relation with different `limit` or `sort`, the first one wins, and `inflate` cannot tell them apart. Every other request round-trips exactly. * In edit mode, included entries are marked, and so is every copy `inflate` makes of them. ## Errors ```ts try { await capa.entries.list("articles", { limit: 500 }); } catch (error) { if (error instanceof CapaError) { console.log(error.status, error.code, error.param, error.hint, error.requestId); } } ``` `CapaError` carries `{ status, type, code, message, param, hint, requestId, docs }`. A non-JSON response is reported as `code: "unparseable_response"`. # GraphQL in the SDK Source: https://capacms.com/docs/sdk/graphql client.graphql, the typed builder, typed documents and the tool spec for explorers and assistants. `/api/graphql` reads the same content as `/api/entries`, with the same key, version, limits and error codes. The schema is built for your key: it has exactly the models the key can read. The API reference is at [capacms.com/docs/api/graphql](https://capacms.com/docs/api/graphql). ## In a Next.js server component ```tsx // app/blog/page.tsx import { draftMode, headers } from "next/headers"; import { capaAttrs } from "@capacms/sdk/next"; import { graphql, tagsFor } from "@capacms/sdk/nextjs"; import { BlogIndexModels } from "./capa-graphql"; // written by capa-codegen --graphql const BLOG_INDEX = `#graphql query BlogIndex($first: Int) { articles(first: $first, sort: [publishedAt_DESC]) { nodes { id model title author { name } } } } `; export default async function Blog() { const { data } = await graphql(BLOG_INDEX, { first: 5 }, { draftMode, headers, tags: tagsFor({ namespace: BlogIndexModels }), // articles, and authors for author { name } revalidate: 60, }); return (
    {data?.articles?.nodes.map((a) =>
  • {a.title}
  • )}
); } ``` `data` and the variables are typed from the document itself, with no cast, once `capa-codegen --graphql` has read it (see Typed documents). Until then the call does not compile, and the error says to run it, so a document edited since the last run is never silently untyped. Given `tags` or `revalidate`, `graphql()` keeps a published read in Next's data cache, through Next's own `unstable_cache`, under the tags you give. Given neither, it keeps nothing: the read is sent as a REST read is, and the page shows a publish on its next render. `BlogIndexModels` lists the models the query reads, which codegen writes beside its types: `articles`, and `authors` for `author { name }`. It changes when the query does, so a model the query starts reading is never left out. `tagsFor({ namespace })` makes one tag per model (`capa:model:articles`), and `capa:media`. The webhook route above calls `revalidateFromWebhook`, which revalidates a model's tag whenever an entry of the model is published, unpublished or deleted, so the page shows the change on the next request. Editing a file in the media library, its alt text say, changes no entry, so it revalidates `capa:media` instead, and every read tagged by its models shows the new text. How long a read is kept: * With neither `tags` nor `revalidate`, not at all: every render reads the API, as `entries.list` does, so a site with no webhook route still shows a publish on the next request. * With `tags` and no `revalidate`, until one of its tags is revalidated. With the webhook route, that is until the next publish of a model it reads. * With `revalidate: 60`, also at most 60 seconds, so a missed webhook costs a minute of stale content at most. * With `revalidate: 0`, not at all. A read with a `revalidate` and no `tags` is tagged `capa:graphql` (`GRAPHQL_TAG`), which `revalidateFromWebhook` revalidates on every content change, so it is never stale after a publish; tag it with its models to refresh the page only when one of them changes. A read that answered with `errors` is never kept: a root field that timed out is shown once and read again on the next request. Next's fetch cache is not used for a GraphQL read, since it keeps every 200 and a GraphQL error is a 200, and in Next 15 it keeps nothing without a `revalidate`. A draft and an edit-mode page are read uncached. Outside a Next request, in a script or a test, the read is simply sent. `unstable_cache` is an option only to supply another implementation. `draftMode` and `headers` work out draft and edit mode as `getCapaClient` does. Under draft mode the read uses `CAPA_DRAFT_KEY`, a `cap_` key, and bypasses the cache. In edit mode (draft mode, or the editor's Published view) each entry that selected `id` and `model` is marked, so `capaAttrs(node, field)` makes it clickable in the Capa editor (see Live preview); a visitor's page carries no tags. Leave both out for a page with no preview. `graphql()` reads `CAPA_API_URL`, `CAPA_KEY` and `CAPA_API_VERSION` like the other helpers, and a setting you pass in `config` (`baseUrl`, `apiKey`, `version`, `fetch`) is used instead of its variable, so a full `config` needs no env at all. A published read is a GET, which the CDN and the API also cache for the published key; a document too long for a URL goes as a POST, which only Next's data cache keeps (see How reads are sent and cached). The result's `cacheTags` holds the API's `Surrogate-Key` for a GET (`m:`, `e:`), for purging a CDN of your own. `draft: true` or `false` decides draft mode yourself. Pass `persisted: true` once your documents are stored with `capa persist` (see Persisted queries). ## In a Node script ```ts import { createClient, isCapaError } from "@capacms/sdk/next"; const capa = createClient({ baseUrl: process.env.CAPA_API_URL!, apiKey: process.env.CAPA_KEY!, version: "2026-10-01", }); const POPULAR = `#graphql query Popular { articles(first: 5, filter: { views: { gte: 10 } }) { nodes { id title } } } `; const { data, errors, extensions } = await capa.graphql(POPULAR); for (const error of errors) console.warn(error.code, error.message, error.hint, error.path); console.log(data?.articles?.nodes, extensions.cost?.actualQueryCost); ``` For a string codegen has not read, such as one built at run time, pass the data type yourself: `capa.graphql<{ version: string }>("{ version }")`. `capa.graphql(document, variables?, options?)` resolves once the API has run the document, even when `errors` is not empty: the root fields that worked still carry data, a root field that failed is `null` (which is why every list root is nullable in the schema and in generated types), and each error is a `CapaGraphQLError`. A `CapaGraphQLError` is a plain object: the spec's `message`, `locations`, `path` and `extensions`, with `code`, `hint`, `docs`, `param` and `type` lifted out of `extensions`. So `Response.json(result)` in a route handler, and a server component passing `errors` to a client component, keep every field. `isCapaGraphQLError(value)` checks one by its shape, so it holds after JSON. A request the API refuses as a whole throws `CapaError`, whose `graphqlErrors` holds every error the API sent: a document that does not parse or validate, a variable of the wrong type, a query over a budget, a filter the planner refuses. Its body has `errors` and no `data`. GraphQL over HTTP sends that refusal as a 200 on `application/json`, which this client asks for, and as a 4xx on `application/graphql-response+json`; either way the `CapaError` is the same, with `status` 400. Other refusals keep their own status: the key (401), the plan (402), the origin (403), the contract (404), a mutation (405) and the rate (429). The API runs 4 reads at once per project, GraphQL documents and `/api/entries` reads counted together, and at most 3 of them for one client of a key, so one busy visitor never holds every slot. Up to 64 more per client and 256 per key wait for a slot. Past that it answers 429 with `Retry-After`. The client waits that long, plus up to 250 ms, and sends the request again, up to 3 times, so a page that reads many things at once from one key slows down instead of failing. `retries: 0` throws the first 429 instead, and `retries: n` allows n repeats. A 429 that is thrown carries `retryAfter`, in seconds. A `Retry-After` over 30 seconds is thrown at once rather than waited out, and `signal` ends a wait early. `entries.list` and `entries.get` share these limits and throw their 429 with `retryAfter` rather than repeat it. When an API machine is full of other projects' reads, it answers 503 `service_unavailable` with `Retry-After`, which is thrown. Where GraphQL is switched off, the `CapaError` says "This Capa deployment does not serve GraphQL." and its `hint` says to read with `entries.list` or `entries.get` meanwhile. Options: `operationName`, `method`, `persisted: true`, `retries`, and `signal`. `extensions.cost` is the query's cost as Shopify's APIs report it, in entries. `requestedQueryCost` is the most the document can read: every list at its full `first` (25 at a root and 100 nested when you give none), capped at 5,000, plus 500 for each scan. `actualQueryCost` is what it did read, plus 500 for each scan that ran. The 5,000 limit is checked before the document runs, on a tighter figure: a nested list with no `first` counts 10 for each entry above it, not 100, so `{ articles(first: 1) { nodes { coauthors { nodes { name } } } } }` passes as 11 entries and requests 101. That figure is `budget.counted`, and the limit is `budget.limit`, on every response: it is the number to watch, since a document is refused exactly when `counted` passes `limit`, and a refusal states it. There is no per-key budget, so there is no `throttleStatus`. A document that uses a deprecated field, argument or enum value gets one `{ coordinate, reason }` for each in `extensions.deprecations`, such as `{ "coordinate": "Articles._folder", "reason": "Read folder instead." }`. `capa-codegen --graphql` warns about the same uses before you ship. A scan reads entries the answer does not hold. Each of these is one: * a `totalCount`; * a filter or sort through a relation; * a root field that filters or sorts by its model's own fields, once however many such conditions it has, so `articles(first: 10, filter: { featured: { eq: true } })` requests 510; * each `contains`, `startsWith`, `endsWith` or `ne` condition past the first; * a relation list sorted with `sort`. Filters on `id`, `createdAt`, `updatedAt`, `publishedAt` and `_tags`, and a relation's `eq`, are served by an index and cost nothing. The API reference has every rule: [capacms.com/docs/api/graphql#cost](https://capacms.com/docs/api/graphql#cost). A development key's reads, such as the draft client's, also carry `extensions.capa`. Its `cost` breaks the cost down against every limit (depth, root fields, connections, nodes, fields) and counts the `scans`. Its `rest` names the REST request each root field was answered with. A production key's reads leave `extensions.capa` out, which keeps a cached page's answer small. ### How reads are sent and cached A read is a GET whenever its URL fits the API's limit of 8,192 bytes (path and query string), and a POST when it does not; a long document is never refused for its length. `method: "POST"` always sends a POST, and `method: "GET"` sends a GET that fits and a POST that does not. | Request | Cached by the API and the CDN | | ------------------------------------------ | ---------------------------------------------------------------------- | | GET with a production key, no errors | yes: `public, max-age=60`, purged by the entries it read (`cacheTags`) | | GET with a development key | no (`no-store`): drafts move | | any response with `errors` | no (`no-store`) | | GET selecting `me`, `__schema` or `__type` | no (`no-store`) | | POST | never | So a document too long for a GET is not cached. Keep it cacheable with persisted queries: `persisted: true` sends only the hash by GET (see Persisted queries). `graphqlSchema()` reads the introspection by POST, since it is never cached. The admin host serves GraphQL by POST only and answers a GET as a path it does not serve; a read that did not ask for GET is then repeated as a POST, and that host is read by POST for five minutes. A `method: "GET"` read there throws "This Capa host does not serve GraphQL by GET." In Next.js, `graphql()` from `/nextjs` adds Next's data cache on top when a read gives `tags` or `revalidate`, for a published read with no errors, a POST included: kept under its `tags` until one is revalidated, or for `revalidate` seconds when you give it, and never for a draft. ## The typed builder Write the query as an object and get the result typed from exactly what you selected, without writing GraphQL text: ```ts import { createClient } from "@capacms/sdk/next"; import type { CapaQuery } from "./capa-graphql"; // written by capa-codegen --graphql const capa = createClient({ baseUrl, apiKey, version: "2026-10-01" }); const { data } = await capa.graphql.query({ articles: { args: { first: 5, sort: ["publishedAt_DESC"], filter: { featured: { eq: true } } }, nodes: { title: true, author: { name: true } }, }, }); data?.articles?.nodes[0].author?.name; // string | null data?.articles?.nodes[0].body; // compile error: not selected ``` `true` selects a scalar, an object selects fields of a relation or a connection, and `args` sits beside the fields of anything that takes arguments. A misspelled field, argument, filter field or operator fails to compile, even beside a correct one, and so does an argument of the wrong type, a sort value that is not in the enum, or a required argument left out (`article` without `args: { id }`). The compiler's error names the key and where it was written: `titel: true` under `articles.nodes` fails with `Type 'true' is not assignable to type 'true & SelectionError<"titel is not a field of articles.nodes">'`. A `Date` in `args` is sent as its ISO text. Without `CapaQuery` the builder still runs, untyped. `selectionToDocument(selection)` returns the text it sends. `query()` takes the options `capa.graphql` takes, except `persisted`. Its document is printed when it runs, so `capa persist` never stored it, and it is already a GET that the API and the CDN cache. To persist a read, write it as a `#graphql` literal (see Persisted queries). In a Next.js server component, read through `getCapaClient`. A read that names tags is kept in Next's data cache under them: ```tsx // app/blog/page.tsx import { draftMode, headers } from "next/headers"; import { getCapaClient, tagsFor } from "@capacms/sdk/nextjs"; import type { CapaQuery } from "./capa-graphql"; export default async function Blog() { const capa = await getCapaClient({ draftMode, headers }); const { data } = await capa.graphql.query( { articles: { args: { first: 5, sort: ["publishedAt_DESC"] }, nodes: { id: true, title: true } } }, { tags: tagsFor({ namespace: "articles" }), revalidate: 60 }, ); return
    {data?.articles?.nodes.map((a) =>
  • {a.title}
  • )}
; } ``` `tags` and `revalidate` work as they do for `graphql()` (see In a Next.js server component). A read is kept until a publish of a model its tags name, a read with errors is never kept, and a draft or an edit-mode page is read uncached. A read with neither is not kept, as the client's REST reads are not. `capa.graphql(document)` on the same client takes them too. Read one field twice in one request with an alias: any other key, with `__aliasFor` naming the field it reads. A home page's featured and latest articles are one request: ```ts const { data: home } = await capa.graphql.query({ articles: { args: { first: 3, filter: { featured: { eq: true } } }, nodes: { id: true, title: true } }, latest: { __aliasFor: "articles", args: { first: 5, sort: ["publishedAt_DESC"] }, nodes: { id: true, title: true } }, }); home?.latest?.nodes[0].title; // string | null, typed as articles is ``` The alias is checked as the field it names, arguments and fields included, and sent as `latest: articles(...)`. A scalar is aliased with `__aliasFor` alone: `{ headline: { __aliasFor: "title" } }`. ### Typing a component's props `NodeOf` names one entry of a builder read, so a component that renders it takes exactly what the selection reads, and the two cannot drift: ```tsx import type { NodeOf } from "@capacms/sdk/next"; import type { CapaQuery } from "./capa-graphql"; const teasers = { articles: { args: { first: 5 }, nodes: { id: true, title: true, author: { name: true } } }, } as const; // { id: string; title: string | null; author: { name: string | null } | null } type Teaser = NodeOf; function Card({ article }: { article: Teaser }) { return
  • {article.title} by {article.author?.name}
  • ; } const { data } = await capa.graphql.query(teasers); const cards = data?.articles?.nodes.map((article) => ); ``` It is a node of a list root, the entry of a single root (`article`), or the node of an alias (`NodeOf`). `QueryResult` is the type of `data` as a whole. ### The same data in REST's shape The builder returns GraphQL's shape, because that is what its types describe: `articles.nodes[0].author.name`. Code written against `/api/entries` reads entries instead: `data[0].fields.author.fields.name`. `toTree` converts one to the other: ```ts import { toTree } from "@capacms/sdk/next"; import { capaTreeLayout } from "./capa-graphql"; // written by capa-codegen --graphql const selection = { articles: { args: { first: 5, sort: ["publishedAt_DESC"] }, nodes: { id: true, status: true, title: true, author: { id: true, status: true, name: true } }, }, } as const; const { data } = await capa.graphql.query(selection); const { articles } = toTree(data, selection, capaTreeLayout); articles?.map((entry) => entry.fields.title); // each a string | null, as entries.list types it // deep-equal to (await capa.entries.list("articles", { select: "title,author(name)", sort: ["-publishedAt"], limit: 5 })).data ``` `capaTreeLayout` is what `toTree` reads of your schema (each model's root fields, and which fields are renamed, relations, ids or media), written by codegen beside the types, so the conversion reads nothing from the API. Without codegen, pass the key's schema instead. That is one introspection request, so read it once and reuse it: ```ts import { toTree } from "@capacms/sdk/next"; const schema = await capa.graphqlSchema(); // one request: read it once, reuse it const selection = { articles: { args: { first: 5 }, nodes: { id: true, status: true, title: true } } } as const; const { data } = await capa.graphql.query(selection); const { articles } = toTree(data, selection, schema); // the same tree as with capaTreeLayout ``` A selection kept in a variable is declared `as const`, so each `true` and each `sort` value keeps its exact type and the result is typed exactly. Each model root field becomes its REST `data`, typed from the selection: a list root as an array of entries, a single root as one entry or `null`, and `null` for a root that failed (its error is in `errors`). System fields sit beside `fields` (`_version` as `version`), every other field sits under `fields` by its namespace (`hero_image` as `hero-image`), every entry carries its `model` from the schema, whether you selected it or not, a relation list becomes `{ items, pageInfo }` (`{ items }` when you did not select its `pageInfo`), a relation into a model the key cannot read is `{ id, model }` as REST writes it unexpanded (a list of them is `{ items, pageInfo }`), and a media value keeps REST's public shape and key order, `{ id, url, alt, type, width, height }`, as far as you selected it (a list of media is an array of those). The result is deep-equal to the REST response for the same read when the selection names the rest of what REST always returns: `id` and `status` on every entry, `pageInfo { hasNextPage endCursor }` on each relation list, and all six media fields. Its type is then assignable to the entry `entries.list` returns for the same read, so a function written for the REST read takes it unchanged. With a typed client, a selection without `id` and `status` does not compile. Three things differ by design, because GraphQL does not carry what REST says there: * A missing single relation (deleted, or a draft the key cannot see) is `null` in GraphQL and `{ id, model, missing: true }` in REST. * A missing item of a relation list is left out in GraphQL, so `items` is shorter. REST keeps its slot as `{ id, model, missing: true }`. * A value that does not fit its field's type is `null` in GraphQL and the raw value in REST. Page a list with the GraphQL result's own `pageInfo`; `version`, `me` and `entry` are not model roots, and `toTree` refuses them. A root alias (`latest: { __aliasFor: "articles", ... }`) becomes the REST data of the field it names, under its own key. An alias below a root has no REST equivalent, since REST reads each field once, so `toTree` refuses it too. ## Typed documents ```json { "scripts": { "dev": "capa-codegen --graphql --watch --out src/capa-graphql.ts & next dev", "prebuild": "capa-codegen --graphql --out src/capa-graphql.ts" } } ``` `capa-codegen` reads `CAPA_API_URL` and `CAPA_KEY` from your shell, else from `.env.local` and `.env` as `next dev` reads them, so a Next.js site needs no extra setup. `--watch` keeps running and writes the module again whenever a document changes, and when the key's schema does (it reads the schema again every minute). A document with a problem is printed with its file and line, and the last good module stays in place. Write a document where you use it, marked in one of three ways, and codegen types it by its text: ```ts import { createClient, gql } from "@capacms/sdk/next"; const LATEST = `#graphql query Latest($first: Int) { articles(first: $first) { nodes { id title } } } `; const ONE = /* capa */ `query One($id: ID!) { article(id: $id) { title views } }`; const VERSION = gql(`query Version { version }`); const { data } = await capa.graphql(LATEST, { first: 10 }); data?.articles?.nodes[0].title; // string | null await capa.graphql(LATEST, { first: "10" }); // compile error await capa.graphql(ONE); // compile error: id is required ``` The generated file adds each literal's text to `CapaDocuments` with `declare module "@capacms/sdk/next"`, and `client.graphql` and `graphql()` from `/nextjs` look the text up, so nothing is imported from it. Keep the generated file inside your tsconfig's `include`. A literal is sent exactly as written, so it holds one operation and every fragment it spreads, written inside it or spread in with `${}` (see Fragments); codegen says so, with the file and line, when it does not. A literal of fragments only is a fragment source for others. Codegen also reads every `.graphql` file and every gql`...` tagged template under `src` (or `--documents dir1,dir2`). A tagged template is a plain string to TypeScript, so for those, and for `.graphql` files, import the `Document` codegen writes for every named operation: ```ts // src/queries/articles.graphql // query ArticlesPage($first: Int) { articles(first: $first) { nodes { id title } } } import { ArticlesPageDocument } from "./capa-graphql"; const { data } = await capa.graphql(ArticlesPageDocument, { first: 10 }); data?.articles?.nodes[0].title; // string | null await capa.graphql(ArticlesPageDocument, { first: "10" }); // compile error ``` ### Fragments ```ts import type { ArticleTeaserFragment } from "./capa-graphql"; const ARTICLE_TEASER = `#graphql fragment ArticleTeaser on Articles { id title } `; const TEASERS = `#graphql query Teasers($first: Int) { articles(first: $first) { nodes { ...ArticleTeaser } } } ${ARTICLE_TEASER} ` as const; function teaser(props: ArticleTeaserFragment) { return props.title; } const { data } = await capa.graphql(TEASERS, { first: 3 }); data?.articles?.nodes.map(teaser); ``` Write a fragment in a `#graphql` literal of its own and spread it into a query with `${NAME}`, as Hydrogen does. Codegen reads the query with the fragment's text in place, which is exactly the text the program sends, so the query is typed and persisted like any other literal. End the query's template with `as const`: TypeScript keeps the text of a template with `${}` only then, and codegen says so when it is missing. A `${}` that names anything else is text only known at run time, which codegen skips and names. Codegen writes a `Fragment` type for every fragment, from a literal or a `.graphql` file, for a component that takes the fragment's data as props: the nodes of any query that spreads the fragment fit it. The generated file also holds the schema's types, `CapaQuery` for the typed builder, `capaTreeLayout` for `toTree`, and `Models` beside each operation: the models it reads, for its cache tags (see In a Next.js server component). That is each model whose entries it selects, and each model a filter or sort reaches through a relation. A filter or sort passed as a variable counts every model its type can reach, and `entry(id:)` counts every model, since either can read any of them. Codegen names, with its file and line, every template it does not read that looks like a query, and says what to change: one left unmarked, graphql`...` (from `/nextjs` that is a request, not a tag), and one with a `${}` that names no literal of the project, whose text is only known at run time. A field the key cannot read is a codegen error with its file and line, so a model change fails CI instead of production. A field, argument, filter operator or sort value a later `Capa-Version` phases out is `@deprecated` in the generated types, so your editor strikes it through, and codegen prints each use with its file, line and the reason, while still writing the module. `--check` exits 2 when the committed file is stale. `--save-schema capa-schema.json` writes the schema it read, and `--schema capa-schema.json` reads it back instead of calling the API, for CI without a key. Codegen needs `graphql` installed in your project (`pnpm add -D graphql`); nothing else in the SDK does. A literal codegen has not read yet, new or edited since the last run, does not compile: the error says `run capa-codegen --graphql`. Text built at run time is a plain `string` and stays untyped, and so does a call that names its data type, `capa.graphql<{ version: string }>(text)`. ## A REST select as a builder read, and back `selectToSelection` turns a REST read into a typed builder selection, which `client.graphql.query` runs and `toTree` turns into that read's `data`. `graphqlToSelect` gives a builder selection's REST request, as it gives a tool spec's: ```ts import { graphqlToSelect, selectToSelection, toTree } from "@capacms/sdk/next"; const schema = await capa.graphqlSchema(); const selection = selectToSelection(schema, "articles", "title,author(name)", { sort: ["-views"], limit: 5 }); const { data } = await capa.graphql.query(selection); const { articles } = toTree(data, selection, schema); // deep-equal to (await capa.entries.list("articles", { select: "title,author(name)", sort: ["-views"], limit: 5 })).data graphqlToSelect(schema, { articles: { args: { first: 5 }, nodes: { title: true, author: { name: true } } } }).url; // "/api/entries/articles?select=title,author(name)&limit=5" ``` The selection names what `toTree` needs to answer what REST answers: `id`, `model` and `status` on every entry, `pageInfo` on every relation list, and all six media fields. Its fields are known only when it runs, so its data and its tree are untyped, with a typed client too. It takes what `selectToGraphQL` takes and refuses what it refuses. A relation the select names bare, which REST returns unexpanded as `{ id, model }`, is read expanded to its id, the REST read `author(id)`, because GraphQL reads a relation to a model the key can read as an entry. `graphqlToSelect` reads a selection's one model root field, and throws `CapaBuildError` for any other root (`version`, `entry`), and for a relation list read from a cursor in a list read, which have no REST request here. `last` with no `before`, or with `before: null` as Relay and Apollo send it, reads the end of the list: REST's `before=end`. ## The tool spec: build a query for an explorer or an assistant Tools that build queries (the admin's Explorer, the MCP server) share one plain format, the tool spec: `{ model, fields, first, sort, filter }`. The SDK reads the key's schema once and writes GraphQL from it in Capa's canonical format, and moves between it and a REST read: ```ts import { buildGraphQLQuery, graphqlToSelect, selectToGraphQL } from "@capacms/sdk/next"; const schema = await capa.graphqlSchema(); // models, fields, filters, sorts, from one request const spec = { model: "articles", fields: ["title", { field: "author", fields: ["name"] }], sort: ["views_DESC"], first: 5 }; const { query, variables } = buildGraphQLQuery(schema, spec); graphqlToSelect(schema, spec).url; // "/api/entries/articles?select=title,author(name)&sort=-views&limit=5" selectToGraphQL(schema, "articles", "title,author(name)", { sort: ["-views"], limit: 5 }); // the same tool spec, from a REST read ``` The tool spec is not the typed builder's selection: `client.graphql.query` and `toTree` take the selection, which `selectToSelection` writes. `graphqlToSelect` writes the REST twin exactly as the API writes it in `extensions.capa.rest`: the filter as `where` JSON, the root's default page size left out, a relation list's `first` written as `limit:` whenever you give it (100 included, since REST counts a limit you write in full and one you leave out as 10), and a system key a field of the model shadows written with `$` (`_tags` beside a field called `tags` is `select=$tags,tags`; `createdAt_DESC` beside a field called `createdAt` is `sort=-$createdAt`). That includes a field GraphQL leaves out because its name collides with another's, which the type's description lists as `Not exposed:` and the schema summary as `notExposed`: beside a hidden field called `id`, `id_DESC` is `sort=-$id`. `selectToGraphQL` reads `$` names back, and refuses a REST name for a hidden field, which GraphQL cannot read. It selects what the REST read returns: a media value with all six of its fields (`id url alt type width height`), and `*`, or no select, as everything: the system keys `createdAt`, `updatedAt`, `publishedAt`, `version`, `folder` and `tags`, every field, and each relation list as a connection of ids (`coauthors { nodes { id } }`), the page of references REST returns. A reference to an entry the key cannot see is the one difference left: REST shows it as `{ id, model, missing: true }`, and GraphQL reads it as `null`, or leaves it out of a list. `buildGraphQLQuery` with no `fields` keeps its own shorter default, media as `id url alt`. Both helpers send each filter value as the type its filter input declares, since GraphQL refuses any other: `"10"` for a number field becomes `10`, and `has: "true"` on a list of true/false values becomes `true`, which its `BooleanListFilter` takes. A name the schema does not have, or a key a relation's spec does not take (`frist` for `first`, at any depth), throws `CapaBuildError` with `didYouMean`, before anything is sent. A `where` on `$tags` ports to GraphQL's `_tags` filter: ```ts import { selectToGraphQL } from "@capacms/sdk/next"; const schema = await capa.graphqlSchema(); selectToGraphQL(schema, "articles", "title", { where: { $tags: { has: "news" } } }); // { model: "articles", fields: ["title"], filter: { _tags: { has: "news" } } } ``` A model GraphQL leaves out, because its type name would collide with another's (`twin_a` and `twin-a`), is listed in the summary's `restOnly`. Asked for one, the builder throws `CapaBuildError` naming its REST read (`twin_a is readable over REST only: GET /api/entries/twin_a.`) and why, with no `didYouMean`: `entries.list("twin_a")` reads it. Two REST reads have no GraphQL twin and throw too: a `where` on `$version`, `$folder`, `$model` or `$status` (GraphQL filters `id`, `createdAt`, `updatedAt`, `publishedAt` and `_tags`), and a filter hop through a relation the key cannot read (REST answers it `unknown_field`). A tool spec pages as a REST read does: `first` with `after` reads the page after a cursor, and `first` with `before` the entries just before one. GraphQL writes the second as `last` with `before`, and the API refuses `first` there, so `{ first: 25, before }` prints `articles(last: $last, before: $before)`, and a tool spec with both `after` and `before` throws, as the API refuses it. `before: "end"` reads the last entries of the list, REST's `before=end`: `{ first: 5, before: "end" }` prints `articles(last: $last)`, and the list's `startCursor` pages back from there. `after: "end"` throws, since `end` is not a cursor. A read of one entry also pages a relation list from its cursor, as REST's `coauthors(name,after:…)` does: `{ field: "coauthors", fields: ["name"], after }` in a spec with `mode: "single"`, or `args: { after }` on the list in a selection. A list read throws for it, as the API refuses it there (`after: applies to a single entry`). `sort` takes one value or a list, at the root as on a relation list. A spec written as REST writes a read throws, and says what to write instead: `"author.name"` or `"author(name)"` in `fields` is `{ field: "author", fields: ["name"] }`, `"*"` is `fields` left out, and a sort `"-publishedAt"` is `"publishedAt_DESC"` (in `didYouMean` too). Relations nest up to 4 deep below the root entry, which with the root is the API's 5 levels of entries; a fifth throws `CapaBuildError` and names the field to select without fields, for its id. # SDK Source: https://capacms.com/docs/sdk @capacms/sdk: install it, pick an entry point, and set the environment variables it reads. The TypeScript SDK for Capa's content API: REST and GraphQL reads typed from your models, Next.js caching and live preview, and codegen. The API reference is at [capacms.com/docs/api](https://capacms.com/docs/api). ```sh pnpm add @capacms/sdk@next ``` It runs on Node 18 or later. Its types need TypeScript 5.0 or later, with `strict` on or off. | Import | What it is | | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | `@capacms/sdk/next` | The client for `/api/`: entries, GraphQL, pages and preview. It takes a `cap_` key, or the legacy key a site already holds for reads. Start here. | | `@capacms/sdk/nextjs` | Next.js helpers: `graphql()` in a server component, cache tags, draft and edit mode, webhook revalidation. | | `@capacms/sdk/nextjs/overlay` | The live preview overlay, as a Next.js client component. | | `@capacms/sdk/overlay` | The same overlay without Next. | | `@capacms/sdk` | The legacy `/v2/api` client, which takes a legacy key and a tenant id and refuses a `cap_` key, and the webhook signature check. | ## Keys A `cap_` key is the key for `/api/`: scoped, and stored hashed. `cap_live_` reads published content, `cap_test_` drafts too. Mint one in the Capa admin under Developers > Keys. The legacy key your site already holds works too, for every read: a `pk_` or `sk_` key, or an older key with no prefix, since every key but `cap_` is a legacy key to the API. That covers `entries`, `graphql`, `graphqlSchema`, `pages`, `me`, `versions` and `preview`: a site checks an editor's preview link with the key it already holds, and Capa refuses a link made for another project. The first client built with one prints a warning once per process, naming the `cap_` key to mint. Draft reads through the SDK keep their rule and take a `cap_` key only: `draftClient`'s `draft` config, and `CAPA_DRAFT_KEY` for `getCapaClient` and `graphql()`, throw a `TypeError` for a legacy key before any request. An `apiKey` that is not a string is refused where the client is built. `CAPA_API_URL` and `CAPA_KEY` are the names every Capa tool reads: the `/nextjs` helpers, `capa-codegen` and Capa's MCP server, so one `.env` serves them all. `capa persist` registers with the development key in `CAPA_DRAFT_KEY`, since only a development key stores a document (see Persisted queries). `CAPA_BASE_URL` and `CAPA_API_KEY` still work as aliases; when both are set, the first pair wins. ## Not yet `--select-from-depth`, `capa convert-url`, GraphQL mutations, and `/api/` writes are not in this release. # The legacy /v2 client Source: https://capacms.com/docs/sdk/legacy-client createClient from @capacms/sdk for /v2/api: reads, scheduling, workspaces, layouts and webhook endpoints. `@capacms/sdk` is the client for sites that read `/v2/api` today. It takes a legacy key (`pk_`, `sk_` or unprefixed) and the tenant's id, and refuses a `cap_` key where it is built, since `/v2` answers one as an invalid key: a `cap_` key reads `/api/`, with `createClient` from `@capacms/sdk/next`. Besides reads, it schedules publishes, arranges the admin's workspaces and entry layouts, and manages webhook endpoints. ```ts import { createClient } from "@capacms/sdk"; import type { BlogPost } from "./capa-types"; // written by capa-codegen const capa = createClient({ baseUrl: process.env.CAPA_API_URL!, apiKey: process.env.CAPA_KEY!, // a pk_ key, not a cap_ key tenantId: process.env.CAPA_TENANT_ID!, }); const page = await capa.listContent("blog_post", { limit: 10 }); const one = await capa.findOne("blog_post", { handle: "hello" }); ``` ## An entry's id: read `Page.ids` A `/v2/api` row puts the model's own fields at its top level, so a model with a field named `id` replaces the entry's id in the row, and the same goes for `title` and `tags`. Many models have one: every Shopify model names a field `id`. So read `Page.ids`, not `row.id`: ```ts const page = await capa.listContent("shopify_page"); page.ids[0] // the entry's UUID when the row has one to give page.data[0].id // the model's OWN id field whenever one exists ``` `Page.ids` is `string | null` per row, in the order of `data`: `null` means the model shadows `id` and the row carries nothing else to read the entry's id from. `instanceIdOf(row)` reads one row the same way, and prefers an `instanceId` when a row carries one. `getContentById` throws for an id that is not a UUID rather than spending a request on a certain 400. `search()` reads its hits the same way, so `SearchHit.id` is `string | null` too. `/api/` has no such collision: an entry's system keys sit beside its `fields`. ## Preview is a key, not a flag Capa shows unpublished content to a key whose environment is not `production`, whatever the request says. There is no `?preview=true`, so preview is a second client: ```ts const capa = createClient({ ...cfg, apiKey: PUBLISHED_KEY }); const preview = createClient({ ...cfg, apiKey: PREVIEW_KEY }); ``` The comparison is exact and case-sensitive, so **any** environment that is not literally `production` returns drafts, a typo like `Production` included. And a `/v2` client cannot find out which it holds: no `/v2` endpoint reports a key's environment, so a site handed a `draft` key serves unpublished content publicly with no way to detect it. `/api/` reports it: `capa.me()` on `@capacms/sdk/next` answers the key's `environment`. ## What it leaves out **Caching.** Every framework caches differently, so the client returns plain data from plain `fetch`, and Next's fetch cache and React Router loaders work with it as they are. What your host cannot know is which surrogate keys a response carries, so `Page.cacheTags` holds them for purging a CDN. **`getBySlug`.** Capa has no slug convention: the field is `handle` on `shopify_page`, `product_handle` on `judge_me_review` and `bloghandle` on `shopify_article`, and nothing marks one as the slug. `findOne(ns, { handle })` says which. **Content writes.** The client writes no entry content. It schedules publishes (`scheduledActions`, below), a request that leaves a row you can read, show and cancel, and it writes arrangement, which touches no entry: ```ts await capa.workspaces.apply(id, { tree }); // the admin's left rail await capa.models.setLayout(id, layout); // the entry editor await capa.models.setLayout(id, null); // back to the linear editor ``` ## Workspaces and entry layouts Reading a layout has two shapes. `models.getLayout(id)` is the document or null; `models.getLayoutInfo(id)` is the same read with the model's `embedByDefault` beside it, which decides how a relation field with no `display` renders, in linear mode too: ```ts const { layout, embedByDefault } = await capa.models.getLayoutInfo(id); ``` A workspace `tree` is a flat `WorkspaceDocNode[]`: one list of folders you named, each holding any mix of `{model}`, `{instance}`, `{media_folder}` and further folders. The older shape, `{ model, content, media }`, is still accepted and lands in folders called Models, Content and Media. `workspaces.*` needs a key with `write`; `models.setLayout` needs one with `agent`, because it writes a model and that is the permission every model write asks for. `models.getLayout` needs only `read`. A refused document comes back as `CapaError` carrying the API's own `{ error, path }`, where `path` names the node to fix. ## Scheduling a publish ```ts const { batchId, resolved, actions, replaced } = await capa.scheduledActions.create({ action: "publish", // or "unpublish" targets: [{ type: "instance", id: instanceId }], // 1 to 500 wallTime: "2026-10-01T09:00", // local clock, NO offset timezone: "America/New_York", // IANA name }); ``` **Entry targets only, from a key.** A `{ type: "model" }` target needs the `model:publish` permission, which no API key carries, so the server answers 403 whatever the key. Scheduling a model publish is done in the Capa admin. **Send a wall clock and a zone, not an instant.** `wallTime` carrying a `Z` or a `+02:00` is refused, here and by the server, because the two together are the only way to say "9am local, whatever the offset turns out to be". The server converts and reports what it decided in `resolved`: ```ts resolved.runAt // "2026-10-01T13:00:00.000Z" resolved.runAtInTimezone // "2026-10-01T09:00:00-04:00" resolved.note // null, or a sentence about daylight saving ``` `resolved.note` is the one field worth showing a person verbatim. A wall time that does not exist (the spring gap) resolves forward and a wall time that happens twice (the autumn overlap) takes the earlier offset, and the note says which happened: `02:30 does not exist on 8 Mar 2026 in America/New_York, publishing at 03:30 EDT`. `replaced` holds the ids of actions that were cancelled to make room. A target may have one active publish and one active unpublish at a time, so scheduling a second publish for the same entry replaces the first rather than queueing it. **`versionId` is optional and you usually want it absent.** With no version pinned, the action publishes the latest draft at fire time, which is what an editor who fixes a typo on Tuesday expects of a schedule made on Monday. The rest of the resource: ```ts await capa.scheduledActions.list({ status: ["pending", "running"], limit: 50 }); await capa.scheduledActions.list({ targetId: instanceId }); await capa.scheduledActions.get(id); // null when there is none await capa.scheduledActions.reschedule(id, { wallTime, timezone }); // + resolved await capa.scheduledActions.cancel(id); await capa.scheduledActions.retry(id); // a NEW row, see below ``` `retry` does not reopen the failed action. It creates a new `pending` one with `retryOfId` pointing at the original, so what failed stays readable. Expect two rows and read the newer one. Statuses are `pending`, `running`, `done`, `failed`, `dead`, `cancelled`. Only `pending` can be rescheduled or cancelled: a `running` action is being executed right now and `cancel` answers 409 `already_running`. `resultVersionId` on a `done` row is the version that went live. Reading needs a `read` key; every write here needs `instance:publish`, which the `write`, `delete` and `agent` keys carry. A refusal is a `CapaError` with the API's own `{ error, code }`. ## Webhooks Two halves that do not need each other. The verifier is clientless, so a receiver imports one function and nothing else. The resource manages endpoints, and it is the only thing in this SDK that needs a session token rather than an API key. ### Verifying a delivery ```ts import { verifyWebhookSignature } from "@capacms/sdk"; export async function POST(request: Request) { const body = await request.text(); // the RAW body, see below const ok = await verifyWebhookSignature({ payload: body, header: request.headers.get("capa-signature") ?? "", secret: process.env.CAPA_WEBHOOK_SECRET!, }); if (!ok) return new Response("bad signature", { status: 400 }); const event = JSON.parse(body); // event.id is stable across retries and redeliveries: store it and ignore // one you have already handled. return new Response("ok"); } ``` **The payload must be the bytes that arrived.** The signature covers `"."`, and a body that has been through `JSON.parse` and `JSON.stringify` again is a different string. Express needs `express.raw({ type: "application/json" })`, Fastify needs a raw-body parser on the route, Next's app router gives you `await request.text()`. A `Buffer` or `Uint8Array` can be passed straight in. `verifyWebhookSignature` is `async` because it uses WebCrypto rather than `node:crypto`, which is what lets it run unchanged in Node, Bun, Deno, Cloudflare Workers, Vercel's edge runtime and a browser. It returns `false` rather than throwing for every bad input: a malformed header, a missing secret, a timestamp outside the tolerance (5 minutes by default, `toleranceSeconds` to change it), or a body that does not match. During the 24 hours after a rotation Capa sends two `v1=` entries, the new secret first. Either one verifies, so a receiver can be updated any time inside that window without dropping a request. ### Managing endpoints ```ts const capa = createClient({ baseUrl: process.env.CAPA_API_URL!, apiKey: process.env.CAPA_KEY!, tenantId: process.env.CAPA_TENANT_ID!, accessToken: sessionJwt, // from POST /v2/user/login }); const { secret, ...endpoint } = await capa.webhooks.endpoints.create({ name: "Site rebuild", url: "https://example.com/hooks/capa", events: ["instance.published", "instance.unpublished", "instance.deleted"], }); // `secret` is here ONCE. Store it now. ``` `createClient` still needs `apiKey` and `tenantId` to construct at all, so a script that only ever calls `webhooks.*` has to pass something non-empty for both. Any legacy-looking placeholder will do, because no webhook call reads either one. **The tenant comes from the session, not from `tenantId`.** These routes read the current tenant off the logged-in user's token, so the `tenantId` you passed to `createClient` is ignored for every `webhooks.*` call: a client built with one `tenantId` operates on whatever tenant that user is currently on. **`accessToken`, not `apiKey`.** The webhook routes take no API key at all: a key that can add an endpoint can forward every content change in the project to a URL of its choosing, and a key is a string in a config file nobody rotates. So these routes want a logged-in person, and every `webhooks.*` method throws `@capacms/sdk: webhooks need accessToken; API keys cannot manage endpoints.` before sending anything when the token is absent. The rest of the resource: ```ts await capa.webhooks.events(); // the catalogue, grouped await capa.webhooks.endpoints.list(); await capa.webhooks.endpoints.get(id); // null when there is none await capa.webhooks.endpoints.update(id, { events }); // headers REPLACE the map await capa.webhooks.endpoints.delete(id); await capa.webhooks.endpoints.pause(id); // cancels what is queued await capa.webhooks.endpoints.resume(id); // enables, and COUNTS the gap await capa.webhooks.endpoints.resume(id, { backfill: true, since }); await capa.webhooks.endpoints.revealSecret(id); // audited, every time await capa.webhooks.endpoints.rotateSecret(id); // old secret lives 24h await capa.webhooks.endpoints.test(id); // one webhook.test event await capa.webhooks.endpoints.deliveries(id, { status: ["failed"], page: 1 }); await capa.webhooks.endpoints.redeliverFailed(id, { since }); await capa.webhooks.deliveries.get(deliveryId); // with both bodies await capa.webhooks.deliveries.redeliver(deliveryId); // same event id ``` `test` answers `{ eventId }`, and that value is an **opaque request id**: the event that actually arrives carries a different `id` (`evt_` plus another id), so there is nothing to correlate on. Read `deliveries(id)` and take the newest `webhook.test` row to see what the test did. `resume` without `backfill` is deliberately a two-step: it enables the endpoint and tells you how many events it missed, so you can show a person `Redeliver everything since 14 Sep, 2:10 PM (312 events)` and let them decide. Call it again with `{ backfill: true, since }` if they say yes. Reads need `webhook:read`, writes need the `webhook:*` write actions, and `revealSecret` needs `secret:reveal`, which no role holds by default: it is an owner or an explicit grant, and every call leaves an audit row whether it was allowed or denied. A server without webhook secrets configured answers `503` with code `webhooks_not_configured` to every write while reads keep working. # Next.js helpers Source: https://capacms.com/docs/sdk/nextjs @capacms/sdk/nextjs: cache tags, webhook revalidation, draft mode and graphql() in a server component. ```ts import { createClient } from "@capacms/sdk/next"; import { withCache, tagsFor } from "@capacms/sdk/nextjs"; const fetchWithCache = withCache(fetch, { tags: tagsFor({ model: articleModelId }), revalidate: 60, }); const capa = createClient({ baseUrl, apiKey, version: "2026-10-01", fetch: fetchWithCache }); ``` `withCache` merges `{ next: { tags, revalidate } }` into every fetch call. `tagsFor` builds Capa surrogate keys: `m:`, `e:`, `k:`, and `t:`, by id, and `tagsFor({ namespace: "articles" })` builds `capa:model:articles`, the tag for a GraphQL read (see GraphQL), and `capa:media`. `revalidateFromWebhook` revalidates all of them for the entry and model a webhook names, and for a media event, the file's `f:` key and `capa:media`. Webhook revalidation pairs with the existing signature verifier: ```ts // app/api/capa/route.ts import { revalidateTag } from "next/cache"; import { revalidateFromWebhook } from "@capacms/sdk/nextjs"; import { verifyWebhookSignature } from "@capacms/sdk"; export async function POST(request: Request) { const raw = await request.text(); const ok = await verifyWebhookSignature({ payload: raw, header: request.headers.get("capa-signature") ?? "", secret: process.env.CAPA_WEBHOOK_SECRET!, }); if (!ok) return new Response("bad signature", { status: 400 }); await revalidateFromWebhook({ payload: JSON.parse(raw), revalidateTag, }); return new Response("ok"); } ``` `draftClient` is server-only. Pass Next's draft state in from the caller so the SDK never imports `next/*`: ```ts import { draftMode } from "next/headers"; import { draftClient } from "@capacms/sdk/nextjs"; import type { CapaQuery } from "./capa-graphql"; // written by capa-codegen --graphql const capa = await draftClient({ production, draft, isDraft: async () => (await draftMode()).isEnabled, }); ``` `CapaQuery` types the GraphQL builder on the client it returns, as it does for `createClient`; `getCapaClient` and `getPublishedClient` take it the same way. Without it each helper returns an untyped client. `getCapaClient` sends its GraphQL reads as it sends its REST reads, which Next does not keep, unless a call gives `tags` or `revalidate`; then it keeps them in Next's data cache as `graphql()` does (see The typed builder). # Webhook events Source: https://capacms.com/docs/webhooks/events Every webhook event, when it fires, and a sample of the data it carries. Every event an endpoint can subscribe to, and the `data` it carries. The envelope around `data` is the same for every event (see [Webhooks](https://capacms.com/docs/webhooks)). Subscribe to one type, or to every type of a resource with `instance.*`, `model.*`, `media.*` or `publish.*`. | Event | When it fires | | --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | [`instance.created`](#instancecreated) | An entry was created. Fires for drafts too, before anything is published. | | [`instance.updated`](#instanceupdated) | An entry was saved without being published. A new draft version exists. | | [`instance.published`](#instancepublished) | An entry went live. Turn on Include content data to receive the published fields with it. | | [`instance.unpublished`](#instanceunpublished) | An entry was taken off the live API. It still exists as a draft. | | [`instance.deleted`](#instancedeleted) | An entry was deleted. Use this to drop it from a cache or an index. | | [`model.created`](#modelcreated) | A model was created. | | [`model.updated`](#modelupdated) | A model's fields changed. The payload names the fields added, edited and removed so a generated client knows what to rebuild. | | [`model.published`](#modelpublished) | A model version went live, so the public schema changed. | | [`model.deleted`](#modeldeleted) | A model was deleted. Its entries go with it and do not send their own events. | | [`media.uploaded`](#mediauploaded) | A file finished uploading and has a URL. | | [`media.updated`](#mediaupdated) | A file was renamed, moved between folders, or made public or private. | | [`media.deleted`](#mediadeleted) | A file was deleted. Its URL stops resolving. | | [`publish.scheduled`](#publishscheduled) | Somebody scheduled a publish or an unpublish for later. | | [`publish.rescheduled`](#publishrescheduled) | A scheduled publish moved to a different time. | | [`publish.cancelled`](#publishcancelled) | A scheduled publish was cancelled before it ran. | | [`publish.failed`](#publishfailed) | A scheduled publish gave up. Fires once, on the last attempt, never on a retry that is still coming. | | [`publish.batch.completed`](#publishbatchcompleted) | A bulk publish or unpublish finished. Fires once per run, whether it published 2 entries or 500, and never for a single Publish. | | [`webhook.test`](#webhooktest) | Sent only by Send test, and only to the endpoint you sent it from. It is not a subscription. | ## Content ### `instance.created` An entry was created. Fires for drafts too, before anything is published. ```json { "data": { "instanceId": "8f1c1f8e-6d3a-4a9e-9a3b-2f0a6b7c1d20", "modelId": "2c8b0a11-7f4e-4d2a-9c31-51a2b3c4d5e6", "namespace": "blog_post", "title": "Summer Guide", "versionId": "b7d4e2f1-3a5c-4e6b-8d9f-0a1b2c3d4e5f", "versionNumber": 4, "status": "draft", "publishedVersionId": null, "publishedAt": null } } ``` ### `instance.updated` An entry was saved without being published. A new draft version exists. ```json { "data": { "instanceId": "8f1c1f8e-6d3a-4a9e-9a3b-2f0a6b7c1d20", "modelId": "2c8b0a11-7f4e-4d2a-9c31-51a2b3c4d5e6", "namespace": "blog_post", "title": "Summer Guide", "versionId": "b7d4e2f1-3a5c-4e6b-8d9f-0a1b2c3d4e5f", "versionNumber": 4, "status": "draft", "publishedVersionId": null, "publishedAt": null } } ``` ### `instance.published` An entry went live. Turn on Include content data to receive the published fields with it. Ticked for a new endpoint. ```json { "data": { "instanceId": "8f1c1f8e-6d3a-4a9e-9a3b-2f0a6b7c1d20", "modelId": "2c8b0a11-7f4e-4d2a-9c31-51a2b3c4d5e6", "modelNamespace": "blog_post", "versionId": "b7d4e2f1-3a5c-4e6b-8d9f-0a1b2c3d4e5f", "versionNumber": 4, "publishedAt": "2026-09-22T14:03:11.000Z", "title": "Summer Guide", "scheduledActionId": null } } ``` ### `instance.unpublished` An entry was taken off the live API. It still exists as a draft. Ticked for a new endpoint. ```json { "data": { "instanceId": "8f1c1f8e-6d3a-4a9e-9a3b-2f0a6b7c1d20", "modelId": "2c8b0a11-7f4e-4d2a-9c31-51a2b3c4d5e6", "modelNamespace": "blog_post", "previousVersionId": "b7d4e2f1-3a5c-4e6b-8d9f-0a1b2c3d4e5f", "scheduledActionId": null } } ``` ### `instance.deleted` An entry was deleted. Use this to drop it from a cache or an index. Ticked for a new endpoint. ```json { "data": { "instanceId": "8f1c1f8e-6d3a-4a9e-9a3b-2f0a6b7c1d20", "modelId": "2c8b0a11-7f4e-4d2a-9c31-51a2b3c4d5e6", "namespace": "blog_post", "title": "Summer Guide", "deletedAt": "2026-09-22T14:09:40.000Z" } } ``` ## Models ### `model.created` A model was created. ```json { "data": { "modelId": "2c8b0a11-7f4e-4d2a-9c31-51a2b3c4d5e6", "namespace": "blog_post", "modelName": "Blog Post", "versionId": "9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b", "versionNumber": 7, "status": "draft" } } ``` ### `model.updated` A model's fields changed. The payload names the fields added, edited and removed so a generated client knows what to rebuild. ```json { "data": { "modelId": "2c8b0a11-7f4e-4d2a-9c31-51a2b3c4d5e6", "namespace": "blog_post", "modelName": "Blog Post", "versionId": "9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b", "versionNumber": 7, "fields": { "added": [ "subtitle" ], "edited": [ "title" ], "removed": [ "legacy_slug" ] } } } ``` ### `model.published` A model version went live, so the public schema changed. ```json { "data": { "modelId": "2c8b0a11-7f4e-4d2a-9c31-51a2b3c4d5e6", "modelNamespace": "blog_post", "modelName": "Blog Post", "versionId": "9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b", "scheduledActionId": null } } ``` ### `model.deleted` A model was deleted. Its entries go with it and do not send their own events. ```json { "data": { "modelId": "2c8b0a11-7f4e-4d2a-9c31-51a2b3c4d5e6", "namespace": "blog_post", "modelName": "Blog Post" } } ``` ## Media ### `media.uploaded` A file finished uploading and has a URL. ```json { "data": { "fileId": "41a2b3c4-d5e6-4f70-8192-a3b4c5d6e7f8", "name": "cover.jpg", "key": "tenants/acme/cover.jpg", "type": "image/jpeg", "url": "https://cdn.example.com/tenants/acme/cover.jpg", "filesize": 184320, "folderId": null, "isPublic": false } } ``` ### `media.updated` A file was renamed, moved between folders, or made public or private. ```json { "data": { "fileId": "41a2b3c4-d5e6-4f70-8192-a3b4c5d6e7f8", "name": "cover.jpg", "key": "tenants/acme/cover.jpg", "type": "image/jpeg", "url": "https://cdn.example.com/tenants/acme/cover.jpg", "filesize": 184320, "folderId": null, "isPublic": true } } ``` ### `media.deleted` A file was deleted. Its URL stops resolving. ```json { "data": { "fileId": "41a2b3c4-d5e6-4f70-8192-a3b4c5d6e7f8", "name": "cover.jpg", "key": "tenants/acme/cover.jpg", "type": "image/jpeg", "url": "https://cdn.example.com/tenants/acme/cover.jpg" } } ``` ## Publishing ### `publish.scheduled` Somebody scheduled a publish or an unpublish for later. ```json { "data": { "actionId": "6d5c4b3a-2918-4f7e-8d6c-5b4a39281706", "batchId": "1a2b3c4d-5e6f-4071-8293-a4b5c6d7e8f9", "action": "publish", "targetType": "instance", "targetId": "8f1c1f8e-6d3a-4a9e-9a3b-2f0a6b7c1d20", "runAt": "2026-09-23T13:00:00.000Z", "timezone": "America/New_York", "wallTime": "2026-09-23T09:00" } } ``` ### `publish.rescheduled` A scheduled publish moved to a different time. ```json { "data": { "actionId": "6d5c4b3a-2918-4f7e-8d6c-5b4a39281706", "runAt": "2026-09-24T13:00:00.000Z", "timezone": "America/New_York", "wallTime": "2026-09-24T09:00" } } ``` ### `publish.cancelled` A scheduled publish was cancelled before it ran. ```json { "data": { "actionId": "6d5c4b3a-2918-4f7e-8d6c-5b4a39281706", "batchId": "1a2b3c4d-5e6f-4071-8293-a4b5c6d7e8f9", "targetId": "8f1c1f8e-6d3a-4a9e-9a3b-2f0a6b7c1d20" } } ``` ### `publish.failed` A scheduled publish gave up. Fires once, on the last attempt, never on a retry that is still coming. ```json { "data": { "actionId": "6d5c4b3a-2918-4f7e-8d6c-5b4a39281706", "batchId": "1a2b3c4d-5e6f-4071-8293-a4b5c6d7e8f9", "action": "publish", "targetType": "instance", "targetId": "8f1c1f8e-6d3a-4a9e-9a3b-2f0a6b7c1d20", "attempts": 5, "status": "dead", "errorCode": "version_not_found", "message": "The version this action was pinned to no longer exists." } } ``` ### `publish.batch.completed` A bulk publish or unpublish finished. Fires once per run, whether it published 2 entries or 500, and never for a single Publish. ```json { "data": { "batchId": "1a2b3c4d-5e6f-4071-8293-a4b5c6d7e8f9", "action": "publish", "targetType": "instance", "total": 500, "done": 497, "failed": 2, "dead": 1, "cancelled": 0, "requestedBy": "00000000-0000-4000-8000-000000000042", "completedAt": "2026-09-22T14:05:11.000Z" } } ``` ## Test ### `webhook.test` Sent only by Send test, and only to the endpoint you sent it from. It is not a subscription. ```json { "data": { "endpointId": "c3d4e5f6-a7b8-4901-9234-5678abcdef01", "message": "Test event from Capa" } } ``` # The Developers section Source: https://capacms.com/docs/admin/developers Keys, the Queries and GraphQL explorers, request logs, the cache and webhooks, plus the API sheet on every list and entry. **Developers** in the sidebar holds the tools for building on a project. It has six tabs. Each person sees the tabs their role allows, and **Developers** opens the first of them. | Tab | For | Who sees it | | ------------ | --------------------------------------------- | ------------------------------------------------- | | **Queries** | Build and save reads of the legacy `/v2/api`. | Admins and developers | | **GraphQL** | Write and run GraphQL against `/api/graphql`. | Admins and developers | | **Keys** | Create and manage API keys. | Admins and developers | | **Requests** | A log of legacy API requests. | Everyone | | **Cache** | Hit rates, purges and rewarm rules. | Everyone | | **Webhooks** | Endpoints that hear about changes. | Everyone. Changing them needs Admin or Developer. | Old links to **Settings > API keys**, **Settings > Webhooks**, **Settings > Cache**, the API explorer and the request log still work: they open the matching tab. ## Keys Create, edit, rotate and deactivate the keys your sites read with. ![The Keys tab with the project's keys and one key's menu open](https://capacms.com/img/docs/developers-keys-light.webp) * **New key** opens **Create API key**: a **Name**, an **Environment** (**Production** or **Draft**), **Grants** and an optional **Expiry**. * **Grants** starts from **Read only**, **Content writer**, **Full integration** or **Schema builder**, or **Custom** to tick scopes yourself and limit the key to some models. * The secret is shown once. Copy it before you close the dialog. The list shows each key's environment, scopes, **Last used** and **Expires**. A key within 14 days of expiring gets a red dot. Each key's menu has **Edit**, **Allowed origins**, **Rotate** and **Deactivate**. There is no delete: deactivate a key you no longer use. On a legacy key, **Create cap\_ key with these grants** makes its modern replacement. See [Keys](https://capacms.com/docs/concepts/keys) for which key to use where, and [Authentication](https://capacms.com/docs/api/authentication) for every scope. ## Queries A builder for reads of the legacy `/v2/api`. Pick a model, **List All** or **Get Specific**, a key, parameters and filters, and choose **Run**. The request and response appear side by side, with code to copy in JavaScript or TypeScript. **Save** keeps a query under **Saved queries** for the whole team. Queries runs with legacy keys only. For the dated `/api/`, use **GraphQL** or the API sheet below. A saved query stores the key it runs with. Anyone who can open Queries can read that key. Save queries with a read-only key. ## GraphQL The GraphQL Explorer runs queries against `/api/graphql` with any of your keys. ![The GraphQL Explorer with a query, its response, and the Docs panel open](https://capacms.com/img/docs/developers-graphql-light.webp) * **Runs as** picks the key. For a `cap_` key, paste its secret: it is kept in memory only, for this tab. * **Builder** lets you tick fields instead of typing. **Docs** browses your schema. **History** keeps what you ran. * **Method** switches between POST and GET. * **Copy** gives the query as cURL, `fetch`, a GET URL or a persisted GET URL, or downloads the schema as SDL. * **Open in REST** shows the same read as an `/api/entries` request. The response shows what the query cost. See [GraphQL limits](https://capacms.com/docs/api/graphql#limits). On a phone, the tab shows the schema only. For a full walkthrough, see [GraphQL Explorer](https://capacms.com/docs/api/graphql-explorer). ## Requests A log of requests to the legacy `/v2/api`, `/v3/api` and agent API that reached Capa directly, with their status and response time. Requests answered from the CDN's cache, and every `/api/` request, are not logged. Each row's menu copies the request id or URL. Quote the request id when you contact support. ## Cache How well your published reads are cached, and tools to clear them. * **Overview** shows requests, hit rate, misses and errors over a time window, and your busiest URLs. * **Purge** clears cached reads for a model, some entries, some surrogate keys, or the whole project. Capa shows how many cached responses each purge affects before you confirm. Admins and developers. * **Rules** turns on **Rewarm on publish** per model, where your plan includes it. You rarely need **Purge**: publishing already clears what changed. See [Caching](https://capacms.com/docs/concepts/caching). ## Webhooks Endpoints on your own servers that Capa calls when content changes. **Add endpoint** asks for a name, an HTTPS URL, the events to send, and optional headers. See [Webhooks](https://capacms.com/docs/webhooks) for events, signatures and retries. ## The API sheet Every list of entries, and every entry, has a code button in the top bar: **API for this view**. It opens a sheet with the `/api/entries` request that reads what you are looking at. ![The API for this view sheet open over a filtered list of articles, showing the request, the Select block and a fetch snippet](https://capacms.com/img/docs/dev-sheet-light.webp) * **Request** is the URL, ready to copy. * **Select** lets you pick fields. **Filter and sort** carries over the list's filters, and names any that `/api/` cannot express. * **Code** is a `fetch` snippet to copy. * **Run** sends the request with a `cap_` key you paste. The key stays in this browser tab only, until you choose **Forget this key**. The sheet shows on the entries of a model, on a single entry, and on **Content** when a filter is set. It needs a computer-sized screen. ## Related * [Keys](https://capacms.com/docs/concepts/keys) and [Authentication](https://capacms.com/docs/api/authentication). * [Caching](https://capacms.com/docs/concepts/caching). * [GraphQL reference](https://capacms.com/docs/api/graphql). # Agent API Source: https://capacms.com/docs/ai/agent-api Let an agent create models and entries with an API key on /v2/agent, with no person signed in. An agent can stand up a content model, fill it with entries and hand the result to a frontend, using an API key and nothing else. It does that on `/v2/agent/*`, the same routes the Capa admin uses, mounted a second time behind key authentication. To read content from an agent, or to let an assistant like Claude Code or Cursor query it, use the [MCP server](https://capacms.com/docs/ai/mcp) instead. This page is for writes. ## What a key can reach | Surface | Prefix | Authentication | Host | For | | ------------- | ------------------------------------------------ | ------------------ | ------------------------- | -------------- | | Content reads | `/v2/api`, `/v3/api`, `/api/` | API key | `https://cdn.capacms.com` | your site | | Agent writes | `/v2/agent/*` | API key | `https://api.capacms.com` | this page | | Admin | `/v2/models`, `/v2/model-instances` and the rest | a signed-in person | `https://api.capacms.com` | the Capa admin | The admin routes refuse a key with `401`. Send key requests to `/v2/agent/...`, not to the admin path of the same name. ## The key Every request carries one header: ``` x-api-key: pk_... ``` A key belongs to one project, and the project comes from the key. No path or body below names a project, so nothing you send can reach outside it. `/v2/agent/*` takes a legacy key: `pk_` for production, `sk_` for other environments, or an older key with no prefix. A `cap_` key is refused with `401 {"error":"Invalid API key"}`. See [Keys and scopes](https://capacms.com/docs/api/authentication#why-a-scoped-key-is-refused-on-the-legacy-surface). A legacy key holds one of four permissions: | Permission | Can | | ---------- | ---------------------------------------------------------------------------------------------------- | | `read` | read models, entries, media, types, categories, workspaces and search | | `write` | everything `read` can, plus create, update and publish entries, upload media, and arrange workspaces | | `delete` | everything `write` can, plus delete entries and media | | `agent` | everything `write` can, plus create and update models | **You want `agent`.** It is the only permission that can create a model. It deletes nothing, on purpose: an agent building a project never needs to remove a model or an entry, and a key that cannot delete cannot destroy content by misreading an instruction. No legacy key can publish a model version or delete a model. A person on the project mints the key once, with their own session (see [Management API](https://capacms.com/docs/api/management#authentication)): ```bash curl -X POST https://api.capacms.com/v2/tenants/api-keys \ -H "Authorization: Bearer $SESSION_TOKEN" \ -H 'content-type: application/json' \ -d '{"environment":"production","permission":"agent"}' ``` A `permission` that is not one of the four is refused with `400`, rather than becoming a `read` key. ## Create a model ```bash curl -X POST https://api.capacms.com/v2/agent/models/ \ -H "x-api-key: $CAPA_AGENT_KEY" \ -H 'content-type: application/json' \ -d '{ "modelName": "Author", "namespace": "guide_author", "fields": [{ "name": "Name", "namespace": "name", "type": "string", "sortOrder": 0 }] }' ``` The answer is `201` with the model, including its `id`. **Every model gets a required `title` field**, whether you ask for one or not. The model above comes back with two fields, `name` and `title`. So every entry you create must carry a `title`, or it is refused with `400 {"error":"Field title is required"}`. Field types: `string`, `markdown`, `html`, `code`, `color`, `number`, `true_false`, `date`, `enum`, `image`, `video`, `file`, `relation`, `array` and `mixed`. Anything else is refused with `400` and the list of valid types. ## Relations A relation field names the model it points at by namespace: ```json { "name": "Author", "namespace": "author", "type": "relation", "relationRef": "guide_author", "sortOrder": 1 } ``` Use the namespace. A model id is refused with `400` and `Model with namespace does not exist`. For a list of related entries, use an array of relations: ```json { "name": "Co-authors", "namespace": "coauthors", "type": "array", "arrayType": "relation", "relationRef": "guide_author", "sortOrder": 2 } ``` ## Create and publish entries ```bash curl -X POST https://api.capacms.com/v2/agent/model-instances/ \ -H "x-api-key: $CAPA_AGENT_KEY" \ -H 'content-type: application/json' \ -d '{ "modelId": "", "publish": true, "data": { "title": "Notes on the Analytical Engine", "author": "" } }' ``` Three things to know: * `data` values are flat. Send `"title": "..."`, not `"title": { "value": "..." }`. The read API returns the wrapped form, and the write routes do not accept it. * A relation value is the id of the entry it points at. Anything else is refused with `400 {"error":"Field author must be a valid UUID"}`. * `publish` is `false` by default, which saves a draft. A production key on the read API sees published entries only. ## Read what you made Your site reads with a separate `read` key, never the `agent` key. Reads go to the CDN: ```bash curl -H "x-api-key: $CAPA_KEY" \ 'https://cdn.capacms.com/v2/api/guide_post?limit=5&depth=1' ``` ```json { "data": [{ "title": { "type": "string", "value": "Notes on the Analytical Engine" }, "author": { "type": "relation", "value": "f3e288a2-…" } }], "relations": { "f3e288a2-…": { "…": "the author entry" } }, "meta": { "total": 1, "limit": 5, "environment": "production" } } ``` On `/v2/api`, values come back wrapped as `type` and `value`, and a relation's `value` is the target's id. The target itself is in the top-level `relations` map. `depth` sets how far relations expand. The [legacy API reference](https://capacms.com/docs/legacy) has the whole format. For new code, read the same entries from [`/api/entries`](https://capacms.com/docs/api/entries). The same legacy key works there. Keep the `agent` key out of your site. A `NEXT_PUBLIC_` variable is compiled into the browser bundle, and a leaked `agent` key can rewrite your models. ## End to end ```bash KEY=pk_... # permission: agent READ=pk_... # permission: read, for the site API=https://api.capacms.com H="content-type: application/json" # 1. The models AUTHOR=$(curl -s -X POST $API/v2/agent/models/ -H "x-api-key: $KEY" -H "$H" \ -d '{"modelName":"Author","namespace":"guide_author", "fields":[{"name":"Name","namespace":"name","type":"string","sortOrder":0}]}' \ | jq -r .id) POST=$(curl -s -X POST $API/v2/agent/models/ -H "x-api-key: $KEY" -H "$H" \ -d '{"modelName":"Post","namespace":"guide_post", "fields":[{"name":"Title","namespace":"title","type":"string","sortOrder":0}, {"name":"Author","namespace":"author","type":"relation", "relationRef":"guide_author","sortOrder":1}]}' \ | jq -r .id) # 2. The entries. Both models have a required title. ADA=$(curl -s -X POST $API/v2/agent/model-instances/ -H "x-api-key: $KEY" -H "$H" \ -d "{\"modelId\":\"$AUTHOR\",\"publish\":true, \"data\":{\"title\":\"Ada Lovelace\",\"name\":\"Ada Lovelace\"}}" | jq -r .id) curl -s -X POST $API/v2/agent/model-instances/ -H "x-api-key: $KEY" -H "$H" \ -d "{\"modelId\":\"$POST\",\"publish\":true, \"data\":{\"title\":\"Notes on the Analytical Engine\",\"author\":\"$ADA\"}}" # 3. What the site reads curl -s -H "x-api-key: $READ" "https://cdn.capacms.com/v2/api/guide_post?limit=5&depth=1" ``` ## More an agent key can do The same key reaches the other routes mounted under `/v2/agent`: | Prefix | What | Permission it needs | | ------------------------------------ | ---------------------------------------------- | ---------------------------- | | `/v2/agent/scheduled-actions` | schedule entries to publish or unpublish later | `write`, `delete` or `agent` | | `/v2/agent/workspaces` | arrange the admin's left rail | `write`, `delete` or `agent` | | `/v2/agent/models/:id/layout` | arrange the entry editor | `agent` | | `/v2/agent/models/:id/field-changes` | change a field's type on a model with entries | `agent` | Each works as its session twin does. See [Management API](https://capacms.com/docs/api/management). ## Errors | Status | Body | Cause | | ------ | ----------------------------------- | -------------------------------------------- | | 400 | `Field title is required` | every model has a required `title` | | 400 | `Field must be a valid UUID` | a relation value must be the id of an entry | | 400 | `Field must be a string` | `data` values are flat, not `{ "value": … }` | | 401 | `API key required` | no `x-api-key` header | | 401 | `Invalid API key` | an unknown or inactive key, or a `cap_` key | | 403 | `Insufficient permissions` | the key's permission does not allow this | A `403` does not say which permission was missing. If the call is right, check that the key's permission is `agent`. ## What this surface does not do * **Delete models.** No legacy key can, and an `agent` key deletes nothing at all. * **Name an author.** A write made with a key has no person behind it, so its `createdBy` is `null`. * **Show who edited.** Where a record names the person who saved it, a key gets `{ "id": "…" }` and no name, email or avatar. * **Create projects.** A key acts inside one project. Creating a project is done in the Capa admin. # AI and agents Source: https://capacms.com/docs/ai What an AI agent can do with Capa today, which key it needs, and where to connect it. Agents work with Capa the same way your code does: over the API, with a key you choose. A read-only key lets an agent answer questions about your content and write the code that reads it. A key made for agents lets it build models and entries. ## Pick a way in - [MCP server](https://capacms.com/docs/ai/mcp): Connect Claude Code, Cursor or Codex to your project with @capacms/mcp. - [Agent API](https://capacms.com/docs/ai/agent-api): Build models and content from an agent over HTTP. ## What an agent can do | Task | How | Key | | ------------------------------------------ | ------------------------------------------------------------- | ----------------------------------------------------------------------- | | Read entries and explain an error | the MCP server's read tools, or `GET /api/entries` | any key that reads the project | | Write a GraphQL query that fits the limits | the MCP server's GraphQL tools, against your project's schema | a key that reads the models it queries | | Build models and entries | the [Agent API](https://capacms.com/docs/ai/agent-api) | a legacy key with the `agent` permission. `cap_` keys are refused there | `/api/` reads only today: no key can write through it yet. The [keys page](https://capacms.com/docs/api/authentication) explains what each key can do, and how to give an agent one that reads a single model. ## Docs for agents Every docs page has a Markdown copy at the same address plus `.md`, such as [/docs/api/entries.md](https://capacms.com/docs/api/entries.md). [/llms.txt](https://capacms.com/llms.txt) lists every page, and [/llms-full.txt](https://capacms.com/llms-full.txt) is the whole reference in one file. # GraphQL Explorer Source: https://capacms.com/docs/api/graphql-explorer Write and run GraphQL queries in the Capa admin, as one of your API keys, and copy them into your site. The GraphQL Explorer is in the Capa admin under **Developers > GraphQL**. You pick an API key, write a query with autocomplete, run it, and copy it out as cURL, fetch or a URL. Admins and developers on the project can open it. Everything here is a real request to [`/api/graphql`](https://capacms.com/docs/api/graphql), sent from your browser with the key, version and headers you choose, as your site would send it. ## Runs as a key The key picker lists your project's active API keys. The line under it says what a run will see: > Runs as Website build, a production key with 1 scope. Published entries only. It can read 1 model. * **A production key** reads published entries only. **Any other environment** includes drafts. * **A legacy key** (`pk_`, `sk_`) is ready to use as listed. * **A `cap_` key** asks you to paste its secret, because Capa stores only a hash of it. The pasted secret stays in the page's memory and is gone when you close the tab. The editor, the autocomplete and the docs know exactly the models and fields the chosen key can read, and nothing else. Switch keys to see what a different key sees. ## Write and run The query editor completes fields and arguments as you type, marks problems as you go, and shows the docs for the name at the cursor. Below it are the **Variables** and **Headers** panes. Press `⌘ Enter` (`Ctrl Enter` on Windows) to run. The toolbar picks the **Version**, sent as `Capa-Version`, from the versions the API serves, and the method, **POST** or **GET**. A GET whose URL would be too long goes as a POST, as the SDK does, and the response says which method went. Each tab holds one document. Open more tabs to keep several queries side by side. ## The side panel | Tab | What it does | | ----------- | --------------------------------------------------------------------------------------------------------------------------- | | **Builder** | Pick a model, a list or a single entry, then tick fields. Set `first`, filters and sorts. The query text is written for you | | **Docs** | Every type and field the key can read, with a search. Each type has an example query you can run in a new tab | | **History** | Your saved operations, and your last 20 runs in this project with what each read and how it went. Open one to run it again | ## The response The response pane shows the status, the time and the size, then: * the errors, each with its hint, whatever the status; * what the query cost (see [Limits](https://capacms.com/docs/api/graphql#limits)); * the REST request each root field ran as on `/api/entries`; * the cache headers. A refused query answers `200` with `errors` and no `data`, as it does for your site. The Explorer reads the errors, so a refusal shows as a failure. The **REST** popover shows the same read as `/api/entries` URLs before you run anything. ## Save and share **Save** keeps the open tab under a name, up to 100 per project. Saved operations, history and open tabs are kept in this browser, per project. They hold your query, variables and headers text, never a key's secret and never a response. **Copy share link** makes a link that opens the Explorer on the same query, variables and operation name. It never carries a key or your headers: whoever opens it runs it as a key of their own. ## Copy into your site The **Copy** menu writes code that reads the API's address from `CAPA_API_URL` and the key from `CAPA_KEY`. The key itself is never copied. | Item | What you get | | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | Copy environment lines | the lines that set `CAPA_API_URL` and `CAPA_KEY` | | Copy as cURL | the request as a shell command | | Copy as fetch | the request as a `fetch` call | | Copy GET URL | the query as a GET URL, which the CDN can cache | | Copy persisted GET URL | a GET URL that sends only the query's hash. The Explorer registers the query first, with a development key, so the URL answers with data | | Download SDL | the schema the key can read, as SDL | A persisted query belongs to the project, not the key that registered it, so the URL works with your site's production key. See [Persisted queries](https://capacms.com/docs/api/graphql#persisted-queries). ## Shortcuts Press `?` outside the editor for the full list. The ones you will use most: | Keys | Does | | -------------- | ---------------------------- | | `⌘ Enter` | run the query | | `Ctrl Shift P` | prettify | | `⌘ S` | save the operation | | `Ctrl Space` | suggest fields and arguments | | `/` | search the docs | On Windows and Linux, `⌘` is `Ctrl`. # Management API Source: https://capacms.com/docs/api/management The routes behind the Capa admin, for scripts: scheduled publishing, workspaces, entry layouts, field changes, API keys and projects. The Capa admin manages a project over these routes, and you can script them too. They run as a signed-in person, with that person's role in the project. Several are also mounted under `/v2/agent/` for an API key. They are older than `/api/` and answer in their own shape: `{ "error": "" }`, usually with a `code` beside it, never the [`/api/` error envelope](https://capacms.com/docs/errors). ## Authentication Send every request on this page to `https://api.capacms.com`, never to the CDN. Sign in with an email and a password to get a session token: ```bash curl -X POST https://api.capacms.com/v2/user/login \ -H 'content-type: application/json' \ -d '{"email":"maya@example.com","password":"…"}' # {"accessToken":"eyJ…","refreshToken":"eyJ…"} ``` Then send the token as a bearer token: ```bash curl https://api.capacms.com/v2/projects \ -H "Authorization: Bearer $SESSION_TOKEN" ``` The access token lasts 24 hours. `POST /v2/user/refresh-token` with `{ "refreshToken": "…" }` returns a new one, valid for 12 hours. Wrong credentials, and an account with no password, are `401 {"error":"Invalid credentials"}`. | Problem | Answer | | ---------------------------------- | ----------------------------------------------------------------------- | | no `Authorization` header | `401 {"error":"Authentication token required"}` | | your role does not allow the route | `403 {"error":"Insufficient permissions","required":[…],"current":"…"}` | ### Which project A request runs in the project you last opened. To choose one for a single request, send its id: ``` x-capa-project: 3f6c1a8e-2b4d-4c9a-9e21-7d5b0c8f4a12 ``` | The header | What happens | | ---------------------------------------------------------------------- | ------------------------------------------------------ | | absent | the request runs in the project you last opened | | a project you are a member of | the request runs there, with your role in it | | a project you are not a member of, an unknown id, or a deleted project | `403 {"error":"You are not a member of this project"}` | | not a project id | `400 {"error":"x-capa-project must be a project id"}` | The header only chooses. It never changes which project you last opened. API key requests ignore it: a key always acts in its own project. ### With an API key instead Scheduled publishing, publishing now, workspaces, entry layouts and field changes are mounted a second time under `/v2/agent/`, behind a legacy API key in `x-api-key`. The routes, bodies and answers are the same. What a key may do depends on its permission, and a refusal is `403 {"error":"Insufficient permissions"}`. See [Agent API](https://capacms.com/docs/ai/agent-api). ## Scheduled publishing Publish or unpublish entries at a time you choose. | Route | Permission | Does | | -------------------------------------------------- | ------------------ | ---------------------------------------------------------------------------------------------------------- | | `POST /v2/scheduled-actions` | `instance:publish` | schedule one or more targets | | `GET /v2/scheduled-actions` | `instance:read` | list actions. Filters: `status`, `targetId`, `batchId`, `from`, `to`, `order`, `page`, `limit` (up to 200) | | `GET /v2/scheduled-actions/:id` | `instance:read` | one action | | `GET /v2/scheduled-actions/batch/:batchId` | `instance:read` | a batch, with a count per status | | `GET /v2/scheduled-actions/failures` | `instance:read` | how many actions failed in the last 7 days and nobody answered, and the latest | | `GET /v2/scheduled-actions/defaults` | `instance:read` | the project's publishing time zone, or `null` | | `PATCH /v2/scheduled-actions/:id` | `instance:publish` | reschedule one pending action | | `PATCH /v2/scheduled-actions/batch/:batchId` | `instance:publish` | reschedule a batch's pending actions | | `POST /v2/scheduled-actions/:id/cancel` | `instance:publish` | cancel a pending, failed or dead action | | `POST /v2/scheduled-actions/batch/:batchId/cancel` | `instance:publish` | cancel a batch's pending actions | | `POST /v2/scheduled-actions/:id/retry` | `instance:publish` | retry a failed or dead action, as a new action | ```bash curl -X POST https://api.capacms.com/v2/scheduled-actions \ -H "Authorization: Bearer $SESSION_TOKEN" \ -H 'content-type: application/json' \ -d '{ "action": "publish", "targets": [{ "type": "instance", "id": "8b0a1f2e-7d34-4c55-9e51-0f9b1d225c3e" }], "wallTime": "2026-10-01T09:00", "timezone": "America/New_York" }' ``` * `action` is `publish` or `unpublish`. `targets` holds 1 to 500 entries or models. A model target also needs `model:publish`, which no API key holds. * `wallTime` is a local clock time with no offset, and `timezone` is an IANA name. The server converts them and says what it decided in `resolved`. When daylight saving moved the time, `resolved.note` says so in a sentence you can show a person. * The time must be at least 60 seconds ahead. Earlier is `400` with `code: "in_the_past"`. * A target has at most one active publish and one active unpublish. Scheduling another replaces the first, and `replaced` lists the ids it cancelled. * With no `versionId`, an action publishes the latest draft at the time it runs. The answer is `201` with a `batchId`, `resolved`, the `actions` and `replaced`. An action's `status` is `pending`, `running`, `done`, `failed`, `dead` or `cancelled`. Only a pending action can be rescheduled. A running one answers `409` with `code: "already_running"` to a cancel. ### Publishing now | Route | Permission | Does | | ----------------------------------------------- | ------------------ | ------------------------------------------------------------- | | `POST /v2/model-instances/:instanceId/publish` | `instance:publish` | publish an entry's current draft, or the `versionId` you send | | `GET /v2/model-instances/:instanceId/scheduled` | `instance:read` | the entry's pending and running actions | | `POST /v2/model-instances/bulk-publish` | `instance:publish` | publish many entries, by id or by a filter on one model | | `POST /v2/model-instances/bulk-unpublish` | `instance:publish` | the same, unpublishing | A bulk body names entries with `instanceIds`, or with `modelId` and a `filter`. Send `"dryRun": true` first to see what would happen, then send the count you were shown as `expectedCount`. If the selection changed in between, the answer is `409` with `code: "selection_changed"`. Up to 50 entries publish inside the request. Above that the answer is `202` with a `poll` link to the batch. ## Workspaces A workspace is an arrangement of the admin's left rail: folders holding models, entries and media folders. Arranging one never changes a record. | Route | Permission | Does | | ----------------------------------------- | ------------------ | -------------------------------------------------------------------------------------- | | `GET /v2/workspaces` | `workspace:read` | the workspaces you can see | | `POST /v2/workspaces` | `workspace:create` | create one, optionally with a `template` and a `tree` | | `PUT /v2/workspaces/current` | `workspace:read` | choose the workspace you are looking at | | `PUT /v2/workspaces/:id` | `workspace:update` | rename it or change its icon | | `POST /v2/workspaces/:id/duplicate` | `workspace:create` | copy it | | `DELETE /v2/workspaces/:id` | `workspace:delete` | delete it. Its records are untouched | | `GET /v2/workspaces/:id/tree` | `workspace:read` | its nodes | | `GET /v2/workspaces/:id/document` | `workspace:read` | the whole arrangement as one document | | `PUT /v2/workspaces/:id/tree` | `workspace:update` | apply a document, with `mode` `replace` or `merge` | | `POST /v2/workspaces/:id/nodes` | `workspace:update` | add a node | | `PUT /v2/workspaces/:id/nodes/:nodeId` | `workspace:update` | change a node | | `POST /v2/workspaces/:id/nodes/move` | `workspace:update` | move nodes, in one step | | `POST /v2/workspaces/:id/nodes/group` | `workspace:update` | put nodes in a new folder | | `DELETE /v2/workspaces/:id/nodes/:nodeId` | `workspace:update` | remove a node. A folder's children move up a level unless you send `withChildren=true` | | `PUT /v2/workspaces/:id/default` | `settings:update` | make it the project's default. Owners and admins only, never a key | | `PUT /v2/workspaces/:id/assign` | `settings:update` | set where people land. Owners and admins only, never a key | A document is a list of nodes. A folder holds more nodes; a model is named by its namespace or id; an entry and a media folder by id: ```bash curl -X PUT https://api.capacms.com/v2/workspaces/$WORKSPACE_ID/tree \ -H "Authorization: Bearer $SESSION_TOKEN" \ -H 'content-type: application/json' \ -d '{ "mode": "merge", "tree": [ { "folder": "Blog", "children": [{ "model": "blog_post" }, { "model": "author" }] }, { "folder": "Media", "children": [{ "media_folder": "41a2b3c4-d5e6-4f70-8192-a3b4c5d6e7f8" }] } ] }' ``` `merge` adds what is missing and never removes. `replace` makes the workspace exactly the document. `template` on create is `blank`, `website` or `catalog`, and seeds top-level folders only. A workspace is `team`, visible to everyone in the project, or `private`, visible to the person who made it. An API key can only create `team` workspaces. ## Entry layouts An entry layout arranges a model's fields in the entry editor, in cards across a main column and a side column. It is live as soon as it is saved: layouts are not versioned with the model. | Route | Permission | Does | | ------------------------------ | -------------- | ------------------------------------------------------------------------ | | `GET /v2/models/:id` | `model:read` | the model, with its `layout` (or `null`) and `embedByDefault` | | `PUT /v2/models/:id/layout` | `model:update` | save a layout. `{ "layout": null }` resets the model to the plain editor | | `DELETE /v2/models/:id/layout` | `model:update` | reset the model to the plain editor | `:id` is the model's id or its namespace. ```bash curl -X PUT https://api.capacms.com/v2/models/blog_post/layout \ -H "Authorization: Bearer $SESSION_TOKEN" \ -H 'content-type: application/json' \ -d '{ "layout": { "v": 1, "main": [{ "t": "card", "id": "content", "title": "Content", "collapsible": false, "collapsed": false, "items": [{ "t": "field", "id": "f1", "fieldId": "41a2b3c4-d5e6-4f70-8192-a3b4c5d6e7f8", "width": "full" }] }], "aside": [] } }' ``` A field item's `width` is `full`, `two_thirds`, `half` or `third`. A relation field may set `display` to `picker`, `inline` or `embedded`. A layout holds at most 50 cards and 500 fields, a card title at most 80 characters and a description at most 200. A body with no `layout` key is refused, so a typo never resets a layout. A refusal carries `path`, naming the part of the document to fix. ## Field changes Changing a field's type, its array type, its relation or its namespace on a model that already has entries is a field change. You plan it, choose what happens to the values, then a background job converts them and moves the model to the new shape at the end. | Route | Permission | Does | | ------------------------------------------ | -------------- | ---------------------------------------------------------- | | `POST /v2/models/:id/field-changes/plan` | `model:update` | plan a change: what happens to every value. Writes nothing | | `POST /v2/models/:id/field-changes` | `model:update` | apply a plan, with a decision per field | | `GET /v2/models/:id/field-changes/current` | `model:read` | the change in progress, or `204` | | `GET /v2/models/:id/field-changes` | `model:read` | past changes, newest first | | `GET /v2/field-changes/:id` | `model:read` | one change, with the values that failed to convert | | `POST /v2/field-changes/:id/cancel` | `model:update` | cancel, until the model starts moving to the new shape | | `POST /v2/field-changes/:id/resume` | `model:update` | resume a failed or cancelled change | The plan body holds the same `editFields` and `removeIds` you would send to update the model: ```bash curl -X POST https://api.capacms.com/v2/models/$MODEL_ID/field-changes/plan \ -H "Authorization: Bearer $SESSION_TOKEN" \ -H 'content-type: application/json' \ -d '{ "editFields": [{ "id": "6d2e4f8a-0c91-4b11-8a77-5c3e0f9b1d22", "type": "number" }] }' ``` A plan returns a `planHash`, one `op` per field with the choices it allows, and counts of the values that convert, lose detail or fail. A model with many entries is counted in the background: the answer is `202` with a `poll` link instead. Apply sends the same diff again with the `planHash` and one decision per op. A decision's `choice` is `convert`, `clear`, `keep_both` or `delete`. For `convert`, `onFailure` is `clear` (the default: values that fail are left empty and listed) or `stop`. If the model moved since the plan, the answer is `409` with `code: "plan_stale"`: plan again. Where field changes are not switched on, every route here answers `404` with `code: "feature_disabled"`. ## API keys These routes mint and manage your project's keys. Developers > Keys in the admin covers the same ground. See [Keys and scopes](https://capacms.com/docs/api/authentication) for what each key can do. | Route | Permission | Does | | ------------------------------------------------ | ---------------- | -------------------------------------------------------------------------------------------------------------------------------- | | `POST /v2/tenants/api-keys` | `api_key:create` | mint a key | | `GET /v2/tenants/api-keys` | `api_key:read` | list keys. Add `?includeStatus=true` for `active`, `allowedOrigins`, `name`, `keyPrefix`, `scopes`, `expiresAt` and `lastUsedAt` | | `GET /v2/tenants/api-keys/grantable` | `api_key:read` | the scopes and presets a key can be given, and which of them you hold | | `PATCH /v2/tenants/api-keys/:id` | `api_key:update` | change `name`, `scopes` or `expiresAt` | | `POST /v2/tenants/api-keys/:id/rotate` | `api_key:update` | mint a successor. Both work for `graceHours` (0 to 168, default 24) | | `PATCH /v2/tenants/api-keys/:id/active` | `api_key:update` | `{ "active": false }` revokes a key | | `PATCH /v2/tenants/api-keys/:id/allowed-origins` | `api_key:update` | set the origins a key may be used from, up to 50 | | `GET /v2/tenants/api-keys/:id/domains-seen` | `api_key:read` | the domains a key was used from in the last 30 days | | `DELETE /v2/tenants/api-keys/:id` | `api_key:delete` | delete a key | Mint a scoped `cap_` key by sending `scopes`: ```bash curl -X POST https://api.capacms.com/v2/tenants/api-keys \ -H "Authorization: Bearer $SESSION_TOKEN" \ -H 'content-type: application/json' \ -d '{"name":"Website build","environment":"production","scopes":["instance:read","model:read"]}' ``` The answer is `201`, with the secret in `apiKey`. It is the only time this key's secret is shown. A `cap_` key cannot be read again: a lost one is rotated. You can only grant scopes you hold yourself. Without `scopes`, the route mints a legacy key. Send `permission`, one of `read`, `write`, `delete` or `agent`, and it answers `apiKey`, `environment` and `permission`. ## Projects | Route | Permission | Does | | -------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------- | | `GET /v2/projects` | any signed-in person | every project you are a member of, with its `id`, `name`, `slug`, your `role` and when you last opened it | | `PATCH /v2/projects/:id` | the owner, or an admin | change the project's `slug`, the name in its admin URLs | | `GET /v2/projects/resolve` | `model:read`, and `instance:read` with `entry` | turn the `model` and `entry` parts of an admin URL back into ids | A slug is 3 to 48 characters: lowercase letters and digits, with single hyphens between them. A slug another project holds is `409 {"error":"That URL is taken"}`. The old slug stops working at once, with no redirect. ```bash curl -X PATCH https://api.capacms.com/v2/projects/$PROJECT_ID \ -H "Authorization: Bearer $SESSION_TOKEN" \ -H 'content-type: application/json' \ -d '{"slug":"northwind-journal"}' ``` # Changelog Source: https://capacms.com/docs/changelog What changed in Capa, in the API and in the SDK. - [Product updates](https://capacms.com/changelog): New features and fixes across the editor, the admin and the API. - [API changelog](https://capacms.com/docs/changelog/api): Every dated version of /api/ and what it changed. - [SDK changelog](https://capacms.com/docs/changelog/sdk): Every release of @capacms/sdk on npm. # Caching Source: https://capacms.com/docs/concepts/caching How published reads stay in the CDN cache, what a publish purges, and what your own site may still hold. Every published read goes through Capa's CDN at `https://cdn.capacms.com`. The CDN keeps the response, and Capa purges it when the content behind it changes. You do not configure this. You only choose which key you read with. ## Which reads are cached The key's environment decides. A production key reads published content, so its responses can be shared. A development key reads drafts, which have no business in a shared cache. | Response | Production key | Development key | | ---------------------------------------- | ------------------------------------ | ----------------- | | `GET /api/entries/...`, 200 | Cached | Never cached | | `GET /api/graphql`, no errors | Cached | Never cached | | `POST /api/graphql` | Never cached | Never cached | | `GET /v2/api/...` and `/v3/api/...`, 200 | Cached | Never cached | | `GET /v2/seo/...`, 200 | Cached | Never cached | | `GET /files/...`, which takes no key | Cached for a year | Cached for a year | | Any error | Not cached, with one exception below | Not cached | The exception: a production key's `404 entry_not_found` for a well-formed id is cached like a 200. When an entry is taken down, the edge serves the 404 in its place, and the publish that brings it back purges the 404. A GraphQL read that selects `me`, `__schema` or `__type` is never cached. ## The headers A production key's 200 on `/api/` carries: | Header | Value | | ------------------- | ------------------------------------- | | `Cache-Control` | `public, max-age=60` | | `Surrogate-Control` | the edge lifetime, with stale windows | | `Surrogate-Key` | the keys a purge targets (below) | | `ETag` | a strong tag over the body | A development key's response carries `Cache-Control: no-store, no-cache` and no `ETag`. `Cache-Control: max-age=60` is for your side: a browser or your own server may reuse the response for 60 seconds. The edge keeps it longer and relies on purges instead. ## What a publish purges Each cached response is tagged with surrogate keys. A change purges the keys it touches, so only the affected responses are dropped. | Key | One per | | -------------------------- | ----------------------------------------------------------------------------------------- | | `t:` | response | | `c::` | response | | `k:` | response | | `m:` | the root model, every expanded relation's model, and every model a filter or sort reaches | | `e:` | entry in the response, and every expanded entry | | `f:` | file a media value renders | | Change | What it purges | | ------------------------------------------ | ----------------------------------------------------------------------------------------------- | | Publish an entry | its `e:` key and its model's `m:` key, so every response showing it and every list of its model | | Move an entry to another folder | the same keys | | Unpublish or delete an entry | the same keys, hard | | Delete a model | its `m:` key, hard | | Delete a project | its `t:` key, hard | | Edit a file's alt text | its `f:` key | | Delete a file | its `f:` key, hard | | Deactivate, rotate, narrow or expire a key | its `k:` key | A publish marks the old copies stale. The edge can serve the stale copy once more while it fetches the new one, so a read in the same moment as the publish may still see the old body. A hard purge drops the old copy at once: unpublish and delete use it so taken-down content never lingers. A response with too many entries to list keeps its `t:`, `c:`, `k:`, `m:` and `f:` keys and drops the `e:` keys. It then carries `Capa-Cache-Scope: model`. A purge is broader than it needed to be, never narrower. ## Revalidate with an ETag Send the `ETag` back as `If-None-Match`. An unchanged response is a `304` with an empty body. ```bash curl -sD - -o /dev/null 'https://cdn.capacms.com/api/entries/articles?limit=2' \ -H "x-api-key: $CAPA_KEY" -H 'If-None-Match: "3f9c1a…"' ``` The tag covers `data` and `page`, never `meta`, because `meta.requestId` changes on every request. ## Rate limits Capa does not limit how many requests a key makes per minute. It limits how many reads run at once, so a build that sends many reads in parallel can get [`429 rate_limit_exceeded`](https://capacms.com/docs/errors/rate_limit_exceeded) with a `Retry-After` in seconds. Send fewer at a time, and retry after that many seconds. ## The legacy API `/v2/api` and `/v3/api` cache the same way for a `pk_` key: `public, max-age=60` for your side, a longer edge lifetime, and a purge of every cached copy of the model when one of its entries is published. An `sk_` key is never cached. After a publish, allow for the 60 seconds your side may still hold. ## Files and images A file under `/files/` is cached for a year, at the edge and in the browser. Every resized or converted variant carries the file's own key, so deleting the file purges every variant at once. Replacing a file does not purge the edge, so the old copy can stay for up to a year. Upload a new file when a change must show. See [Images](https://capacms.com/docs/guides/images). ## Your own caches The CDN is Capa's half. Anything your site keeps on top is yours to expire: * **Your server or framework.** Next.js, for one, can keep fetch results in its data cache. See [Next.js](https://capacms.com/docs/guides/nextjs) for tags and webhook revalidation. * **A CDN of your own.** The SDK gives you the response's surrogate keys as `cacheTags`, so you can purge by the same keys. * **The browser.** `max-age=60` lets it reuse a response for a minute. ## Related * [Drafts and publishing](https://capacms.com/docs/concepts/drafts-and-publishing) for what each key sees. * [Entries reference: caching](https://capacms.com/docs/api/entries#caching) for every header. * [Webhooks](https://capacms.com/docs/webhooks) to hear about a publish the moment it happens. # Content model Source: https://capacms.com/docs/concepts/content-model Models, namespaces, field types and relations, and how each one comes back from the API. Your content model is the set of content types in a project and the fields each one has. You design it in the admin. The API serves exactly what you design, under the names you give it. ## Models A model is one content type: Article, Author, Product, Home page. | Property | What it does | | ------------------- | ----------------------------------------------------------------------------------------------------------------- | | **Name** | What editors see in the admin. | | **Namespace** | What the API uses: `GET /api/entries/article`. Lowercase letters, digits, `_` and `-`, unique within the project. | | **Single instance** | The model holds one entry, such as site settings or a home page. Set it when you create the model. | | **Searchable** | Its entries are indexed for search. On by default. | Capa fills in the namespace from the name: `Blog Post` becomes `blog_post`. You can change it when you create the model, and later in the model's settings. Changing a model's namespace changes its API address at once. Reads of the old namespace answer `404 model_not_found` . Update your code first. Every model starts with a required `title` field. ## Fields A field is one value on an entry. Each has a **name** editors see and a **namespace** the API uses, unique within the model. | In the admin | Type | The API returns | | ------------------ | ------------------------ | --------------------------------------------------------------------------- | | Text | `string` | a string | | Rich Text | `markdown` | an HTML string from the admin's editor, or Markdown written through the API | | HTML | `html` | an HTML string | | Number | `number` | a JSON number | | True/False | `true_false` | `true` or `false` | | Date | `date` | an ISO 8601 string, with or without a time | | Options | `enum` | the chosen option, as a string | | Color | `color` | a hex string, such as `"#3f6c1a"` | | Image, Video, File | `image`, `video`, `file` | `{ id, url, alt, type, width, height }` | | Model | `relation` | a reference to another entry (below) | | Array | `array` | a list of strings | Two more types exist for content written through the API rather than in the admin: `code`, a string, and `mixed`, any JSON value. A field you never filled comes back as `null`. A value that does not fit its type, such as text saved in a number field, also reads as `null`. ### One value or a list Any field can hold a list. In the field's settings, set **Entries** to **A list of values**. The API then returns an array: a list of numbers, a list of images, a list of related entries. ### Required **Required** is the one rule a field can enforce. The admin will not save an entry with a required field left empty. Capa has no unique, minimum, maximum or pattern rules on fields. Check those in your own code if you need them. ## Relations A **Model** field points at entries of another model: an article's `author`, a product's `related` products. A model can point at itself, for example a page with a `parent` page. A relation comes back as a reference until you ask for more: ```json "author": { "id": "…07", "model": "author" } ``` Expand it with `select`, and you get the related entry with its own fields: ```bash curl -G https://cdn.capacms.com/api/entries/article \ -H "x-api-key: $CAPA_KEY" -H 'Capa-Version: 2026-10-01' \ --data-urlencode 'select=title,author(name,photo)' ``` ```json "author": { "id": "…07", "model": "author", "status": "published", "fields": { "name": "Ana Ruiz", "photo": { "url": "https://cdn.capacms.com/files/…" } } } ``` A list relation comes back as `{ "items": [...], "pageInfo": {...} }` so a long list can be paged. An expanded relation that points at a draft a production key cannot see shows its id, marked `"missing": true`. A relation you do not expand is a plain reference, and Capa does not check it. When an entry is deleted, Capa removes it from every relation that pointed at it. ### How deep One request can expand relations 5 levels deep, counting the entry you asked for: `author(employer(city(country(name))))`. It can expand at most 12 relations, and return at most 5,000 entries in all. See [the size caps](https://capacms.com/docs/api/entries#the-size-caps). ## Names in GraphQL GraphQL turns each namespace into a type name: `article` becomes `Article`, `blog_post` becomes `BlogPost`. Field names stay as you wrote them, with any character GraphQL cannot use turned into `_`. See [GraphQL names](https://capacms.com/docs/api/graphql#names). ## Changing a model that has entries Adding a field is always safe. Existing entries read `null` for it until someone fills it in. Once a model has entries, Capa checks each change to an existing field against the values already stored: | Change to an existing field | What happens | | ------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | | Reorder it, move it in the layout, or change its width | Saved at once | | Change its name or label, or turn **Required** on or off | Saved at once | | Add an option to an **Options** field | Saved at once | | Change its type, including Text to Rich Text or one value to a list | Capa shows what happens to every stored value, and asks before it changes anything | | Change its namespace or its related model, or remove an option | The same review | | Remove it | The same review. Confirming deletes the field and its data | The `title` field cannot be removed, change type or change namespace. See [Change a field's type](https://capacms.com/docs/modeling/change-field-type) for the review and what converts to what. ## Related * [Build a model](https://capacms.com/docs/modeling/build-a-model) in the admin, step by step. * [Entries reference](https://capacms.com/docs/api/entries) for `select`, filters and sorting. * [TypeScript](https://capacms.com/docs/guides/typescript) to generate types from your models. # Drafts and publishing Source: https://capacms.com/docs/concepts/drafts-and-publishing What a draft is, what publishing changes, and what each kind of key sees. Every entry keeps its versions. Saving writes a new draft version. Publishing makes a version the live one. Your site only ever sees the live version, unless it reads with a draft key. ## The states of an entry | State | What it means | API `status` | | ---------------------- | ----------------------------------------------------- | ----------------------- | | Draft | Never published. Only draft keys can read it. | `draft` | | Published | The live version is the newest one. | `published` | | Published, draft ahead | Live, with newer saved changes that are not live yet. | `changed` | | Scheduled | A publish or unpublish is set for a time you chose. | Unchanged until it runs | The admin's status line shows these, such as **Published v7** or **Scheduled: publishes Tue 6 Oct, 9:00 AM EDT**. ## Saving does not publish **Save draft** writes a new version and leaves the live one alone. Your site keeps showing what was published until someone publishes again. **Publish** saves and publishes in one step. The previous live version stays in the entry's history. Every role that can edit entries can also publish them: Content, Developer and Admin. A Viewer can do neither. See [Members and roles](https://capacms.com/docs/projects/members-and-roles). ## What each key sees The key's environment decides, never a query parameter. | Key | Sees | `status` can be | | ------------------------------- | --------------------------------------------- | ------------------------------- | | Production (`cap_live_`, `pk_`) | Published entries, at their published version | `published` | | Draft (`cap_test_`, `sk_`) | Every entry, at its newest version | `published`, `draft`, `changed` | A production key never learns that a draft exists. For a published entry with a newer draft, it reads the published data and `status: "published"`. The same goes for the entry's `updatedAt` and `tags`: a production key reads them as they were at the last publish, so saving a draft changes nothing it can see. A draft key reads the newest data and the entry's own `updatedAt`, which every save moves. One exception: **folders**. Moving an entry to another folder applies to every key at once, published or not, because the folder is where the entry is filed in the admin, not part of its content. ## Publishing reaches your site in seconds Publishing purges every cached response that shows the entry, and every list of its model. The next read fetches the new version, usually within a few seconds. Your own site may cache too. See [Caching](https://capacms.com/docs/concepts/caching). ## Unpublish Unpublishing takes the entry off the public API at once. The cached copies are dropped immediately, not refreshed in the background. The content is kept. The live version turns back into a draft, so you can edit it and publish it again later. ## Delete Deleting an entry removes it and every version of it. In the admin there is no undo. Capa then removes the deleted entry from every relation that pointed at it, in drafts and in the live version. It never publishes a draft to do so. ## Schedule You can schedule a publish or an unpublish for a date, a time and a time zone. Capa publishes **the latest saved draft at that moment**, so a typo fixed after scheduling still goes out. An entry can have one scheduled publish and one scheduled unpublish at a time. Scheduling a second publish replaces the first. See [Publishing](https://capacms.com/docs/editor/publishing). ## Draft keys are not a sandbox There is one database. A draft key only changes what you can read. A draft key that holds publish rights publishes to your live site. For a safe place to experiment, use a separate project. ## Related * [Publishing](https://capacms.com/docs/editor/publishing) for doing all of this in the admin. * [Keys](https://capacms.com/docs/concepts/keys) for production and draft keys. * [Entries reference: drafts](https://capacms.com/docs/api/entries#drafts-and-environments). # Keys Source: https://capacms.com/docs/concepts/keys The two key families, production and draft keys, and which key to give each site, script and browser. Every read from Capa carries an API key in the `x-api-key` header. The key decides three things: which project you read, which content you see, and what else the key may do. ## Two families | Family | Looks like | Works on | Carries scopes | | ------ | ---------------------------------------------- | ------------------------------------------------------------------------- | --------------------------------- | | Scoped | `cap_live_…`, `cap_test_…` | `/api/` only | Yes | | Legacy | `pk_…`, `sk_…`, or an older key with no prefix | `/v2/api`, `/v3/api`, `/v2/schema`, `/v2/seo`, `/v2/agent/*`, and `/api/` | No. One of four fixed permissions | **Start new work with a `cap_` key.** It can be limited to exactly what one integration needs: reads only, one model, an expiry, a list of allowed origins. **Your existing legacy key keeps working**, unchanged, everywhere it works today. It also reads `/api/`, so you can move a page to the new API without minting anything. ## Production and draft keys Every key has an environment. | Environment | Prefix | Sees | Cached at the CDN | | ------------------------------- | ------------------ | ---------------------------- | ----------------- | | Production | `cap_live_`, `pk_` | Published entries only | Yes | | Draft, or any other environment | `cap_test_`, `sk_` | Every entry, drafts included | Never | The admin labels the two choices **Production** and **Draft**. `GET /api/me` reports the key's `environment`, and any value other than `production` sees drafts. A draft key is a read-visibility dial, not a sandbox. There is one database. A draft key that can publish publishes to your live site. If you want somewhere safe to break things, use a separate project. See [Drafts and publishing](https://capacms.com/docs/concepts/drafts-and-publishing) for what each key sees. ## What a scoped key can do today `/api/` reads and never writes. A `cap_` key that holds `instance:read` reads entries through `GET /api/entries/...` and `/api/graphql`. `GET /api/me` answers any key. A write scope you grant today is stored and enforced from the day the route it unlocks exists, and does nothing before then. **Keep your legacy key for writes** and for anything that calls `/v2` or `/v3`. ## Why a `cap_` key is refused on `/v2` and `/v3` The legacy routes do not read scopes. If they accepted a scoped key, they would serve it everything, and a restriction you set in the admin would be ignored on the surface most sites use. So a `cap_` key sent to `/v2/api`, `/v3/api`, `/v2/schema`, `/v2/seo` or `/v2/agent/*` gets the same `401` an unknown key gets. A site that is half ported runs two keys: its legacy key for the old pages and a `cap_` key for the new ones. ## Create a key 1. Open **Developers > Keys** and choose **New key**. 2. Give it a **Name** after the system that will hold it, such as `Website` or `Shopify sync`. 3. Pick an **Environment**: **Production** for a live site, **Draft** for a preview or staging build. 4. Under **Grants**, pick a starting point. **Read only** "reads published content, models and media" and suits almost every site. 5. Optionally set an **Expiry**. 6. Choose **Create key**, then copy the secret. **The secret is shown once.** Capa stores it hashed and cannot show it again. If you lose it, rotate the key. Only Admins, Developers and the project owner can create keys. The full scope list and presets are in [Authentication](https://capacms.com/docs/api/authentication). ## Check a key `GET /api/me` says what the key in your hand can do: ```bash curl https://cdn.capacms.com/api/me -H "x-api-key: $CAPA_KEY" ``` It returns the key's `environment`, `scopes`, the `models` it can read, the `surfaces` it works on, its version pin and its expiry. Read it before you debug a `403`. ## Keys in a browser Anything in a browser bundle is public. If a key must ship to the browser: * Use a **production** `cap_live_` key with **Read only**. * Bind it to your site's origins with **Allowed origins** in the key's row menu. A request from another origin gets [`403 origin_refused`](https://capacms.com/docs/errors/origin_refused). * Never ship a draft key. Anyone who views your page source could read your unpublished drafts with it. A server-to-server call sends no `Origin` header, so origin binding never blocks your own backend. ## Rotate, expire and deactivate * **Rotate** mints a second key with the same grants and gives the old one a grace window: Now, 1 hour, 24 hours or 7 days. Deploy the new secret inside the window. A key can be rotated once. * **Expiry** is optional. Within 14 days of it, Developers > Keys marks the key with a red dot. Put the date in your own calendar too: nothing emails you. * **Deactivate** stops a key at once. You can reactivate it later. An expired, deactivated or unknown key all get the same [`401 invalid_key`](https://capacms.com/docs/errors/invalid_key), so the answer never reveals which it was. ## Which key for which job | Job | Key | | -------------------------------------------- | -------------------------------------------------- | | A public website | `cap_live_`, Read only | | A preview or staging build that shows drafts | `cap_test_`, Read only, server-side only | | A browser app | `cap_live_`, Read only, bound to your origins | | A build script or static site generator | `cap_live_`, Read only | | An existing site on `/v2/api` | Keep its legacy key | | An AI assistant through the MCP server | `cap_live_`, Read only. See [AI and MCP](https://capacms.com/docs/ai) | One key per site or integration makes rotation painless and makes the **Last used** column meaningful. ## Related * [Authentication](https://capacms.com/docs/api/authentication) for scopes, presets, rotation and allowed origins in detail. * [The Developers section](https://capacms.com/docs/admin/developers) for the Keys screen. * [Versions](https://capacms.com/docs/concepts/versions) for how a key's version pin works. # Projects Source: https://capacms.com/docs/concepts/projects A project is one site's own space, with its own content, keys, members, plan and URL. A project holds everything for one site: its models, entries, media, keys, webhooks and members. Nothing is shared between projects, and nothing leaks from one to another. Most teams run one project per site. An agency runs one per client. ## What belongs to a project | Thing | Notes | | -------------------------------------- | --------------------------------------------------------------------------------------------------------------- | | Models and entries | A model's namespace is unique within its project. Two projects can both have `article`. | | Media | Files are uploaded to one project. Their URLs are public, so any site can show them. | | Keys | A key reads exactly one project. The key alone says which, so `/api/` needs no project id. | | Members | People are invited to a project with a role. Someone can belong to many projects with a different role in each. | | Webhooks, settings, publishing history | Per project. | | Plan and billing | Per project. | The API calls a project a **tenant**, for example in the response of `GET /api/me`. It is the same thing. ## A project's URL Every project has its own address in the admin: ``` https://app.capacms.com/project/northpeak-outdoor/content ``` `northpeak-outdoor` is the project's **URL slug**. Capa makes it from the project name when you create the project: lowercase, with every other character turned into `-`. If another project already has it, Capa adds `-2`, `-3` and so on. Renaming the project does not change its slug. To change the slug, see [Manage projects](https://capacms.com/docs/projects/manage#change-the-project-url). Changing the slug breaks every bookmark and shared link that uses the old one. Capa does not redirect from the old address. ## Links to entries An entry's address in the admin reads like its title: ``` https://app.capacms.com/project/northpeak-outdoor/content/article/winter-field-guide-2026-layering-above-treeline-3f9c1a7b ``` Only the last part, the start of the entry's id, finds the entry. Renaming the entry changes the title part of new links, and old links keep working. Share these links freely with your team. ## Switch between projects The project switcher sits at the left of the top bar. It lists your five most recent projects, then all of them from A to Z. You can search when you have many. The admin remembers which project you opened last. Two browser tabs can each have a different project open. ## One project per site, not per environment A draft key shows drafts, but it reads the same content as your live site. There is one database. A draft key that can publish publishes to your live site. So use one project per site, with a production key for the live site and a draft key for previews. If you want a place to try things that can never reach the live site, make a separate project. ## Related * [Manage projects](https://capacms.com/docs/projects/manage): create, rename, transfer and delete. * [Members and roles](https://capacms.com/docs/projects/members-and-roles): who can do what. * [Keys](https://capacms.com/docs/concepts/keys): production and draft keys. # Versions Source: https://capacms.com/docs/concepts/versions How Capa dates the API, how to pin a version, and how to hear about the next one. The `/api/` surface has no version in its path. It has a date in a header. ```http 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 the old one until you change the header or the key's pin. Today there is one version, `2026-10-01`. ## Two dials Capa separates the API's shape from your content's shape. | Dial | Header | Who moves it | Today | | ---------------- | --------------- | -------------------------------- | ------------ | | Platform version | `Capa-Version` | Capa, when we change the API | `2026-10-01` | | Data contract | `Capa-Contract` | You, when you change your models | `1` | Neither forces the other. Our envelope moving is our business. Your fields moving is yours. ## Which version a request gets Capa picks the first of these that applies: 1. The `Capa-Version` header on the request, when it names a supported date. 2. The version your key is pinned to. 3. `2026-10-01`, the first version. Nothing floats. A key never starts receiving a newer shape on the day we publish one. A `Capa-Version` that is not a supported date is refused, never rounded: ```json { "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 says which version served it, in the `Capa-Version` response header and in `meta.version`. See [invalid_version](https://capacms.com/docs/errors/invalid_version). ## Pin the key, not every call A `cap_` key is pinned to the newest version at the moment you create it. That pin never moves on its own. So the day a second date exists, the keys you already hold keep the date they were made under. Only a key made after that day gets the new one. A legacy `pk_` or `sk_` key carries no pin. It resolves to `2026-10-01`. To move a key to another date, change its pin. This is a session call, so it goes to `api.capacms.com` with your login token: ```bash curl -X PATCH https://api.capacms.com/v2/tenants/api-keys/ \ -H 'Authorization: Bearer ' \ -H 'content-type: application/json' \ -d '{ "apiVersion": "2026-10-01" }' ``` A date that does not exist is refused, so a key can never be pinned to a version that is not there. The SDK sends the header for you. Pin it in code where you create the client: ```ts import { createClient } from "@capacms/sdk/next"; const capa = createClient({ baseUrl: "https://cdn.capacms.com", apiKey: process.env.CAPA_KEY!, version: "2026-10-01", }); ``` ## List the versions `GET /api/versions` needs no key. It is the one route a build script can call without a credential. ```bash curl https://cdn.capacms.com/api/versions ``` ```json { "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 version. | | `supported` | Older, fully supported, no end date. | | `deprecated` | A successor exists and has been announced. It still serves every byte it served. | | `sunset` | Its end date has passed. | ## Hear about the next version Ask for every version newer than yours: ```bash curl 'https://cdn.capacms.com/api/versions?from=2026-10-01' ``` Today the list is empty. Put this call in a weekly job and your own tooling tells you when a new date ships. Each change in the list has a stable `id` (`.`), a `kind` (`response`, `request` or `behaviour`), the `surfaces` it touches (`rest`, `graphql`) and a one-sentence `summary`. ## Sunset Capa does not retire a version on its own schedule while you are still calling it. A version is deprecated only when its successor exists and has been announced. It gets a sunset date only after its traffic has been zero for thirty days. No version is deprecated today. No response carries a `Deprecation` or `Sunset` header yet, and no version answers `410`. ## Contracts `Capa-Contract` names your data-model version. There is exactly one today, `1`, and it is the default, so you can leave the header off. Any other value is refused with [`404 contract_not_found`](https://capacms.com/docs/errors/contract_not_found). Multiple contracts arrive later, with screens to start, compare, publish and retire them. ## Related * [API overview](https://capacms.com/docs/api) for the request and response shape. * [API versions reference](https://capacms.com/docs/api/versions) and the [API changelog](https://capacms.com/docs/changelog/api). * [Keys](https://capacms.com/docs/concepts/keys) for what else a key carries. # Entries Source: https://capacms.com/docs/editor/entries Find, create, edit, duplicate and delete entries, and work with linked entries and images. An entry is one piece of content: one article, one product, one page. This page covers everything you do with entries short of publishing them. For that, see [Publishing](https://capacms.com/docs/editor/publishing). ## Find entries **Content** in the sidebar lists every entry in the project. ![The Content page filtered to articles, with Title, Model, Status and Updated columns](https://capacms.com/img/docs/content-list-light.webp) * **Search content** finds entries as you type. * **Model**, **Category**, **Tag** and **Status** narrow the list. **Clear all** removes every filter. * Click a column header to sort by it. * Switch between **List view** and **Grid view** at the top right. * Choose how many rows a page shows at the bottom: 25, 50, 100 or 250. To see one model's entries, open the model from **Models**, or set the **Model** filter. A model's own list adds a **Published** column and a finer **Status** filter: **Published**, **Published with draft** and **Draft**. | Status | Means | | -------------------------- | ----------------------------------------------- | | **Draft** | Never published. Your site cannot see it. | | **Published** | Live, and the live version is the newest. | | **Published, draft ahead** | Live, with saved changes that are not live yet. | | **Scheduled** | Set to publish or unpublish at a time. | ## Create an entry 1. In **Content**, choose **New content**, then pick the model. In a model's own list, choose **New entry**. 2. Fill in the fields. Fields marked as required must have a value. 3. Choose **Save draft** to keep it private, or **Publish** to put it live. A model that holds a single entry, such as site settings, has no **New entry** button. Open its one entry instead. If a required field is empty, Capa says how many fields need fixing and opens the cards that hold them. ## Edit an entry Open it from any list. Its fields are grouped in cards, in the layout your developers designed. **Capa does not save as you type, and has no save shortcut.** Choose **Save draft** when you are done. If you leave with unsaved changes, Capa asks: **Save**, **Discard** or **Keep editing**. ![An article open in the editor, with the details panel showing Status, Version and Tags](https://capacms.com/img/docs/entry-editor-light.webp) ### The details panel The panel beside the fields shows the entry's facts. On a narrow screen, open it with the **Entry details** button. | Row | What it shows | | ------------------------ | ------------------------------------------------------------------------------ | | **Status** | Draft, published, or scheduled, with **Change** and **Cancel** for a schedule. | | **Version** | Every saved version. Pick one to see it. | | **Created**, **Updated** | When the entry was created and last saved. | | **Tags** | The entry's tags. Type and press Enter to add one. | | **References** | Other entries that link to this one. | **Tags save at once**, separately from **Save draft**. ### Older versions Every save is kept. Choose a version under **Version** to see the entry as it was then. There is no restore button: to bring something back, copy it from the older version into the current one and save. ## Linked entries A field that links to another entry, such as an article's author, shows **Choose a…** with the model's name. * Pick from the list, or search it. * **Create a new entry** opens a side panel where you write the new entry without leaving this one. Save or publish it there. * Click a linked entry to open it in a side panel. You can stack up to four. * **Change entry** swaps the link. **Clear** removes it. If the linked entry was deleted, Capa clears the link shortly after the delete. Until then the field says **Missing entry**. ## Images and files An empty image, video or file field shows **Drop a file or browse**. Click it to open the media library, then pick a file or upload a new one. Once a file is chosen, its menu offers **Replace**, **Upload new**, **Copy URL**, **Open** and **Clear**. An image also has an **Alt text** box: describe the image for people who cannot see it. The alt text is copied from the media library when you pick the file. Editing it here changes it for this entry only. See [Media](https://capacms.com/docs/editor/media). ## Duplicate an entry * **In the entry**, open **⋯** and choose **Duplicate**. The copy is a new draft with exactly what is on screen, unsaved changes included, and opens straight away. * **In a list**, choose **Duplicate** in the row's menu. The copy is a new draft of what was last saved. Tags are not copied. ## Delete an entry Open the entry, choose **⋯**, then **Delete**, and confirm. In a list, **Delete** is in each row's menu and in the selection bar, for admins and developers. Deleting removes the entry and all its versions. There is no undo. To take an entry off your site but keep it, unpublish it instead. Capa then removes it from every entry that linked to it, in drafts and in the live version. ## Work on many entries at once Tick the rows you want. A bar appears with **N selected** and the actions: | Action | Does | | ------------------------------------------- | -------------------------------------------- | | **Publish**, **Unpublish**, **Schedule** | See [Publishing](https://capacms.com/docs/editor/publishing). | | **Add to workspace**, **Group into folder** | Add shortcuts to the sidebar. Nothing moves. | | **Copy IDs** | Copy the entries' ids, for a developer. | | **Duplicate** | In a model's own list. | | **Delete** | Admins and developers. | The header checkbox selects the current page. On a phone, choose **Edit** to start selecting. ## Share a link Every entry has its own address, built from its model and title, such as `…/content/article/winter-field-guide-2026-layering-above-treeline-3f9c1a7b`. Copy it from the browser. It keeps working after the entry is renamed. ## Related * [Publishing](https://capacms.com/docs/editor/publishing): publish, schedule and unpublish. * [Navigating the admin](https://capacms.com/docs/editor/navigating): search and the sidebar. * [Layout builder](https://capacms.com/docs/modeling/layout-builder): how the editor's cards are designed. # Media Source: https://capacms.com/docs/editor/media Upload images, video and documents, write alt text, replace and delete files, and how images are resized for your site. **Media** in the sidebar holds every file in the project: images, video, audio and documents. Entries use them through image, video and file fields. ## Upload files 1. Choose **Media**, then **Upload**. 2. Drag files onto the panel, paste them with **⌘V** (**Ctrl+V**), or choose **Click to Select Files**. ![The Upload files panel over the media grid, with two files uploading](https://capacms.com/img/docs/media-upload-light.webp) Each file shows its progress, with a button to cancel it. Files go into the media folder you have open. | Kind | Accepted | | --------- | ---------------------------------------------- | | Images | JPG, PNG, GIF, BMP, WebP, AVIF, SVG | | Video | MP4, MOV, WebM, MKV, AVI | | Audio | MP3, WAV, OGG, FLAC, M4A, AAC | | Documents | PDF, TXT, CSV, DOC, DOCX, XLS, XLSX, PPT, PPTX | * A file can be up to 800 MB. Text and CSV files can be up to 16 MB, and SVGs up to 8 MB. * HEIC photos from an iPhone are not accepted. Export them as JPG first. * A file's contents must match its extension. * Your plan sets how much the project can store in all. When there is a limit, a meter at the top of **Media** shows how much is used. You can also upload straight from an entry's image or file field. Those files go to the top level of the library. ## Find files * **Search files** matches file names. * Filter by **Type**, **Size**, **Date** and **Alt text**. **No alt text** finds the images that still need a description. * **Sort** by upload date, newest or oldest first. * Switch between **Grid View** and **List View**. Click a file to open it full screen. Use the arrow keys to move between files, **+** and **−** to zoom, and **Esc** to close. **File Details** shows its size, type, alt text and URL. ## Write alt text Alt text describes an image for people who cannot see it, and for search engines. Write what the image shows, in a sentence. * **One file:** open its menu, choose **Edit file**, fill in **Alt Text**, and choose **Update**. * **Many files:** open **More actions** at the top of **Media**, turn on **Quick edit**, write alt text beside each file, then choose **Save All**. ### How alt text reaches your site When you pick an image in an entry, its alt text is copied into the entry. After that the entry's own alt text is what your site gets. So changing alt text in **Media** updates only the entries whose own alt text is empty. To change it everywhere, edit it in each entry too. ## Copy a file's URL Open the file's menu and choose **Copy media URL**. Every file has a public URL on Capa's CDN: ``` https://cdn.capacms.com/files/winter-field-guide-cover_1759302000000_3f9c1a7b2e4d.jpg ``` Anyone with the link can open the file. Do not upload anything that must stay private. ## Replace a file Open the file's menu and choose **Replace file**. Upload a new file or pick one from the library, compare **Current** and **New**, and confirm. The URL stays the same, so every entry that uses the file points at the new one. Capa's CDN can keep serving the old file for a long time, and a visitor's browser may too. If the change must show, upload a new file and pick it in the entries instead. ## See where a file is used Open the file's menu and choose **View references**. It lists the entries that use the file. ## Delete a file Open the file's menu, choose **Delete file**, and confirm. The file is removed for good. Entries that used it keep its old address, which no longer works, so their image breaks. Check **View references** first, and pick another file in those entries. ## Folders Media folders appear in your sidebar workspace, not on the **Media** page. To show one, choose **Add media folders…** in a workspace folder's menu. To move files, drag them from the grid onto a media folder in the sidebar. See [Navigating the admin](https://capacms.com/docs/editor/navigating#workspaces). ## Automatic resizing You upload one large original. Your site asks for the size it needs, and Capa makes it on the fly: smaller, cropped, or in a lighter format such as WebP. There is nothing to do in the admin. Upload the best original you have. Developers can read how in [Images](https://capacms.com/docs/guides/images). ## Who can do what Everyone can view media. Admins, developers and the Content role can upload, edit, replace and delete. # Navigating the admin Source: https://capacms.com/docs/editor/navigating The sidebar, workspaces and folders, search, notifications and keyboard shortcuts. The admin has three places to look: the sidebar on the left, the top bar, and the page itself. This page covers how to move around and how to arrange the sidebar for your team. ## The sidebar ![The sidebar expanded, with the main items, a team workspace with its folders, and the account row at the bottom](https://capacms.com/img/docs/admin-sidebar-light.webp) | Item | What is there | Who sees it | | ---------------- | --------------------------------------------------------------- | --------------------------- | | **Dashboard** | Counts, recent updates, and what is scheduled. | Everyone | | **Models** | The project's content types. | Everyone | | **Content** | Every entry, of every model. | Everyone | | **Publish** | Entries with changes waiting to go live. The badge counts them. | People who can publish | | **Media** | Images, videos and documents. | Everyone | | **Developers** | Keys, the API explorers, request logs, the cache and webhooks. | Everyone, with tabs by role | | **Integrations** | Connected services, such as Shopify. | Admins and developers | Below the main items is your **workspace**: folders and shortcuts your team arranges. At the bottom is your account. Collapse the sidebar with the arrow at its top, or press **⌘B** (**Ctrl+B** on Windows). ## Workspaces A workspace is an arrangement of the sidebar: folders, and shortcuts to the models, entries and media folders you use most. **Nothing in a workspace moves or changes your content.** Removing a folder or a shortcut never deletes an entry. ### Team and private | Kind | Who sees it | | ----------- | ---------------------------------------------------------------------------------- | | **Team** | Everyone in the project. Anyone who can edit can arrange it. | | **Private** | Only you. Arrange it however you like. Nothing you do there moves for anyone else. | Switch workspaces with the name at the top of the tree. An admin picks the project's **default** workspace, the one people see when they first arrive. ### Make a workspace 1. Open the workspace switcher and choose **New workspace**. 2. Give it a name, and choose **Team** or **Private**. 3. Pick a template: **Blank**, **Website** (Pages, Components, Settings and Media folders) or **Catalog** (Products, Collections and Assets). 4. Choose **Create**. To rename it, change its icon or visibility, duplicate it or delete it, choose **Manage workspace…** in the switcher. Only the person who made a workspace, or an admin, can change it. ### Folders * **Add a folder** with the **+** beside the workspace name. It arrives named "New folder", ready to rename. * **Nest folders** with **New folder inside** in a folder's menu. * **Rename** by double-clicking, or with **F2**. * **Remove** a folder from its menu. What was inside moves up a level. Clicking a folder opens and closes it. ### Shortcuts Drag a row from **Content**, a model's entries or **Models** into the tree. Drop it on a folder to file it there, or on another shortcut to group both in a new folder. Or select rows in a list and choose **Add to workspace** or **Group into folder** in the selection bar. To show media folders, choose **Add media folders…** in a folder's menu and pick them. In a team workspace, you can pin a model only if you can edit models. Anyone who can edit entries can pin entries. ## Search Press **⌘K** (**Ctrl+K**), or choose **Search** in the top bar. Type to search models, entries, saved queries, people and media at once. Results are grouped by kind. ![The search palette open over the Content page, with results grouped under Models and Entries](https://capacms.com/img/docs/search-palette-light.webp) For more control, choose **Search everything with filters** at the bottom. The search page filters by type, model, category, folder and date, and sorts by relevance or date. ## Notifications The bell in the top bar shows what happened in the last 7 days: * **Needs attention**: a scheduled publish that failed, with **Retry**. * **Activity**: entries and batches published, and invitations waiting. * **What's new**: the latest change to Capa itself. Items are marked read after the menu has been open for a moment. **Mark all read** clears them at once. ## Your account Open the account menu at the bottom of the sidebar for: * **Profile**: your name and picture. * **Settings**: the project's settings. * **What's new**: every recent change to Capa. * **Theme**: Light, Dark or System. * **Keyboard shortcuts** and **Log out**. ## Keyboard shortcuts | Keys | Does | | ------------------- | ------------------------------------------------------------ | | **⌘K** / **Ctrl+K** | Open search | | **⌘B** / **Ctrl+B** | Collapse or expand the sidebar | | **Esc** | Close the open panel, or clear a selection | | **⌘.** / **Ctrl+.** | Edit the fields of the entry's model. Admins and developers. | | **Space** | Select the focused row in a list | | **↑ ↓ ← →** | Move through the workspace tree | | **⌘↑** / **⌘↓** | Move a tree row up or down | | **F2** | Rename the focused folder | | **← →**, **+ −** | In the media viewer: previous and next file, zoom | There is no save shortcut. Use **Save draft** in the top bar. ## On a phone The sidebar becomes a tab bar along the bottom: **Dashboard**, **Content**, **Models**, **Media** and **Account**. **Account** holds the project switcher, your workspace, **Publish**, **Developers**, settings and the theme. You can read your workspace on a phone. Arrange it on a computer. # Publishing Source: https://capacms.com/docs/editor/publishing Publish now, schedule, unpublish, publish many entries at once, and see what is waiting to go live. Saving keeps your changes private. Publishing puts them on your site. This page covers every way to do it. Admins, developers and the Content role can publish. Viewers cannot. ## Publish an entry now Open the entry and choose **Publish**. Capa saves what is on screen and publishes it in one step. A confirmation appears with **Undo** for a few seconds. Undo puts the previous live version back, or takes the entry off your site if it was never published before. Your site shows the change within seconds. Capa clears its cached copies as you publish. The very first visitor afterwards can still get the old version once while the cache refreshes. ## What the status line says The **Status** row in the entry's details panel, and the lists, say where an entry stands: | Status | Means | | ----------------------------------------------- | -------------------------------------------------------------- | | **Draft** | Never published. | | **Published v7** | Live, at version 7. | | **Published, draft ahead** | Live, with saved changes that are not live yet. | | **Scheduled: publishes Tue 6 Oct, 9:00 AM EDT** | Set to go live then. **Change** moves it, **Cancel** stops it. | ## Schedule a publish 1. Open the entry, then **⋯** and **Schedule**. 2. Leave **Action** on **Publish**. 3. Pick a date and a time, or a quick pick: **In 1 hour**, **Tomorrow 9:00**, **Next Monday 9:00**. 4. Check the **Timezone**. It starts on your browser's time zone. 5. Choose **Schedule publish**. ![The Schedule dialog with a date, a time and the New York time zone picked, and the summary line “Publishes Tue 6 Oct, 9:00 AM EDT, in 4 days.”](https://capacms.com/img/docs/schedule-dialog-light.webp) The dialog spells out the result before you confirm, such as "Publishes Tue 6 Oct, 9:00 AM EDT, in 4 days.", with the time in UTC and in your own zone. If the entry has unsaved changes, the button reads **Save and schedule**: it saves them first. ### What gets published **The latest saved draft at the scheduled time**, not the version you had when you scheduled. Fix a typo after scheduling, save, and the fix goes out too. ### The rules * The time must be at least a minute ahead. * An entry can have one scheduled publish and one scheduled unpublish. Scheduling a second publish replaces the first, and the dialog tells you so. * One schedule can cover up to 500 entries. * If you discard an entry's draft changes, its scheduled publish is cancelled. ### Daylight saving A time that does not exist, such as 02:30 on the night clocks go forward, moves forward to the first real time: 03:30. A time that happens twice, on the night clocks go back, uses the first one. Capa tells you when either happens. ### Change or cancel a schedule On the entry's status line, choose **Change** to pick a new time, or **Cancel** to stop it. The **Scheduled** tab of the Publish page lists every schedule, soonest first. ### If a scheduled publish fails When a failure can be retried, Capa tries again after 1, 5, 30 and 30 more minutes, then stops. A failure shows: * on the entry's status line, with **Try again**, * as a red dot on **Settings > Publishing**, where **Retry now** runs it again, * in the notification bell, with **Retry**. Common reasons: the entry was deleted, or a required field is empty. ## Schedule an unpublish Open the entry, then **⋯** and **Schedule**, and set **Action** to **Unpublish**. At that time the entry comes off your site. Use it for offers and announcements that end. An entry can have a scheduled unpublish and a scheduled publish at once. The status line shows both. ## Unpublish Open the entry, then **⋯** and **Unpublish**. You can also unpublish from any list: tick the entries and choose **Unpublish**. The entry comes off your site at once, and its cached copies are dropped. Nothing is lost: the content becomes a draft again, ready to edit and publish later. ## The Publish page **Publish** in the sidebar lists every entry with changes waiting. The badge on it counts them. ![The Publish page on Ready to publish, with entries selected and the selection bar showing Publish now, Schedule and Discard](https://capacms.com/img/docs/publish-page-light.webp) ### Ready to publish Each row shows the entry, its model, the kind of change, which fields changed, and who edited it last. | Change | Means | | ----------------------- | -------------------------------------- | | **New** | Never published, or unpublished since. | | **Edited** | Has saved changes that are not live. | | **Unpublish scheduled** | Unchanged, but set to come off. | Filter by **Model**, **Change**, **Edited by** and **Schedule**, or search titles. Click a row to see exactly what changed: text is compared word by word, with removed words struck through and added words highlighted. Other fields show **Was** and **Now**. Some rows cannot be published yet, and say why: a required field is empty, or the entry was written against an older version of its model. Open the entry, fix it and save. ### Publish, schedule or discard together Tick rows, or tick the header and choose **Select all**. Then: * **Publish now** publishes them. Rows that cannot be published are left out, and Capa lists them first. * **Schedule** sends them all out at one time. Quick picks: **Tonight 9 PM**, **Tomorrow 9 AM**, **Next Monday 9 AM**. * **Discard** throws away the draft changes on edited entries. Each goes back to its published version. The discarded drafts stay in the entry's history. ### Scheduled The **Scheduled** tab lists every schedule, soonest first, grouped as they were scheduled. Each group and each entry has **Publish now**, **Reschedule** and **Cancel**. Cancelling publishes nothing: the entries keep their changes and return to Ready to publish. ## Publish all drafts in a model To publish every waiting entry of one model, open the model's **⋯** menu, on its entries page or in **Models**, and choose **Publish all drafts**. 1. Narrow it down if you need to, by **Folder**, **Tags**, **Updated since** or **Title contains**. 2. Read the count. Capa says how many are ready, and which are left out and why. 3. Choose **Publish N**, or **Schedule instead** to pick a time. More than 50 entries publish in the background. A progress pill shows how far it has got, and a message says when it is done. ## Publish from a list In **Content** or a model's entries, tick rows and choose **Publish**, **Unpublish** or **Schedule** in the selection bar. **Publish** from a list does not check required fields. To be sure every entry is complete, publish from the Publish page, which skips incomplete entries. ## Settings > Publishing **Settings > Publishing** is the record of everything scheduled and published in the project: what, who, when, and how it went. Filter by state: **Scheduled**, **Running**, **Done**, **Failed**, **Failed, gave up** or **Canceled**. Each row's menu can reschedule, publish now, retry, or cancel it. Cancelling a failure is how you mark it handled. ![Settings, Publishing with a failed row and its menu open, showing Retry now](https://capacms.com/img/docs/settings-publishing-light.webp) ## Related * [Drafts and publishing](https://capacms.com/docs/concepts/drafts-and-publishing): what each key sees. * [Entries](https://capacms.com/docs/editor/entries): create, edit and delete. # Your first day Source: https://capacms.com/docs/get-started/editors For editors. Find an entry, change it, and publish or schedule it. This page is for people who write and publish. You will find an entry, edit it, and put it live. No code involved. ## Sign in Open the invitation email and follow its link. If you are new to Capa, you create your account there, with the address the invitation was sent to. After that, sign in at [app.capacms.com](https://app.capacms.com). You land in the project you were invited to. If you work on several, switch with the project switcher at the left of the top bar. ## Find your way around ![The admin on the Content page, with the sidebar, the top bar and a list of entries](https://capacms.com/img/docs/admin-overview-light.webp) The sidebar on the left holds: | Item | What is there | | ------------- | ---------------------------------------------------------------------- | | **Dashboard** | Counts of entries and files, recent updates, and what is scheduled. | | **Models** | The content types of this project, such as Article or Product. | | **Content** | Every entry, of every model, in one list. | | **Publish** | Every entry with changes waiting to go live. Shown if you can publish. | | **Media** | Images, videos and documents. | Below them is your team's **workspace**: folders and shortcuts to the models and entries you use most. See [Navigating the admin](https://capacms.com/docs/editor/navigating). ## Find an entry Choose **Content**, then type in **Search content**. Narrow the list with the **Model**, **Status** and **Tag** filters. Or press **⌘K** (**Ctrl+K** on Windows) anywhere and type a few words of the title. ## Edit it Open the entry. Its fields are grouped in cards. Change what you need. **Capa does not save as you type.** When you are done, choose **Save draft** in the top bar. A draft is invisible to your site until someone publishes it. If you try to leave with unsaved changes, Capa asks whether to save them. ## Publish it Choose **Publish**. Capa saves and publishes in one step, and your site shows the change within seconds. Published the wrong thing? The confirmation stays for a few seconds with **Undo**, which puts the previous version back. To publish later, open **⋯** in the top bar and choose **Schedule**. Pick a date, a time and a time zone. See [Publishing](https://capacms.com/docs/editor/publishing). ## What you can do Your role decides. Ask an admin if you need more. | Role | Can | | --------------------------- | ------------------------------------------------------------------------------------------------ | | **Content** | Create, edit, publish and delete entries, and upload media. Delete is in the entry's **⋯** menu. | | **Viewer** | Read everything. Change nothing. | | **Developer** and **Admin** | Everything above, plus models and keys. Admins also manage members and billing. | See [Members and roles](https://capacms.com/docs/projects/members-and-roles) for the full table. ## Next * [Entries](https://capacms.com/docs/editor/entries): create, duplicate, filter and delete. * [Publishing](https://capacms.com/docs/editor/publishing): schedule, unpublish, and publish many entries at once. * [Media](https://capacms.com/docs/editor/media): upload images and write alt text. # Next.js quickstart Source: https://capacms.com/docs/get-started/nextjs Build a Next.js site that lists and shows your Capa entries with the SDK. This page builds a two-page Next.js site: a list of articles and a page for each one. It reads published entries through `@capacms/sdk`. It assumes the `article` model from the [Quickstart](https://capacms.com/docs/get-started/quickstart), with a `title` and a `body`, and a production key. ## Create the app ```bash pnpm create next-app@latest northpeak --ts --app --yes cd northpeak pnpm add @capacms/sdk@next ``` The SDK runs on Node 18 or later. ## Add your key Create `.env.local` in the project root: ```bash CAPA_API_URL=https://cdn.capacms.com CAPA_KEY=cap_live_... ``` These two names are the ones every Capa tool reads. Keep the key server-side: never prefix it with `NEXT_PUBLIC_`. ## Make one client ```ts // lib/capa.ts import { createClient } from "@capacms/sdk/next"; import { withCache } from "@capacms/sdk/nextjs"; export const capa = createClient({ baseUrl: process.env.CAPA_API_URL!, apiKey: process.env.CAPA_KEY!, version: "2026-10-01", // Keep each read in Next's data cache for up to 60 seconds. fetch: withCache(fetch, { revalidate: 60 }), }); ``` `version` pins the shape of every response, so a later API version never changes your site under you. ## List the articles ```tsx // app/page.tsx import Link from "next/link"; import { capa } from "@/lib/capa"; export default async function Home() { const articles = await capa.entries.list("article", { select: ["title"], sort: ["-publishedAt"], limit: 20, }); return (

    Articles

      {articles.data.map((article) => (
    • {String(article.fields.title)}
    • ))}
    ); } ``` `select` asks for only the fields the page shows. `sort: ["-publishedAt"]` puts the newest first. ## Show one article ```tsx // app/articles/[id]/page.tsx import { notFound } from "next/navigation"; import { capa } from "@/lib/capa"; export default async function ArticlePage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; const article = await capa.entries.get("article", id, { select: ["title", "body"] }); if (!article) notFound(); return (

    {String(article.data.fields.title)}

    {String(article.data.fields.body)}
    ); } ``` `entries.get` returns `null` when the entry does not exist or is not published, so the page answers 404. Any other error throws a `CapaError` with the API's code and hint. A Rich Text field arrives as the string it was saved as. The admin's editor saves HTML, so render it as HTML. A value written through the API in Markdown stays Markdown. ## Run it ```bash pnpm dev ``` Open [localhost:3000](http://localhost:3000). Publish a change in Capa, and the page shows it within a minute. ## Make it yours * **Readable URLs.** Add a `slug` field to the model, link to `/articles/${slug}`, and read with `filter: { slug: { eq: slug } }, limit: 1`. * **Types.** Run `capa-codegen` and every field is typed from your model. See [TypeScript](https://capacms.com/docs/guides/typescript). * **Instant updates and drafts.** Revalidate on publish, and show drafts to editors. See the [Next.js guide](https://capacms.com/docs/guides/nextjs). * **Images.** Resize on the CDN with a `next/image` loader. See [Images](https://capacms.com/docs/guides/images). # Quickstart Source: https://capacms.com/docs/get-started/quickstart Create a model, publish an entry, make a key and read it with curl. About five minutes. By the end of this page you will have one published entry and a `curl` command that reads it from Capa's CDN. You need a Capa account. Sign in at [app.capacms.com](https://app.capacms.com). ### Create a project [#create-a-project] A project holds one site's models, entries, media and keys. 1. On **Projects**, choose **Create project**. 2. Enter a **Project name**, such as `Northpeak Outdoor`. 3. Choose **Create**. Capa opens the new project. Already have a project? Open it and skip this step. ### Create a model [#create-a-model] A model is a content type, such as an article or a product. 1. Choose **Models** in the sidebar, then **New model**. 2. Enter `Article` in **Unique Model Name**. The **Namespace** fills in as `article`. That is the name the API uses. 3. Choose **Create Model**. Every new model starts with a `title` field. ### Add a field [#add-a-field] 1. Open the model's row menu and choose **Edit fields**. 2. Under the card of fields, choose **Add field**, then **Rich Text**. 3. In the field's settings, set **Name** to `Body`. Its namespace becomes `body`. 4. Choose **Done**, then **Save fields** in the top bar. Fields are saved only when you choose **Save fields**. ### Write and publish an entry [#write-and-publish-an-entry] 1. Choose **Content** in the sidebar, then **New content**, then **Article**. 2. Fill in **Title** and **Body**. 3. Choose **Publish**. **Publish** saves and publishes in one step. **Save draft** saves without publishing, and a production key never sees a draft. ### Create a key [#create-a-key] 1. Open **Developers > Keys** and choose **New key**. 2. Set **Name** to `Website`. 3. Leave **Environment** on **Production**. 4. Under **Grants**, choose **Read only**. 5. Choose **Create key**, and copy the key. The key is shown once. Capa stores it hashed and cannot show it again. If you lose it, rotate the key. ### Read it [#read-it] Put the key in an environment variable, then read the model's entries: ```bash export CAPA_KEY=cap_live_... curl "https://cdn.capacms.com/api/entries/article?select=title,body" \ -H "x-api-key: $CAPA_KEY" \ -H "Capa-Version: 2026-10-01" ``` You get your entry back: ```json { "data": [ { "id": "…3a", "model": "article", "status": "published", "fields": { "title": "Winter Field Guide 2026: Layering Above Treeline", "body": "The cherries came in late this year…" } } ], "page": { "limit": 25, "hasNext": false, "next": null, "hasPrev": false, "prev": null }, "meta": { "version": "2026-10-01", "contract": 1, "environment": "production", "requestId": "req_0f3c…" } } ``` ## What just happened * `article` in the URL is the model's namespace. * `select` chose two fields. Without it you get every field. * The response came from Capa's CDN. When you publish a change, Capa purges the cached copy, and the next read gets the new version. * `Capa-Version` pins the shape of the response. See [Versions](https://capacms.com/docs/concepts/versions). ## Something went wrong? | You got | It means | | ---------------------------------- | ---------------------------------------------------------------------------------------------- | | `401 missing_key` or `invalid_key` | The `x-api-key` header is missing, or the key is wrong. Copy it again, or rotate it. | | `404 model_not_found` | No model with that namespace, or the key cannot read it. Check the namespace under **Models**. | | `"data": []` | The entry is a draft. Publish it, or read with a draft key. | Every error has its own page under [Errors](https://capacms.com/docs/errors). ## Next * [Next.js](https://capacms.com/docs/get-started/nextjs): show this entry on a site. * [Content model](https://capacms.com/docs/concepts/content-model): field types and relations. * [Any language with fetch](https://capacms.com/docs/guides/fetch): filters, sorting and paging. # What is Capa Source: https://capacms.com/docs/get-started/what-is-capa Projects, models, entries, drafts, keys and the CDN, on one page. Capa is a hybrid headless CMS. Your team writes and publishes in the admin. Your sites, apps and agents read the content as JSON from a cached API. This page names the parts. Each one has a page of its own. ## The parts | Part | What it is | | ------------ | ------------------------------------------------------------------------------------------------------------------- | | **Project** | One site's space: its models, entries, media, keys and members. Nothing crosses from one project to another. | | **Model** | A content type you define, such as Article or Product. Each has a **namespace**, like `article`, that the API uses. | | **Field** | One value on a model: text, a number, a date, an image, a link to another entry. | | **Entry** | One piece of content: one article, one product. | | **Relation** | A field that points at another entry, such as an article's author. Your content is a graph, not a set of tables. | | **Version** | Every save of an entry is kept. One version is live. | | **Key** | The credential your code reads with. It decides which project you read and what you see. | ## How content gets to your site 1. An editor writes an entry in the admin at [app.capacms.com](https://app.capacms.com) and saves it as a draft. 2. They publish it. That version becomes the live one. 3. Your site asks for it with a key: ```bash curl "https://cdn.capacms.com/api/entries/article?select=title,slug" \ -H "x-api-key: $CAPA_KEY" \ -H "Capa-Version: 2026-10-01" ``` 4. Capa's CDN answers from cache when it can. Publishing a change purges the cached copies, so the next read gets the new version. ## Drafts and the live version Saving never changes your site. Publishing does. A **production key** sees published entries only. A **draft key** sees drafts too, for preview builds and staging sites. The key decides, never a query parameter. See [Drafts and publishing](https://capacms.com/docs/concepts/drafts-and-publishing). ## Ways to read | Surface | Use it for | | -------------------------- | -------------------------------------------------------------------------------------------- | | `GET /api/entries/{model}` | REST reads. Choose fields with `select`, filter, sort, page, and expand relations. | | `/api/graphql` | GraphQL reads, typed from exactly the models your key can read. | | `@capacms/sdk` | A TypeScript client for both, with types generated from your models and helpers for Next.js. | | `/v2/api`, `/v3/api` | The legacy API that existing sites still use. It keeps working. | These are for reading. Content is written in the admin, or by an AI agent through the [Agent API](https://capacms.com/docs/ai/agent-api). ## Who uses it * **Developers** model the content, make keys and build the site. Start with the [Quickstart](https://capacms.com/docs/get-started/quickstart). * **Editors** write, schedule and publish. Start with [Your first day](https://capacms.com/docs/get-started/editors). * **Agencies** run a project per client and hand it over. Start with the [Agency playbook](https://capacms.com/docs/projects/agency-playbook). ## Words you will see The admin says **project** and **entry**. Parts of the API say **tenant** and **instance** for the same things, such as the `instance:read` scope. See the [Glossary](https://capacms.com/docs/glossary). # Glossary Source: https://capacms.com/docs/glossary The words Capa uses, and the older API names you will meet for the same things. The admin and these docs say **project** and **entry**. Parts of the API, older code and some error messages say **tenant** and **instance**. They mean the same things. | Term | Means | | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **Project** | One site's space in Capa: its models, entries, media, keys and members. The API calls it a **tenant**. See [Projects](https://capacms.com/docs/concepts/projects). | | **Model** | A content type you define, such as Article. The API sometimes calls it a **data model**. | | **Namespace** | A model's or a field's name in the API, such as `article` or `hero_image`. Lowercase, unique within its project or model. | | **Field** | One value on a model: text, a number, an image, a link. See [Content model](https://capacms.com/docs/concepts/content-model). | | **Entry** | One piece of content of a model. The API calls it an **instance**: the `instance:read` scope reads entries, and `instance.published` is the webhook event for a publish. | | **Version** | One saved state of an entry. Every save makes a new one, and one of them is live. | | **Draft** | An entry, or a version of one, that is not live. Only draft keys can read it. | | **Published** | Live. Production keys read the published version. | | **Published, draft ahead** | Live, with saved changes that are not live yet. The API's `status` for it is `changed`. | | **Relation** | A field that links to entries of another model, such as an article's author. | | **Expand** | Ask for a related entry's own fields in the same read: `select=title,author(name)`. | | **Select** | The `select` parameter: which fields a read returns. | | **Key** | The credential your code reads with, sent in `x-api-key`. See [Keys](https://capacms.com/docs/concepts/keys). | | **Scoped key** | A key starting `cap_live_` or `cap_test_`. It carries scopes and works on `/api/`. | | **Legacy key** | A key starting `pk_` or `sk_`, or an older key with no prefix. It works on `/v2/api`, `/v3/api` and `/api/`. | | **Production key** | A key that reads published entries only: `cap_live_` or `pk_`. Its reads are cached. | | **Draft key** | A key that reads drafts too: `cap_test_` or `sk_`. The API reports its environment as something other than `production`. Never cached. | | **Scope** | One permission on a scoped key, written `resource:action`, such as `instance:read`. | | **Version (API)** | The dated contract a read follows, such as `2026-10-01`, sent in `Capa-Version`. See [Versions](https://capacms.com/docs/concepts/versions). | | **Surrogate key** | A tag on a cached response that names what it holds, so a publish purges exactly the responses that showed the entry. See [Caching](https://capacms.com/docs/concepts/caching). | | **Purge** | Clearing cached responses. Publishing purges what changed. | | **Workspace** | An arrangement of the admin's sidebar: folders and shortcuts. Moving things in it never moves content. | | **Layout** | How a model's entries are arranged in the editor: cards, columns and widths. | | **Owner** | The one person in a project who can transfer it or delete it. | | **Role** | What a member may do: Admin, Developer, Content or Viewer. See [Members and roles](https://capacms.com/docs/projects/members-and-roles). | | **Legacy API** | `/v2/api` and `/v3/api`, the read API existing sites use. It keeps working. | # Astro Source: https://capacms.com/docs/guides/astro Build an Astro site from Capa entries with plain fetch, at build time or on every request. Astro needs no SDK to read Capa. This guide uses `fetch`, a small helper, and Astro's own pages. It assumes an `article` model with `title`, `slug` and `body` fields, and a production key. The [Quickstart](https://capacms.com/docs/get-started/quickstart) shows how to make both. ## Add your key Put the key in `.env` at the project root: ```bash CAPA_API_URL=https://cdn.capacms.com CAPA_KEY=cap_live_... ``` Astro exposes variables without the `PUBLIC_` prefix to server code only, so the key never reaches the browser. Declare them for TypeScript: ```ts // src/env.d.ts interface ImportMetaEnv { readonly CAPA_API_URL: string; readonly CAPA_KEY: string; } ``` ## Write the helper ```ts // src/lib/capa.ts export interface Entry { id: string; model: string; status: string; fields: F; } interface ListPage { data: Entry[]; page: { hasNext: boolean; next: string | null }; } /** One GET against Capa's read API. Throws with the API's code and hint. */ export async function capa(path: string, params: Record = {}): Promise { const url = new URL(path, import.meta.env.CAPA_API_URL); for (const [name, value] of Object.entries(params)) url.searchParams.set(name, value); const res = await fetch(url, { headers: { "x-api-key": import.meta.env.CAPA_KEY, "Capa-Version": "2026-10-01" }, }); const body = await res.json(); if (!res.ok) { const error = body?.error ?? {}; throw new Error(`${res.status} ${error.code}: ${error.message} ${error.hint ?? ""}`.trim()); } return body as T; } /** Every entry of a model, following the cursor from page to page. */ export async function allEntries(namespace: string, params: Record = {}) { const entries: Entry[] = []; let after: string | null = null; do { const query: Record = after ? { ...params, after } : params; const page: ListPage = await capa>(`/api/entries/${namespace}`, query); entries.push(...page.data); after = page.page.next; } while (after); return entries; } ``` `capa` sends the two headers every read needs. `allEntries` follows `page.next` until the list ends, so a model of any size comes back whole. Pass `limit: "200"` to fetch it in as few requests as possible. ## List the articles ```astro --- // src/pages/index.astro import { allEntries } from "../lib/capa"; type Article = { title: string; slug: string }; const articles = await allEntries
    ("article", { select: "title,slug", sort: "-publishedAt", }); ---

    Articles

    ``` `select` asks for only the fields the page shows. The response is the same JSON `curl` returns: see [Any language with fetch](https://capacms.com/docs/guides/fetch). ## A page per article ```astro --- // src/pages/articles/[slug].astro import { allEntries, type Entry } from "../../lib/capa"; type Article = { title: string; slug: string; body: string }; export async function getStaticPaths() { const articles = await allEntries
    ("article", { select: "title,slug,body" }); return articles.map((article) => ({ params: { slug: article.fields.slug }, props: { article }, })); } interface Props { article: Entry
    ; } const { article } = Astro.props; ---

    {article.fields.title}

    {article.fields.body}
    ``` `getStaticPaths` reads every article once, at build time, and Astro writes one page for each. A Rich Text field arrives as the string it was saved as. The admin's editor saves HTML, so render it with `set:html`. A value written through the API in Markdown stays Markdown. ## When content changes By default Astro builds static pages, so a published change appears on the next build. Pick one: * **Rebuild on a schedule.** Most hosts can run a build every hour or every night. * **Rebuild on publish.** Point a webhook at your host's deploy hook. See [Webhooks](https://capacms.com/docs/webhooks). * **Render on request.** Add a server adapter, then put `export const prerender = false;` in a page's frontmatter. The page reads Capa on every request. Those reads are served from Capa's CDN, and a publish purges them, so the page is current within seconds. ## Read one entry on request On a server-rendered page, read the one entry you need instead of the whole model: ```astro --- // src/pages/articles/[slug].astro, rendered on request import { capa, type Entry } from "../../lib/capa"; export const prerender = false; type Article = { title: string; body: string }; const { data } = await capa<{ data: Entry
    [] }>("/api/entries/article", { select: "title,body", "filter[slug]": Astro.params.slug ?? "", limit: "1", }); const article = data[0]; if (!article) return Astro.redirect("/404"); ---

    {article.fields.title}

    {article.fields.body}
    ``` `filter[slug]` matches the slug exactly. `limit: "1"` stops after the first match. ## Errors The helper throws with the API's code and hint, such as `404 model_not_found` for a namespace the key cannot read. A failed read at build time fails the build, which is what you want: no page ships with missing content. Every code has a page under [Errors](https://capacms.com/docs/errors). ## Related * [Entries reference](https://capacms.com/docs/api/entries) for `select`, filters, sorting and paging. * [Images](https://capacms.com/docs/guides/images) for resizing on the CDN. * [Keys](https://capacms.com/docs/concepts/keys) for production and draft keys. # Any language with fetch Source: https://capacms.com/docs/guides/fetch Read Capa from any stack with plain HTTP. curl first, then JavaScript and Python, with paging and error handling. Capa's read API is plain HTTPS and JSON. Anything that can send a header can read your content. This guide uses `curl`, then the same calls in JavaScript and Python. ## Before you start You need three things: * **A key.** Create one under **Developers > Keys** in the admin. For a public site, a production `cap_live_` key with the **Read only** starting point is right. See [Keys](https://capacms.com/docs/concepts/keys). * **The host.** Every read goes to `https://cdn.capacms.com`. * **A version.** Send `Capa-Version: 2026-10-01`, or rely on your key's pin. See [Versions](https://capacms.com/docs/concepts/versions). Keep the key in an environment variable, never in your code: ```bash export CAPA_KEY=cap_live_... # from your secret store ``` ## Check the key `GET /api/me` says what the key in your hand can do. Run it first, before you debug anything else. ```bash curl https://cdn.capacms.com/api/me \ -H "x-api-key: $CAPA_KEY" \ -H 'Capa-Version: 2026-10-01' ``` Read `environment` (`production` sees published entries only), `scopes`, and `models`, the models this key can read. ## Read a list ```bash curl -G https://cdn.capacms.com/api/entries/articles \ -H "x-api-key: $CAPA_KEY" \ -H 'Capa-Version: 2026-10-01' \ --data-urlencode 'select=title,slug,author(name)' \ --data-urlencode 'sort=-publishedAt' \ --data-urlencode 'limit=10' ``` `articles` is the model's namespace. The response wraps entries in `data`, with paging in `page`: ```json { "data": [ { "id": "…41", "model": "articles", "status": "published", "fields": { "title": "Winter Field Guide 2026: Layering Above Treeline", "slug": "winter-field-guide-2026", "author": { "id": "…07", "model": "authors", "status": "published", "fields": { "name": "Ana Ruiz" } } } } ], "page": { "limit": 10, "hasNext": true, "next": "c1.eyJ2…", "hasPrev": false, "prev": null }, "meta": { "version": "2026-10-01", "contract": 1, "environment": "production", "requestId": "req_0f3c…" } } ``` Your fields live under `fields`. `id`, `model` and `status` always come back. See [the entry shape](https://capacms.com/docs/api/entries#what-an-entry-looks-like). ## Choose, filter and sort Four parameters cover most pages: | Parameter | Example | What it does | | ------------- | -------------------------------------- | -------------------------------------------------------------- | | `select` | `title,slug,author(name)` | Return only these fields. `author(name)` expands the relation. | | `filter[...]` | `filter[slug]=winter-field-guide-2026` | Equality, or `filter[views][gte]=10` for an operator. | | `sort` | `-publishedAt,title` | Up to three keys. `-` means descending. | | `limit` | `25` | 1 to 200 a page. The default is 25. | Any other parameter is refused with [`400 invalid_parameter`](https://capacms.com/docs/errors/invalid_parameter), so a typo never quietly returns the wrong entries. A parameter whose name starts with `_` or `utm_` is ignored, so cache busters and campaign tags are safe. The full grammar is in the [entries reference](https://capacms.com/docs/api/entries). ## Read one entry By id: ```bash curl -G https://cdn.capacms.com/api/entries/articles/ \ -H "x-api-key: $CAPA_KEY" \ --data-urlencode 'select=title,body,author(name)' ``` By slug, read a list of one: ```bash curl -G https://cdn.capacms.com/api/entries/articles \ -H "x-api-key: $CAPA_KEY" \ --data-urlencode 'filter[slug]=winter-field-guide-2026' \ --data-urlencode 'limit=1' ``` An id that does not exist, or that a production key cannot see, is [`404 entry_not_found`](https://capacms.com/docs/errors/entry_not_found). A filter that matches nothing is a `200` with an empty `data`. ## In JavaScript This runs in Node 18 or later, Deno, Bun and the edge runtimes. It retries a `429` or `503` after `Retry-After`, and throws everything else with the request id. ```js const BASE = "https://cdn.capacms.com"; const KEY = process.env.CAPA_KEY; export async function capa(path, params = {}, attempt = 0) { const url = new URL(path, BASE); for (const [name, value] of Object.entries(params)) url.searchParams.set(name, String(value)); const res = await fetch(url, { headers: { "x-api-key": KEY, "Capa-Version": "2026-10-01" }, }); if ((res.status === 429 || res.status === 503) && attempt < 3) { const wait = Number(res.headers.get("Retry-After") ?? 1); await new Promise((resolve) => setTimeout(resolve, wait * 1000)); return capa(path, params, attempt + 1); } const body = await res.json().catch(() => null); if (!res.ok) { const error = body?.error ?? { code: "http_" + res.status, message: res.statusText }; throw new Error( `${res.status} ${error.code}: ${error.message}` + (error.hint ? ` ${error.hint}` : "") + (body?.meta?.requestId ? ` (${body.meta.requestId})` : ""), ); } return body; } const latest = await capa("/api/entries/articles", { select: "title,slug,author(name)", sort: "-publishedAt", limit: 10, }); for (const article of latest.data) { console.log(article.fields.title, "by", article.fields.author?.fields?.name); } ``` Prefer TypeScript? The SDK does all of this with types. See [the SDK](https://capacms.com/docs/sdk) and [TypeScript](https://capacms.com/docs/guides/typescript). ## In Python Standard library only, Python 3.8 or later. ```python import json import os import time import urllib.error import urllib.parse import urllib.request BASE = "https://cdn.capacms.com" KEY = os.environ["CAPA_KEY"] class CapaError(Exception): pass def capa(path, params=None, attempt=0): url = BASE + path if params: url += "?" + urllib.parse.urlencode(params) request = urllib.request.Request( url, headers={"x-api-key": KEY, "Capa-Version": "2026-10-01"} ) try: with urllib.request.urlopen(request) as response: return json.load(response) except urllib.error.HTTPError as err: if err.code in (429, 503) and attempt < 3: time.sleep(int(err.headers.get("Retry-After", "1"))) return capa(path, params, attempt + 1) try: body = json.load(err) except ValueError: body = {} error = body.get("error", {}) request_id = body.get("meta", {}).get("requestId", "") raise CapaError( f"{err.code} {error.get('code')}: {error.get('message')} " f"{error.get('hint') or ''} ({request_id})" ) from None latest = capa( "/api/entries/articles", {"select": "title,slug,author(name)", "sort": "-publishedAt", "limit": 10}, ) for article in latest["data"]: print(article["fields"]["title"]) ``` ## Page through everything There are no page numbers. Follow `page.next` until `hasNext` is `false`. Keep the same `sort` on every page: a cursor is tied to the sort it was made with. ```js export async function* allEntries(namespace, params = {}) { let after; do { const page = await capa(`/api/entries/${namespace}`, after ? { ...params, after } : params); yield* page.data; after = page.page.next; } while (after); } for await (const article of allEntries("articles", { select: "title,slug", limit: 200 })) { console.log(article.fields.slug); } ``` ```python def all_entries(namespace, params=None): params = dict(params or {}) while True: page = capa(f"/api/entries/{namespace}", params) yield from page["data"] if not page["page"]["hasNext"]: return params["after"] = page["page"]["next"] for article in all_entries("articles", {"select": "title,slug", "limit": 200}): print(article["fields"]["slug"]) ``` With `curl`, the same loop: ```bash url='https://cdn.capacms.com/api/entries/articles?limit=200&sort=title&select=title,slug' while [ -n "$url" ]; do body=$(curl -s "$url" -H "x-api-key: $CAPA_KEY") echo "$body" | jq -c '.data[] | .fields.slug' next=$(echo "$body" | jq -r '.page.next // empty') url=$([ -n "$next" ] && echo "https://cdn.capacms.com/api/entries/articles?limit=200&sort=title&select=title,slug&after=$next") done ``` Need a total? Add `count=true` and read `page.total`. It costs a second pass, so leave it off when you do not show the number. ## Handle errors Every error on `/api/` has one shape: ```json { "error": { "type": "invalid_request", "code": "unknown_field", "message": "articles has no field \"subtitle\" in contract 1.", "param": "select", "hint": "Fields: title, slug, excerpt, body, cover, author, tags. System keys: id, model, status, createdAt, updatedAt, publishedAt, version, folder, $tags.", "docs": "https://docs.capacms.com/errors/unknown_field" }, "meta": { "version": "2026-10-01", "contract": 1, "requestId": "req_0f3c…" } } ``` * Branch on `error.code`. It is stable. The message is for people. * Read `error.hint`. It usually names the fix: the fields you could have asked for, or the scope your key lacks. * Log `meta.requestId`. Quote it when you ask for help. * Retry only `429` and `503`, after `Retry-After`. Everything else fails the same way twice. | You see | It usually means | | ----------------------------------------------------------------- | ----------------------------------------------------------------------------- | | [`401 missing_key`](https://capacms.com/docs/errors/missing_key) | No `x-api-key` header. | | [`401 invalid_key`](https://capacms.com/docs/errors/invalid_key) | The key is unknown, deactivated or expired. Check it under Developers > Keys. | | [`403 scope_missing`](https://capacms.com/docs/errors/scope_missing) | The key cannot read this model. | | [`403 origin_refused`](https://capacms.com/docs/errors/origin_refused) | A browser key bound to other origins. | | [`404 model_not_found`](https://capacms.com/docs/errors/model_not_found) | No model with that namespace. The hint lists the ones you can read. | | [`402 subscription_required`](https://capacms.com/docs/errors/subscription_required) | The project has no active subscription. | Every code has its own page under [Errors](https://capacms.com/docs/errors). ## Related * [Entries reference](https://capacms.com/docs/api/entries) for every parameter and operator. * [GraphQL](https://capacms.com/docs/api/graphql) for the same reads as a typed schema. * [Caching](https://capacms.com/docs/concepts/caching) for what the CDN keeps and when it purges. # GraphQL clients Source: https://capacms.com/docs/guides/graphql-clients Use Apollo Client, urql or plain fetch with Capa's GraphQL endpoint, and keep reads cacheable with GET and persisted queries. Capa's GraphQL endpoint speaks standard GraphQL over HTTP. Any client works. This guide sets up Apollo Client and urql, and shows how to keep every read cacheable at the CDN. ## The endpoint | Request | Notes | | ------------------------------------------------------------- | -------------------------------------------------------------- | | `GET https://cdn.capacms.com/api/graphql?query=…&variables=…` | Cacheable at the CDN with a production key. | | `POST https://cdn.capacms.com/api/graphql` | JSON body `{ query, variables, operationName }`. Never cached. | Every request carries two headers: ```http x-api-key: cap_live_... Capa-Version: 2026-10-01 ``` The schema is built for the key: it holds exactly the models the key can read. A production key sees published entries, and a development key sees drafts. GraphQL is read-only, and one operation is sent per request. Batching is refused. Try it with `curl` first: ```bash curl -G https://cdn.capacms.com/api/graphql \ -H "x-api-key: $CAPA_KEY" \ -H 'Capa-Version: 2026-10-01' \ --data-urlencode 'query=query Latest($first: Int) { articles(first: $first, sort: [publishedAt_DESC]) { nodes { id title author { name } } } }' \ --data-urlencode 'variables={"first":3}' ``` Want to explore first? Open **Developers > GraphQL** in the admin. The Explorer runs queries with any of your keys and shows the cost and the matching REST request. See [GraphQL Explorer](https://capacms.com/docs/api/graphql-explorer). ## Use GET for anything a CDN should serve | Request | Cached at the CDN | | ------------------------------------------ | -------------------------------------------------- | | GET with a production key, no errors | Yes, and purged when an entry it read is published | | GET with a development key | No | | Any response with `errors` | No | | GET selecting `me`, `__schema` or `__type` | No | | POST | Never | Configure your client to send queries by GET. Keep a GET URL under 8,192 bytes. Longer documents belong in a POST, or better, in a persisted query (below). ## Apollo Client ```ts import { ApolloClient, HttpLink, InMemoryCache } from "@apollo/client"; import { relayStylePagination } from "@apollo/client/utilities"; export const client = new ApolloClient({ link: new HttpLink({ uri: "https://cdn.capacms.com/api/graphql", headers: { "x-api-key": process.env.CAPA_KEY!, "Capa-Version": "2026-10-01" }, useGETForQueries: true, }), cache: new InMemoryCache({ typePolicies: { Query: { fields: { articles: relayStylePagination(["filter", "sort"]) } }, }, }), }); ``` Capa's lists are Relay connections, so Apollo's `relayStylePagination` merges pages for you. Fetch the next page by passing `pageInfo.endCursor` as `after`: ```ts import { gql } from "@apollo/client"; const ARTICLES = gql` query Articles($first: Int, $after: String) { articles(first: $first, after: $after, sort: [publishedAt_DESC]) { edges { node { id title } } pageInfo { hasNextPage endCursor } } } `; const first = await client.query({ query: ARTICLES, variables: { first: 10 } }); ``` ## urql ```ts import { Client, cacheExchange, fetchExchange } from "@urql/core"; export const client = new Client({ url: "https://cdn.capacms.com/api/graphql", exchanges: [cacheExchange, fetchExchange], preferGetMethod: "within-url-limit", fetchOptions: () => ({ headers: { "x-api-key": process.env.CAPA_KEY!, "Capa-Version": "2026-10-01" }, }), }); ``` In React, pass the same client to urql's `Provider`. ## Persisted queries A persisted query sends the SHA-256 hash of the document instead of the document. The URL stays short, so every read can be a cacheable GET. Capa speaks the automatic persisted queries protocol that Apollo and urql use: 1. The client sends the hash alone, by GET. 2. If Capa has the document, it runs it. The CDN can serve the next identical GET. 3. If not, Capa answers `PersistedQueryNotFound`. The client sends the document with the hash, by POST, and Capa runs it. **A production key never stores a document.** It runs what it is sent, but it does not register it, because it ships in a public bundle. So register your documents at build time with a development key, or every call from your site is a miss followed by an uncached POST. ### With Apollo Client ```ts import { ApolloClient, HttpLink, InMemoryCache } from "@apollo/client"; import { createPersistedQueryLink } from "@apollo/client/link/persisted-queries"; import { sha256 } from "crypto-hash"; export const client = new ApolloClient({ cache: new InMemoryCache(), link: createPersistedQueryLink({ sha256, useGETForHashedQueries: true }).concat( new HttpLink({ uri: "https://cdn.capacms.com/api/graphql", headers: { "x-api-key": process.env.CAPA_KEY!, "Capa-Version": "2026-10-01" }, }), ), }); ``` ### With urql ```ts import { Client, cacheExchange, fetchExchange } from "@urql/core"; import { persistedExchange } from "@urql/exchange-persisted"; export const client = new Client({ url: "https://cdn.capacms.com/api/graphql", exchanges: [cacheExchange, persistedExchange({ preferGetForPersistedQueries: true }), fetchExchange], fetchOptions: () => ({ headers: { "x-api-key": process.env.CAPA_KEY!, "Capa-Version": "2026-10-01" }, }), }); ``` ### Register the hashes your client sends A client hashes the document it prints, not your source text. Apollo, for one, adds `__typename` to every selection first. So register by running each operation once through the same client setup, with a development key, as a build step. Registration is a POST with a development key. Send it to the host you read from: `cdn.capacms.com` passes every POST on to the host that stores documents. ```ts // register-queries.ts: run on every deploy, with a development key. import { ApolloClient, HttpLink, InMemoryCache } from "@apollo/client"; import { createPersistedQueryLink } from "@apollo/client/link/persisted-queries"; import { sha256 } from "crypto-hash"; import { ARTICLES, ARTICLE } from "./queries"; const register = new ApolloClient({ cache: new InMemoryCache(), // the same cache options as the site's client link: createPersistedQueryLink({ sha256, useGETForHashedQueries: true }).concat( new HttpLink({ uri: "https://cdn.capacms.com/api/graphql", headers: { "x-api-key": process.env.CAPA_DRAFT_KEY!, "Capa-Version": "2026-10-01" }, }), ), }); for (const query of [ARTICLES, ARTICLE]) { // A document that validates is stored even when its variables are refused, // so an operation with required variables registers with {} too. await register.query({ query, variables: {}, fetchPolicy: "network-only" }).catch(() => undefined); } ``` `CAPA_DRAFT_KEY` holds a development key, `cap_test_...`. Never ship it to a browser: it reads drafts. Check that it worked. A GET with the hash alone should answer `data`, not `PersistedQueryNotFound`. Using the Capa SDK instead? `capa persist` does all of this for you, and pins each build's documents so preview builds never push production's out. See [SDK CLI](https://capacms.com/docs/sdk/cli). ### The rules * The hash is the lowercase hex SHA-256 of the document exactly as sent. Any change, whitespace included, is a new hash. * A document is stored only if it parses, validates for the key that sends it, and fits in 32 KB. * Documents are stored per project, and every key of the project can run one by hash. A document is checked against the calling key's schema each time it runs. * A project keeps up to 2,000 documents and 16 MiB. Past that, the documents least likely to be needed are dropped first. See [persisted queries](https://capacms.com/docs/api/graphql#persisted-queries). ## Keys in the browser A key in a browser bundle is public. Use a production `cap_live_` key with the **Read only** starting point, and bind it to your site's origins under **Developers > Keys**. A request from any other origin is refused with [`403 origin_refused`](https://capacms.com/docs/errors/origin_refused). Never ship a `cap_test_` key. It reads drafts. ## Errors GraphQL errors arrive in `errors`, each with Capa's code, hint and docs link in `extensions`: ```json { "errors": [ { "message": "articles has no field \"subtitle\".", "extensions": { "type": "invalid_request", "code": "unknown_field", "hint": "…", "docs": "https://docs.capacms.com/errors/unknown_field" } } ], "data": null } ``` Branch on `extensions.code`. A root field that failed is `null`, and the other root fields still carry data. A request refused as a whole, such as a bad key or a query over the cost budget, has `errors` and no `data`. ## Related * [GraphQL reference](https://capacms.com/docs/api/graphql) for the schema, filters, sorts and [limits](https://capacms.com/docs/api/graphql#limits). * [TypeScript](https://capacms.com/docs/guides/typescript) for typed documents with `capa-codegen`. * [Caching](https://capacms.com/docs/concepts/caching) for what a publish purges. # Images Source: https://capacms.com/docs/guides/images Resize, crop, convert and blur any image in your media library by adding parameters to its URL. Every file in your media library has a public URL on the CDN. Add parameters to an image's URL and Capa returns it resized, cropped or converted. Each variant is cached at the edge for a year. ``` https://cdn.capacms.com/files/?width=800&format=webp ``` There is nothing to export and nothing to configure. ## Get the URL A media field comes back from the API with the file's `url`: ```json "cover": { "id": "…9b", "url": "https://cdn.capacms.com/files/winter-field-guide-cover_1759302000000_3f9c1a7b2e4d.jpg", "alt": "A lit tent below snowy peaks at dawn", "type": "image", "width": 3200, "height": 2133 } ``` `width` and `height` are the original's pixel size. `alt` is the field's alt text, or the file's own when the field has none. You can also copy a file's URL from the media library in the admin. See [Media](https://capacms.com/docs/editor/media). ## Parameters | Parameter | Values | Default | What it does | | -------------- | ------------------------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | `width` | 1 to 4096 | none | Target width in pixels, before `dpr`. | | `height` | 1 to 4096 | none | Target height in pixels, before `dpr`. | | `fit` | `inside`, `cover`, `contain` | `inside` | `inside` keeps the aspect ratio within the box. `cover` fills the box and crops. `contain` fills it and pads, transparent or white on JPEG. | | `dpr` | 1 to 3 | `1` | Multiplies `width` and `height`. `width=200&dpr=2` gives 400 pixels. | | `upscale` | `0`, `1` | `0` | An image is never enlarged past its source size unless you send `1`. | | `format` | `webp`, `avif`, `jpeg`, `png`, `auto` | the source format | The output format. `jpg` works as a spelling of `jpeg`. | | `quality` | 1 to 100 | `80` | Encoder quality for lossy formats. Ignored for PNG. | | `blur` | 1 to 256 | none | A tiny blurred placeholder: a square resize to that many pixels, then a blur. | | `keepMetadata` | `0`, `1` | `0` | On a transformed image, camera metadata and colour profiles are removed unless you send `1`. | Only `width`, `height`, `blur`, `format` and `quality` start a transform. `fit`, `dpr`, `upscale` and `keepMetadata` change how one runs, and do nothing on their own. A value out of range is a `400` with a JSON body that names the parameter. It is never ignored, and never a `500`. Parameters Capa does not know pass through untouched, so cache busters such as `?v=3` and campaign tags keep working. ## Common recipes A 400-pixel-wide version, aspect ratio kept: ``` /files/winter-field-guide-cover_1759302000000_3f9c1a7b2e4d.jpg?width=400 ``` A 400 by 300 crop for a card: ``` /files/winter-field-guide-cover_1759302000000_3f9c1a7b2e4d.jpg?fit=cover&height=300&width=400 ``` Sharp on high-density screens, 800 pixels in a 400-pixel box: ``` /files/winter-field-guide-cover_1759302000000_3f9c1a7b2e4d.jpg?dpr=2&width=400 ``` WebP at quality 60: ``` /files/winter-field-guide-cover_1759302000000_3f9c1a7b2e4d.jpg?format=webp&quality=60&width=800 ``` A 24-pixel blurred placeholder to show while the real image loads: ``` /files/winter-field-guide-cover_1759302000000_3f9c1a7b2e4d.jpg?blur=24 ``` ## Responsive images Let the browser pick a width: ```html A lit tent below snowy peaks at dawn ``` To offer AVIF with a fallback, use `` and one `` per format. ## Use it with next/image Point `next/image` at Capa with a custom loader. Next then requests the sizes it needs straight from the CDN. ```ts // capa-image-loader.ts export default function capaImageLoader({ src, width, quality, }: { src: string; width: number; quality?: number; }) { const url = new URL(src); url.searchParams.set("format", "webp"); if (quality && quality !== 80) url.searchParams.set("quality", String(quality)); url.searchParams.set("width", String(width)); return url.toString(); } ``` ```ts // next.config.ts import type { NextConfig } from "next"; const config: NextConfig = { images: { loader: "custom", loaderFile: "./capa-image-loader.ts" }, }; export default config; ``` ```tsx import Image from "next/image"; {cover.alt; ``` The loader writes its parameters in alphabetical order and leaves out the default quality. That is the spelling Capa caches, so no request is redirected (see below). ## Choose the format yourself `format=auto` picks AVIF or WebP from the browser's `Accept` header. Through the CDN today it answers in the source format, so name the format you want, or offer several with ``. PNG is lossless. Asking for it on a photograph makes the file far larger, so it is never chosen for you. ## One spelling per image `?width=200&format=webp` and `?format=webp&width=200` are the same image. Capa answers the canonical spelling and redirects every other one to it with a cacheable `301`, so each variant is one object at the edge. The canonical spelling sorts the parameters alphabetically, leaves out any parameter set to its default, and normalises values (`format=JPG` becomes `format=jpeg`). Write URLs that way and you skip the redirect. A URL that uses only `width`, `height` and `blur` is never redirected, however it is written. ## Rotation and metadata When a transform runs, the photo is first turned the right way up from its camera orientation, so `width=400` means 400 pixels of the picture as a person sees it. Camera metadata, location included, is then removed. Send `keepMetadata=1` to keep it and the colour profile. A URL with no transform returns the file exactly as it was uploaded, metadata included. If a photo may carry a location, serve it with at least a `width` or a `format`. ## SVG An SVG with no parameters is served as the SVG document itself, with anything that could run script removed first. Any size or format parameter renders it to a raster image instead, PNG unless you ask for another format. With only a format and no size, the long edge is 1024 pixels. An SVG with no `viewBox` and no absolute size cannot be measured, so a resize answers `400`. ## What is passed through untouched Some files come back as stored, with an `X-Capa-Transform` header that says why: | `X-Capa-Transform` | When | | --------------------- | ----------------------------------------------------- | | `skipped-size` | The source is larger than 25 MB. | | `skipped-unsupported` | An image type Capa cannot decode, such as BMP. | | `skipped-non-image` | Not an image: a PDF, a video, a document. | | `skipped-undecodable` | The file claims to be an image and could not be read. | | `sanitised-svg` | An SVG that had something removed. | An animated GIF or WebP passes through untouched with no parameters. With a resize or format, only the first frame is used. ## Limits and errors The longest edge of the output is capped at 4096 pixels, measured after `dpr`. `width=2000&dpr=3` is a `400`, not a smaller image. | Status | When | | ------ | ---------------------------------------------------------------- | | `400` | A parameter value is out of range. The body names the parameter. | | `404` | No such file. Never cached. | | `503` | The transform took too long. Retry after `Retry-After`. | ## Caching Each variant is cached for a year, at the edge and in the browser. The `ETag` changes when the file or the parameters change, and only then. Replacing a file in the media library keeps its URL, but it does not purge the edge. The edge and any browser that already holds the old copy can keep showing it for up to a year. When a visitor must see the new image, upload it as a new file instead, or add a cache buster such as `?v=2`. Deleting a file removes every variant from the edge. ## Related * [Media](https://capacms.com/docs/editor/media) for uploading files and writing alt text. * [Caching](https://capacms.com/docs/concepts/caching) for how purges work. # Next.js Source: https://capacms.com/docs/guides/nextjs Keep a Next.js site fresh after every publish, tag reads by model, and show drafts to editors. The [Next.js quickstart](https://capacms.com/docs/get-started/nextjs) gets a site reading Capa. This guide covers what a production site needs next: how fresh each page is, refreshing on publish, and a draft view for editors. The examples use Next.js 16 and the App Router, with `@capacms/sdk`. ## Two caches Every published read passes through two caches: 1. **Capa's CDN.** Published reads are cached at the edge, and Capa purges them when an entry changes. You do not configure it. 2. **Next's data cache.** Next keeps what a page fetched, for as long as you tell it to. The CDN is always current within seconds of a publish. How fresh your site is depends on the second cache, so choose how Next keeps each read. | You want | Do this | | --------------------------------------------- | --------------------------------------------------------- | | Fresh within a minute, nothing else to set up | `revalidate: 60` on the client | | Fresh the moment an editor publishes | Tag reads by model, and revalidate the tag from a webhook | | Every request reads the API | Read at request time with `connection()` | ## Fresh within a minute Give the client a fetch that Next keeps for a fixed time: ```ts // lib/capa.ts import { createClient } from "@capacms/sdk/next"; import { withCache } from "@capacms/sdk/nextjs"; export const capa = createClient({ baseUrl: process.env.CAPA_API_URL!, apiKey: process.env.CAPA_KEY!, version: "2026-10-01", fetch: withCache(fetch, { revalidate: 60 }), }); ``` `withCache` adds Next's `next: { revalidate }` option to every request the client sends. A page built from these reads is regenerated at most once a minute. ## Fresh on publish Tag each read with the models it shows. When an entry of a model changes, revalidate that tag. ### Tag reads by model `tagsFor({ namespace })` builds one Next tag per model, plus a tag for media. Build a client for the models a page reads: ```ts // lib/capa.ts import { createClient } from "@capacms/sdk/next"; import { tagsFor, withCache } from "@capacms/sdk/nextjs"; /** A client whose reads Next keeps until one of these models changes. */ export function capaFor(...namespaces: string[]) { return createClient({ baseUrl: process.env.CAPA_API_URL!, apiKey: process.env.CAPA_KEY!, version: "2026-10-01", fetch: withCache(fetch, { tags: tagsFor({ namespace: namespaces }), revalidate: 3600, // a safety net if a webhook is ever missed }), }); } ``` ```tsx // app/page.tsx import { capaFor } from "@/lib/capa"; export default async function Home() { // The list expands each article's author, so it reads both models. const capa = capaFor("article", "author"); const articles = await capa.entries.list("article", { select: ["title", "slug", { author: ["name"] }], sort: ["-publishedAt"], }); return (
      {articles.data.map((article) => (
    • {String(article.fields.title)}
    • ))}
    ); } ``` Name every model a read touches, including the ones it expands. A page that shows an author's name should refresh when the author changes too. ### Revalidate from a webhook Add a route that Capa calls on every publish, unpublish and delete: ```ts // app/api/capa/route.ts import { revalidateTag } from "next/cache"; import { verifyWebhookSignature } from "@capacms/sdk"; import { revalidateFromWebhook } from "@capacms/sdk/nextjs"; export async function POST(request: Request) { const raw = await request.text(); const ok = await verifyWebhookSignature({ payload: raw, header: request.headers.get("capa-signature") ?? "", secret: process.env.CAPA_WEBHOOK_SECRET!, }); if (!ok) return new Response("bad signature", { status: 400 }); await revalidateFromWebhook({ payload: JSON.parse(raw), revalidateTag: (tag) => revalidateTag(tag, { expire: 0 }), }); return new Response("ok"); } ``` `revalidateFromWebhook` reads the model and entry the event names and revalidates every tag that covers them. On Next.js 15, pass `revalidateTag` itself: it takes one argument there. Then register the route in **Developers > Webhooks**: choose **Add endpoint**, enter `https:///api/capa`, and keep the default events. Copy the signing secret into `CAPA_WEBHOOK_SECRET`. See [Webhooks](https://capacms.com/docs/webhooks). ## Every request reads the API Some pages must never be cached by Next: search results, or a page that reads the request's cookies. Read at request time: ```tsx // app/search/page.tsx import { connection } from "next/server"; import { createClient } from "@capacms/sdk/next"; const capa = createClient({ baseUrl: process.env.CAPA_API_URL!, apiKey: process.env.CAPA_KEY!, version: "2026-10-01", }); export default async function Search({ searchParams }: { searchParams: Promise<{ q?: string }> }) { await connection(); // render on every request, never at build time const { q = "" } = await searchParams; const results = await capa.entries.list("article", { select: ["title"], filter: q ? { title: { contains: q } } : undefined, limit: 10, }); return

    {results.data.length} results

    ; } ``` A plain client with no `withCache` is not kept by Next, but a page that uses no request data can still be rendered once at build time. `await connection()` stops that. The read still comes from Capa's CDN, so it stays fast. ## Show drafts to editors Editors want to see a draft on the real site before they publish. Next's draft mode does this: a cookie switches the page to a client that reads drafts. You need a **draft key**. In **Developers > Keys**, create a key with **Environment** set to **Draft** and **Read only** grants. It starts with `cap_test_`. Never send it to the browser: it reads every unpublished entry. ```bash # .env.local CAPA_DRAFT_KEY=cap_test_... DRAFT_SECRET=a-long-random-string ``` ### Pick the client per request `draftClient` returns the draft client under draft mode and the published one otherwise: ```ts // lib/capa.ts import { draftMode } from "next/headers"; import { draftClient, tagsFor, withCache } from "@capacms/sdk/nextjs"; export function capaFor(...namespaces: string[]) { return draftClient({ production: { baseUrl: process.env.CAPA_API_URL!, apiKey: process.env.CAPA_KEY!, version: "2026-10-01", fetch: withCache(fetch, { tags: tagsFor({ namespace: namespaces }), revalidate: 3600 }), }, draft: { baseUrl: process.env.CAPA_API_URL!, apiKey: process.env.CAPA_DRAFT_KEY!, version: "2026-10-01", }, isDraft: async () => (await draftMode()).isEnabled, }); } ``` The draft client has no `withCache`, so drafts are never kept. Capa's CDN never caches a draft key's reads either. Pages now call `await capaFor("article")`. ### Turn draft mode on and off ```ts // app/api/draft/route.ts import { draftMode } from "next/headers"; import { redirect } from "next/navigation"; export async function GET(request: Request) { const url = new URL(request.url); if (url.searchParams.get("secret") !== process.env.DRAFT_SECRET) { return new Response("Invalid secret", { status: 401 }); } const path = url.searchParams.get("path") ?? "/"; // Only paths on this site, so the route cannot send anyone elsewhere. if (!path.startsWith("/") || path.startsWith("//")) { return new Response("Invalid path", { status: 400 }); } (await draftMode()).enable(); redirect(path); } ``` ```ts // app/api/draft/off/route.ts import { draftMode } from "next/headers"; import { redirect } from "next/navigation"; export async function GET() { (await draftMode()).disable(); redirect("/"); } ``` An editor opens `/api/draft?secret=…&path=/articles/winter-field-guide-2026` to see that page with drafts, and `/api/draft/off` to leave. Keep `DRAFT_SECRET` among your editors. Anyone who has it sees your drafts. ## Errors A read that fails throws `CapaError`, with the API's `status`, `code`, `hint` and `requestId`. `entries.get` returns `null` for a missing entry instead, so `notFound()` covers it. ```ts import { CapaError } from "@capacms/sdk/next"; try { await capa.entries.list("article", { limit: 500 }); } catch (error) { if (error instanceof CapaError) console.error(error.code, error.hint, error.requestId); throw error; } ``` Every code has a page under [Errors](https://capacms.com/docs/errors). ## Related * [Caching](https://capacms.com/docs/concepts/caching) for what a publish purges at the CDN. * [Drafts and publishing](https://capacms.com/docs/concepts/drafts-and-publishing) for what each key sees. * [SDK reference](https://capacms.com/docs/sdk) for `graphql()` in a server component, which takes `tags` per call. # SEO and AI exports Source: https://capacms.com/docs/guides/seo Get schema.org JSON-LD, a Markdown copy of an entry, and a plain-text bundle of your content for search and retrieval. Capa can turn your entries into three formats that search engines and AI tools read well: | Export | Route | Use it for | | --------- | --------------------------------------------------------- | ----------------------------------------------------------------------------- | | JSON-LD | `/v2/seo/{model}/{id}/json-ld`, `/v2/seo/{model}/json-ld` | Structured data in a page's ``. | | Markdown | `/v2/seo/{model}/{id}/markdown` | A Markdown copy of a page, such as `/blog/my-post.md` or an `llms.txt` entry. | | AI bundle | `/v2/seo/{model}/ai-bundle`, `/v2/seo/ai-bundle` | Plain text and metadata, ready for search indexing or embeddings. | These routes are part of the legacy `/v2` surface. They take the legacy key your site already uses (`pk_`, `sk_`, or an older key with no prefix). A `cap_` key is refused with `401`. None of them call an AI model. They reshape your content and nothing else. ## Drafts The key decides what you get, as on every read: * A production key (`pk_`) gets published content only. No parameter changes that. * A development key (`sk_`) gets drafts by default. Add `?published=true` to see exactly what is live. ## JSON-LD for one entry ```bash curl -G https://cdn.capacms.com/v2/seo/article//json-ld \ -H "x-api-key: $CAPA_KEY" \ --data-urlencode 'baseUrl=https://northpeak.example' ``` ```json { "@context": "https://schema.org", "@type": "Article", "@id": "https://northpeak.example/api/article/", "identifier": "winter-field-guide-2026", "headline": "Winter Field Guide 2026: Layering Above Treeline", "description": "How to stay warm and dry above treeline this winter.", "articleBody": "…", "dateCreated": "2026-09-30T14:02:11.000Z", "dateModified": "2026-10-01T09:15:40.000Z", "datePublished": "2026-10-01T09:15:40.000Z", "name": "Winter Field Guide 2026: Layering Above Treeline", "url": "https://northpeak.example/article/winter-field-guide-2026" } ``` The response is `application/ld+json`. Put it in your page inside `