# 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<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](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 <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 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                                      | `<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 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_<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](#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     | `<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 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 <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 root      | `Entries of the <modelName> model. Same as GET /api/entries/<namespace>.`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| A single root    | `One 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.`                                                                                                                                                                                                                                                                                                                                                                                                 |
| `Query`          | ends 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](#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](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 `<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`:

```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 `<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`](#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=<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.

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

```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=<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:

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