Docs

GraphQL

The same reads as a typed GraphQL schema: names, filters, pagination, limits, cost and persisted queries.

View as Markdown

Every model your key can read, as a typed GraphQL schema, at one endpoint.

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 } } }"}'
{ "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": {
  "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:

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:

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<Latest>(
  `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).

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), and any status other than 200, such as 401, 402, 403, 404, 405 or 429. The SDK README covers the rest:

ToUseSDK README
read in a Next.js server component, cached and revalidated by taggraphql() from @capacms/sdk/nextjsIn a Next.js server component
infer result and variable types from the document, with no castscapa-codegen --graphqlTyped documents
write the query as an object, typed from what it selectscapa.graphql.query({ ... })The typed builder
send a hash instead of the document, cached at the CDNcapa persist at build time, then { persisted: true }Persisted queries

Any GraphQL client works too. The rest of this page is the HTTP contract every client speaks.

Endpoints

RequestNotes
POST https://cdn.capacms.com/api/graphqlContent-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.

Both take the same headers as every keyed /api/ route: x-api-key, Capa-Version and Capa-Contract (see API reference). 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:

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:

"""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:

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) is typed ID as well, and keeps its label and the , model <namespace> 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 fieldGraphQL typeFilter input
string, markdown, html, code, enum, colorStringStringFilter (eq ne in nin contains startsWith endsWith exists null)
numberFloatFloatFilter (eq ne in nin lt lte gt gte exists null)
true_falseBooleanBooleanFilter (eq ne exists null)
dateDateTime (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 readsthat model's type<T>RelationFilter: eq ne in nin exists null, plus one hop into the target's fields
array of relation to a model the key reads<T>RelationConnection!<T>RelationListFilter: has hasAny hasAll exists null, plus one hop
relation to a model the key cannot readID or [ID]RelationIDFilter / RelationIDListFilter, id operators only
image, video, fileMedia (id url alt type width height), or [Media]MediaFilter (exists null id); a list of media takes PresenceFilter (exists null)
mixedJSONJSONFilter (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.

RuleExamples
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_<namespace>. 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 <list>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).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).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:

WhereFormat
A model type<modelName> (model <namespace>), the namespace standing in for an empty model name. A second line Not exposed: <ns>, ... lists fields the type leaves out
A customer fieldThe 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 <namespace>, type <type>[ of <arrayType>][, model <target>] 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 rootEntries of the <modelName> model. Same as GET /api/entries/<namespace>.
A single rootOne entry of the <modelName> model by id, or null. Same as GET /api/entries/<namespace>/{id}., and on a model that holds one entry, then Without id, the model's one entry: GET /api/entries/<namespace>?limit=1.
Queryends with Models not exposed in GraphQL: <ns>, ... 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). A customer field with one of those names is exposed as <name>_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), 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:

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

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 }
  }
}
{ "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).
  • Sorts are enum values <field>_ASC and <field>_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 <relation>__<field> 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:

query Page($after: String) {
  articles(first: 2, after: $after) {
    nodes { id title }
    pageInfo { hasNextPage startCursor endCursor }
  }
}

The first request sends no variables:

{ "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:

{ "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:

query PageBack($before: String!) {
  articles(last: 2, before: $before) {
    nodes { id title }
    pageInfo { hasPreviousPage startCursor }
  }
}

From the second page, send its startCursor:

{ "before": "c1.eyJ2IjoiMjAyNi0xMC0wMSIs…c67971c76cda041f" }

and the answer is the first page again, with nothing before it:

{ "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).
  • 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 <T>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 <T>RelationSort: the related entries' own fields and system fields, without the author__name_ASC values of <T>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, 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:

{ article(id: "00000000-0000-4000-8000-000000000023") { title coauthors(first: 1, sort: name_ASC) { nodes { name } } } }

Or by id alone, whatever its model:

{ 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:

{ node(id: "00000000-0000-4000-8000-000000000023") { id ... on Articles { title } } }

nodes(ids:) reads several entries in one root field, whatever their models:

query Refetch($ids: [ID!]!) {
  nodes(ids: $ids) { __typename id ... on Articles { title } ... on Authors { name } }
}
{ "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:

{ 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

{ 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.

LimitValue
Document size (UTF-8 of query)32,768 bytes
Variables (JSON)32,768 bytes
POST body65,536 bytes
GET URL (path and query)8,192 bytes
URL and headersNode'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 nesting128 levels of {, [ and (, checked before the document is parsed
Filter nesting8 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
Depth8 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 depth20 levels inside __schema and __type (the standard introspection query fits)
Entry root fields per operation10. 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 computed10 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 operation25. Every list root and every selected array relation, aliases counted
Entries per operation5,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), and the rows actually read after
Text conditions in one or3 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 fields10,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
Fragments100 defined in the document, and 50 spread side by side in one selection set
Selected fields1,000 after fragments are expanded, and 1,000 written in the whole document, every operation and fragment counted, since every one is validated
Entry depth5 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 expansions12 per root field, as on REST
first1 to 200
sortup to 3 on a root, 1 on a nested connection
Database time5 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 once4 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 becauseMessageHint
nested too deepThis 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 deepThis 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 fieldsThis 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 copiedThis 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 entriesThis 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 entriesA 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 sortsThis 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 manyarticles: could read 40,200 entries, over the limit of 5,000.the same
relations nested too deeparticles: 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 valuesarticles: 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 deeparticles: 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 searchesarticles: 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 deepVariable "$f": filter nests 12 levels deep, over the limit of 8.the same
one field repeated too oftenThis 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 fragmentsThis 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:

{ "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). 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

{ "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). 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:

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 AcceptA request error answersSent as
none, */*, or application/json ranked first200, with errors and no dataapplication/json
application/graphql-response+json ranked at least as high as application/json, as Apollo Client 4, urql and graphql-http send itits 4xx from the table belowapplication/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=<id>; env=<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.

Codeapplication/jsongraphql-response+jsonWhen
missing_key, invalid_key401401no key, or a key Capa does not know
subscription_required402402the project's plan does not allow reads
origin_refused, edge_only403403the key's origin rules or the edge lock refused the request
invalid_version400400Capa-Version names no version
contract_not_found404404Capa-Contract other than 1
invalid_parameter400400the 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=<id>; env=<env>
invalid_parameter200400the 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_failed200400a syntax error, with locations and a hint that names them: Fix the syntax at line 3, column 1.
graphql_validation_failed200400an 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_complex200400, or 200 on a fieldany limit above; after SQL, more than 5,000 rows read
unknown_field, invalid_filter_value, invalid_operator, invalid_cursor, invalid_select200400REST 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)
count_unavailable200200totalCount could not be taken (a hop filter matching over 50,000 entries); only totalCount is null
query_timeout200200a root field hit the statement timeout; it and every later root field are null
persisted_query_not_found200200the 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_mismatch400400the sha256 of query is not the hash you sent
mutations_not_enabled405405a mutation operation anywhere in the document, whichever operation operationName picks. The Allow header names the methods the route answers, GET, POST
rate_limit_exceeded429429your 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_found404404GraphQL 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_unavailable503503the 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
internal500, or 200 on a field500, or 200 on a fieldour 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:

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/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:

RequestCache-ControlSurrogate keysETag
any POSTno-storenono
GET, production key, no errorspublic, max-age=60yesyes, a 304 on If-None-Match
GET, development keyno-store, no-cachenono
GET with any error, or selecting me, __schema or __typeno-storenono

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:<id> 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:<id> 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:<tenantId> hard.
  • Editing a file's alt text purges f:<fileId>, 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:<keyId>, 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:

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 example. When the document is registered (see 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:

{ "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:

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:

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:

    // 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=<id>; 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=<id>; env=<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:

"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.

curl https://cdn.capacms.com/api/graphql \
  -H "x-api-key: $CAPA_KEY" \
  -H 'content-type: application/json' \
  -d '{"query":"{ version }","extensions":{"capa":true}}'
KeyWhenWhat
costexecuted responses, and documents refused for costing more than 5,000 entries; no other refusalrequestedQueryCost (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)
deprecationsanswers to a document that validated and uses a deprecated memberone { coordinate, reason } per member (see Versions and deprecation)
missingReferencesanswers that left a reference out, for every keyone { 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
persistedQueryrequests that sent a hash{ registered }, and pinned: true when the document is pinned
capa.requestId, capa.version, capa.contractcapa answers, except requestId on one a shared cache may keepquote the requestId to support
capa.environmentcapa answers that executedproduction or development
capa.costcapa answers that executeddepth, 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.restcapa answers that executedone { 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):

{ "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). Send the date your code was written against, and version answers the one that ran:

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 }"}'
{ "data": { "version": "2026-10-01" },
  "extensions": { "cost": {
    "requestedQueryCost": 0, "actualQueryCost": 0, "budget": { "counted": 0, "limit": 5000 } } } }

Every executed answer carries extensions (see 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-VersionWhat you see
before the date that deprecates itthe member, unchanged
from that date onthe member, marked @deprecated(reason: "Read folder instead.") with its replacement beside it
from a later date that removes itno 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 names each date. Introspection shows each deprecation and its reason; ask for deprecated arguments and input fields with includeDeprecated: true:

{ __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:

{ "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:

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 } } }"}'
{ "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:

    {
      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.