# Entries

Source: https://capacms.com/docs/api/entries

The whole read contract: the entry shape, select, filters, sorting, cursor paging, caching and every error.

`GET /api/entries/{namespace}` and `GET /api/entries/{namespace}/{id}` read your
content. This page is the whole read contract: the shape of an entry, the
grammar for choosing fields and expanding relations, filters, sorting, cursor
paging, caching, and every error you can get back with what to do about it.

Start with [API reference](https://capacms.com/docs/api) for how a `/api/` request is shaped and
[Keys and scopes](https://capacms.com/docs/api/authentication) for how to mint a key.

| Route                        | What it answers                 |
| ---------------------------- | ------------------------------- |
| `GET /api/entries/{ns}`      | a page of entries, newest first |
| `GET /api/entries/{ns}/{id}` | one entry                       |

`{ns}` is the model's namespace, matched exactly. It is not lowercased, and a
namespace that does not exist on your project answers `404 model_not_found`
with the readable namespaces listed in `hint`.

```bash
export CAPA_KEY=...          # from your secret store, never in a script
curl 'https://cdn.capacms.com/api/entries/articles?select=title,views&limit=2&sort=-views' \
  -H "x-api-key: $CAPA_KEY"
```

The routes read `select`, `filter[...]`, `where`, `sort`, `limit`, `count`,
`after`, `before` and `shape`, each described below. Any other parameter is
`400 invalid_parameter`, so a URL ported half way from `/v2/api` is refused
rather than answered with the wrong entries. The hint gives the `/api/` form:
`?slug=/` is refused with `?slug=/ is legacy equality: send filter[slug]=/.`
A name that starts with `_` or `utm_`, such as a cache buster (`?_=1727400000000`)
or a campaign tag copied from a page's own URL, is ignored.

## Telling Capa which page you are rendering

Both routes accept an optional `Capa-Page` header naming the page of **your**
site the read is for:

```bash
curl 'https://cdn.capacms.com/api/entries/articles?limit=3' \
  -H "x-api-key: $CAPA_KEY" \
  -H 'Capa-Page: /blog/[slug]'
```

Send either the route pattern (`/blog/[slug]`) or the concrete path you are
rendering (`/blog/hello`). It must start with `/` and be at most 200 characters.
What you get back is [Pages](https://capacms.com/docs/preview/pages): which of your pages read which
entries, and therefore what breaks if you unpublish one.

Three rules, and each is a promise to your site:

* **It is ignored when it is invalid, never a 400.** A proxy that mangles the
  header, or a typo in a template, must not take your blog down. A value Capa
  cannot store is dropped and the request is served exactly as if the header had
  never been sent.
* **It never varies the response.** It is not in `Vary`, it is not echoed, and
  the body, the `ETag` and the `Surrogate-Key` are byte for byte what they would
  have been without it. Varying on it would multiply every cached object by the
  number of pages that read it, which is the opposite of what sending it is for.
* **It is recorded, not acted on.** Nothing about what you are served changes.

Repeating the header with one value is fine, because a proxy that appends a
value to a request that already carried it asked for one page and said it twice.
Two DIFFERENT values are dropped: Capa does not know which page the read was
for, so it records none.

`@capacms/sdk/next` sends it for you from the `page` option, and
`routeOf(import.meta.url)` in `@capacms/sdk/nextjs` builds the string from a Next
route file.

## Telling Capa which schema you built against

Both routes also accept an optional `Capa-Schema` header, the checksum of the
schema your code was generated from:

```bash
curl 'https://cdn.capacms.com/api/entries/articles?limit=3' \
  -H "x-api-key: $CAPA_KEY" \
  -H 'Capa-Page: /blog/[slug]' \
  -H 'Capa-Schema: 7199153b8f2bd4cf'
```

The value is the `checksum` `GET /v2/schema` returns, which `capa-codegen`
writes into your generated types as `CAPA_SCHEMA_CHECKSUM`. It must be 8 to 64
lower-case hex characters.

The same three rules apply, word for word: **ignored when it is invalid, never a
400**; **it never varies the response**; **it is recorded, not acted on**. A
repeated header with one value collapses, and two different values are dropped,
for the same reasons as above.

It is recorded only on a request that also carries `Capa-Page`, because the
stamp is stored on the page read row. What it buys is the `drift` suggestion in
[Pages](https://capacms.com/docs/preview/pages): a site that has never been rebuilt keeps issuing perfectly
valid requests, so without this header a stale build is invisible.

`@capacms/sdk/next` sends it for you from the `schemaChecksum` option.

## Telling Capa which URL you are rendering

A read that carries `Capa-Page: /blog/[slug]` may also carry `Capa-Path`, the
concrete path being rendered, such as `/blog/hello`. It is what lets Capa list
the real URLs an entry appears on, not only the route patterns. It starts with
`/`, holds no whitespace, `?` or `#`, and is at most 1,024 characters. The
same three rules apply: an invalid value is ignored, it never varies the
response, and it is recorded, not acted on. Without `Capa-Page` it is not
recorded at all.

`@capacms/sdk/next` sends it for you from the `path` option.

## What an entry looks like

```json
{
  "id": "00000000-0000-4000-8000-000000000021",
  "model": "articles",
  "status": "published",
  "createdAt": "2026-07-25T06:03:52.112Z",
  "updatedAt": "2026-07-25T06:03:52.112Z",
  "publishedAt": "2026-07-25T06:03:52.112Z",
  "version": 1,
  "folder": null,
  "tags": [],
  "fields": {
    "title": "Alpha ships today",
    "body": "Body of \"Alpha ships today\".",
    "views": 5,
    "featured": true,
    "tags": ["news"],
    "author": { "id": "00000000-0000-4000-8000-000000000011", "model": "authors" },
    "coauthors": { "items": [ { "id": "00000000-0000-4000-8000-000000000012", "model": "authors" } ],
                   "pageInfo": { "hasNext": false, "next": null } }
  }
}
```

The keys are always in that order. The first nine are the system keys, and
everything you defined on the model lives under `fields`, so a field you called
`id`, `status` or `tags` never collides with a system key.

| Key           | Type                            | Meaning                                                                                                                                                                        |
| ------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `id`          | uuid                            | the entry                                                                                                                                                                      |
| `model`       | string                          | the namespace, so a mixed list stays readable                                                                                                                                  |
| `status`      | `published`, `draft`, `changed` | see [Drafts](#drafts-and-environments)                                                                                                                                         |
| `createdAt`   | ISO 8601                        | when the entry was created                                                                                                                                                     |
| `updatedAt`   | ISO 8601                        | when the entry was last saved. A production key reads when the version it is served was saved, so an unpublished draft never moves it (see [Drafts](#drafts-and-environments)) |
| `publishedAt` | ISO 8601 or `null`              | when the entry was last published, `null` if it never was                                                                                                                      |
| `version`     | integer                         | the version number of the row you are reading                                                                                                                                  |
| `folder`      | uuid or `null`                  | the entry's folder                                                                                                                                                             |
| `tags`        | array of strings                | the entry's own tags, not a field. A production key reads the tags the entry had when it was last published (see [Drafts](#drafts-and-environments))                           |
| `fields`      | object                          | your fields, in the order you laid them out on the model                                                                                                                       |

Inside `fields`:

| Field type                                  | Renders as                                                                                                                                                                                                                                                                                                                                                                                               |
| ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| string, markdown, html, code, enum, color   | the stored string                                                                                                                                                                                                                                                                                                                                                                                        |
| number                                      | a JSON number                                                                                                                                                                                                                                                                                                                                                                                            |
| true_false                                  | `true` or `false`                                                                                                                                                                                                                                                                                                                                                                                        |
| date                                        | an ISO 8601 string                                                                                                                                                                                                                                                                                                                                                                                       |
| array                                       | a JSON array                                                                                                                                                                                                                                                                                                                                                                                             |
| array of image, video or file               | a JSON array of `{ "id", "url", "alt", "type", "width", "height" }`, one per file                                                                                                                                                                                                                                                                                                                        |
| relation (single)                           | `{ "id": "...", "model": "authors" }`, or the whole entry when expanded                                                                                                                                                                                                                                                                                                                                  |
| relation (array)                            | `{ "items": [...], "pageInfo": { "hasNext": false, "next": null } }`                                                                                                                                                                                                                                                                                                                                     |
| relation into a model this key may not read | `{ "id": "...", "model": null }`, never expanded, see [Per-model keys](#per-model-keys)                                                                                                                                                                                                                                                                                                                  |
| image, video, file                          | `{ "id", "url", "alt", "type", "width", "height" }`. `type` is the kind of file the upload recorded: `image`, `video`, `audio`, `document`, `pdf`, `file` or `unknown`. Any other stored value is printed as it is, where GraphQL reads `null`. `width` and `height` are the pixel size the upload measured, `null` for a file it did not measure. An empty `alt` is filled from the file's own alt text |

A field you never filled is `null`. A relation pointing at an entry that was
deleted, or that a production key cannot see, renders as
`{ "id": "...", "model": "authors", "missing": true }` rather than vanishing, so
a broken link in your content is visible instead of silent.

A list wraps entries in `data` and adds `page`:

```json
{ "data": [ /* entries */ ],
  "page": { "limit": 2, "hasNext": true, "next": "c1.eyJ2…", "hasPrev": false, "prev": null },
  "meta": { "version": "2026-10-01", "contract": 1, "environment": "production",
            "requestId": "req_0f3c…" } }
```

A single entry is `{ "data": { /* entry */ }, "meta": { … } }` with no `page`.

## Choosing fields: `select`

With no `select` you get every system key and every field, with relations as
references. That is the right default for a first call and the wrong one for a
page that needs three fields out of forty.

```
select   := item ("," item)*
item     := name | "*" | name "(" arg ("," arg)* ")"
arg      := item | "*" | "limit:" 1..200 | "sort:" ["-"] name | "after:" cursor
name     := bare | quoted | "$" system-key
bare     := a field namespace or a system key with none of , ( ) : . " * [ ]
            or whitespace in it, and not starting with $ or -
quoted   := '"' the field namespace, with each " inside doubled '"'
```

Whitespace is not allowed outside a quoted name. `,` separates items, `(` and
`)` wrap the arguments of a relation, and `:` separates a modifier from its
value. `limit:`, `sort:` and `after:` belong inside a relation's parentheses;
at the root they are `400 invalid_select`, and the list takes the `limit`,
`sort` and `after` parameters instead.

**Any field name.** A field's name is whatever was saved in the admin, so it
can hold characters the grammar uses. Write such a name in double quotes, and
double a quote inside it. The same form works in `select`, `sort`, `where` and
`filter[...]`, and a quoted name always means your field, never a system key or
a relation hop.

| Field                                     | You write                                                                     |
| ----------------------------------------- | ----------------------------------------------------------------------------- |
| `am/pm_indicator`, `open-time`, `émoji_ñ` | as it is: `select=am/pm_indicator`                                            |
| `price.usd`                               | `select="price.usd"`, `sort=-"price.usd"`, `where={"\"price.usd\"":{"gt":3}}` |
| `a,b`, `paren(x)`, `colon:x`, `has space` | `select="a,b","paren(x)","colon:x","has space"`                               |
| `say "hi"`                                | `select="say ""hi"""`                                                         |
| `at.place` (a relation)                   | `select="at.place"(name)`, `where={"\"at.place\".name":{"eq":"Harbour"}}`     |

In a URL the quote is `%22` and a space `%20`: most HTTP clients encode them for
you. A field whose name starts with `$` cannot be named, because `$` starts a
system key; `select=*` and a request with no `select` return it.

| You write                                   | You get                                                                                      |
| ------------------------------------------- | -------------------------------------------------------------------------------------------- |
| (nothing)                                   | every system key, every field, relations as references                                       |
| `select=title,views`                        | `id`, `model`, `status` and those two fields                                                 |
| `select=*`                                  | every system key and every field, as with no `select`                                        |
| `select=title,publishedAt,version`          | `id`, `model`, `status`, the two system keys you named, and `title`                          |
| `select=tags,$tags`                         | on a model with its own `tags` field: your field under `fields`, and the entry's tags on top |
| `select=title,author`                       | `author` as `{ id, model }`                                                                  |
| `select=title,author(name,bio)`             | `author` expanded to a nested entry with two fields                                          |
| `select=title,author(*)`                    | `author` expanded with every field                                                           |
| `select=coauthors(name,limit:5,sort:name)`  | the first five coauthors by name                                                             |
| `select=title,author(name),coauthors(name)` | both relations expanded in one read, each with its `name`                                    |

Expansions nest, up to 5 levels of entries: on a model of your own whose
authors have a `books` relation, `select=author(name,books(title,limit:3))`
reads each entry's author with three of that author's books. The sample
project's authors have no relations, so every row above reads one level.

`id`, `model` and `status` come back whether or not you name them, on the root
entry and on every expansion. Other system keys come back only when you name
them, or select `*`, which returns every system key at that level. You can name
them at any level: `select=title,author(name,publishedAt)`
puts `publishedAt` on the expanded author. Wherever they appear they are
printed in the fixed key order of [an entry](#what-an-entry-looks-like),
not in the order you wrote them. The hint on an `unknown_field` lists the
system keys only at the root, because an expansion's hint lists that model's
fields and nothing else.

**System keys and your fields.** A plain name means your field when the model
has a field of that name, and the system key otherwise. The two never collide
in the response, because your fields live under `fields` and the system keys
do not, but they share one namespace in the query grammar. So a system key
also has a name no field can take: `$` and the key, as `$tags`, `$createdAt`
or `$id`. It works wherever the plain name does, in `select`, `filter`,
`where` and `sort`, and after a hop as `author.$id`.

On a model with its own `tags` and `createdAt` fields:

| You write                        | It reads                           |
| -------------------------------- | ---------------------------------- |
| `select=tags`                    | your `tags` field                  |
| `select=$tags`                   | the entry's own tags               |
| `sort=-createdAt`                | your `createdAt` field             |
| `sort=-$createdAt`               | when the entry was created         |
| `where={"$tags":{"has":"news"}}` | entries tagged `news` in the admin |

On a model without such a field the two names are the same key, and the plain
one is shorter. A `$` name that is not a system key is `400 unknown_field`.

An expanded single relation is the nested entry itself. An expanded array
relation is a slice:

```json
"coauthors": {
  "items": [ { "id": "…0011", "model": "authors", "status": "published",
               "fields": { "name": "Ada Vale" } } ],
  "pageInfo": { "limit": 1, "hasNext": true, "next": "c1.eyJ2…" } }
```

`pageInfo.next` is a cursor token. Pass it back inside the parentheses as
`after:` to get the next slice of that one relation on that one entry:

```
?select=coauthors(name,limit:1,after:c1.eyJ2…)
```

The cursor carries the parent entry's id, so replaying it on a different entry
answers `400 invalid_cursor`.

A relation page is a window of the ids the entry stores. `limit:3` covers three
stored ids, and an id whose entry was deleted, or that a production key cannot
see, keeps its place in the window as `{ "id", "model", "missing": true }`. So
`items` always holds one item per stored id in the window, and `pageInfo.next`
always moves past it. An id stored twice appears twice, and a cursor minted on
the second one resumes after the second one.

Entries change between pages, and a relation cursor holds its place:

* With `sort:`, the next page starts after the sort values the cursor carries,
  wherever its own entry sorts now. Renaming that entry between pages skips
  nothing and repeats nothing: a rename that moves it later brings it back on
  a later page.
* Without `sort:`, the next page starts after the cursor's id in the list the
  entry stores now. If that id has been taken out of the list, the cursor is
  refused with `400 invalid_cursor` and the message `The entry this cursor
  points at has changed since the page was read.`, rather than starting the
  list again. Read the relation's first page again.

### Modifiers

`limit:`, `sort:` and `after:` are only meaningful on an array relation, and
only inside its parentheses. On a single relation they are `400 invalid_select`,
because a single relation is one entry and there is nothing to page or order.

| Modifier | Range                                             | Default                                    |
| -------- | ------------------------------------------------- | ------------------------------------------ |
| `limit:` | 1 to 200                                          | 100, counted as 10 by the node cap (below) |
| `sort:`  | one field of the target model, `-` for descending | the order the items are stored in          |
| `after:` | a cursor from that relation's `pageInfo.next`     | none                                       |

### The size caps

A `select` can ask for more work than is reasonable, so four numbers bound it.

| Cap                             | Value                                                                                                                                   |
| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| Depth                           | 5 levels of entries, the root counted: `author(employer(city(country(name))))` is the deepest, and it reads what legacy `depth=4` reads |
| Relation expansions per request | 12                                                                                                                                      |
| Nodes per request               | 5,000                                                                                                                                   |
| Top-level `limit`               | 200                                                                                                                                     |

Nodes are entries, root and expanded together. The cap is checked twice: before
anything reaches the database, by multiplying the limits you asked for, and
again after each level is fetched, by counting what actually came back. Either
way over is `400 query_too_complex` with nothing half-served.

The bound is a product, so it is the combination that costs. `limit=25` with
`coauthors(*,limit:200)` bounds at 5,025 and is refused with
`This request could return 5,025 entries, over the limit of 5,000.` and a hint
that names the limit to change and a value that fits:
`coauthors reads up to 200 entries for each of the 25 entries above it. Set limit:199 on coauthors to fit, or lower another limit. At most 5 levels, 12 expansions and 5,000 entries per request.`
`limit=24` is 4,824 and passes.

An array relation with no `limit:` still returns up to 100 entries, but the
bound counts it as 10 for each entry above it, so ordinary pages are never
refused for sizes nobody wrote: `select=title,coauthors(name),related(title)`
on a page of 25 bounds at 25 × (1 + 10 + 10) = 525, and a list inside a list
on one entry at 1 + 10 × 11 = 111. A `limit:` you write is counted as written,
`limit:100` included. What comes back is still held to 5,000: a list holding
more than it was counted for stops the read at that list, answering
`This request read at least 5,010 entries at coauthors, over the limit of 5,000.`
with a hint to set a smaller `limit:` on it. Give a `limit:` to any list that
can grow long.

Some reads cost more than the entries they return, and are charged 500 entries
each on the same 5,000:

* each `contains`, `startsWith`, `endsWith` or `ne` condition on one of the
  model's own fields, which reads and matches every entry's stored value;
* each relation list sorted with `sort:`, which reads every entry the list
  references, for every entry above it, to know which come first.

A request over the cap with them is refused with both parts stated:
`This request costs 5,300 entries, over the limit of 5,000: 800 it could return, plus 9 scans at 500 each.`
An `or` may also hold at most 3 such text conditions, nested ones counted,
since an entry that matches none of them is read against every one:
`where has an or of 5 contains, startsWith, endsWith or ne conditions, over the limit of 3.`
An `and` stops at its first condition that fails, so it has no such limit.

A `limit` or `limit:` out of range states the range:
`limit is "abc", which is not a whole number from 1 to 200.`

The URL and the request's headers together may be up to Node's default of 16 KB,
as on the legacy API. Past that the HTTP server answers a bare `431` before the
API reads the request, with a body of its own rather than this envelope. A
`select` naming a few thousand fields is past it: ask for fewer fields in one
request, or split the read in two.

Through the CDN the practical limit is lower. The edge refuses a URL over
8 KB with its own `414 URI Too Long`, which is not this envelope and never
reaches Capa. Keep a CDN-served read under 8,192 bytes, the same bound
GraphQL GET has; a longer selection belongs in `POST /api/graphql`.

Inline expansion has no filter. A sub-collection you want to filter is a
separate request against the parent's id.

## Each related entry once: `shape=flat`

An expansion is nested where you selected it, so twenty articles by one author
carry that author twenty times. Add `shape=flat` and each expanded entry comes
back once. On the sample project, Brin Cole writes the first article and
co-writes the second:

```bash
curl -s 'https://cdn.capacms.com/api/entries/articles?select=title,author(name),coauthors(name)&limit=2&shape=flat' \
  -H "x-api-key: $CAPA_KEY"
```

```json
{ "data": [
    { "id": "…2e", "model": "articles", "status": "published",
      "fields": { "title": "Xi marks the spot",
                  "author": { "id": "…12", "model": "authors" },
                  "coauthors": { "items": [ { "id": "…11", "model": "authors" }, { "id": "…13", "model": "authors" } ],
                                 "pageInfo": { "limit": 100, "hasNext": false, "next": null } } } },
    { "id": "…2c", "model": "articles", "status": "published",
      "fields": { "title": "Mu on measurable goals",
                  "author": { "id": "…13", "model": "authors" },
                  "coauthors": { "items": [ { "id": "…12", "model": "authors" } ],
                                 "pageInfo": { "limit": 100, "hasNext": false, "next": null } } } } ],
  "included": {
    "authors": {
      "00000000-0000-4000-8000-000000000012": { "id": "…12", "model": "authors", "status": "published", "fields": { "name": "Brin Cole" } },
      "00000000-0000-4000-8000-000000000011": { "id": "…11", "model": "authors", "status": "published", "fields": { "name": "Ada Vale" } },
      "00000000-0000-4000-8000-000000000013": { "id": "…13", "model": "authors", "status": "published", "fields": { "name": "Cody Marsh" } } } },
  "page": { "limit": 2, "hasNext": true, "next": "c1.eyJ2Ijoi…", "hasPrev": false, "prev": null },
  "meta": { "version": "2026-10-01", "contract": 1, "environment": "production", "requestId": "req_…" } }
```

Brin Cole and Cody Marsh are each reached twice, once as an author and once
as a coauthor, and each is in `included` once.

* Every relation is a `{ id, model }` reference, expanded or not. An array
  relation keeps `{ items, pageInfo }`, with references in `items`.
* `included` holds every expanded entry once, by model and then id, nested
  expansions too. It is `{}` when nothing was expanded.
* An entry that is already in `data` is not repeated in `included`: look it up
  in `data`. It carries any extra fields a relation path asked of it.
* An entry reached by two paths carries the fields both asked for.
* A reference stands for one rendering of the entry. When two paths render the
  same entry's relations differently (one pages `related` with `limit:1`, the
  other with `limit:2`, or one finds a relation missing), the path that
  disagrees with the entry's first rendering carries the entry in place
  instead: the whole entry, nested below it exactly as `shape=tree` renders it
  there. An entry every path renders the same way is still carried once.
* `inflate` returns the `shape=tree` body exactly, for every `select`.
* `page`, cursors and `total` are the same as the default shape.
* `shape=tree` is the default. Anything other than `tree` or `flat` is
  `400 invalid_parameter`.

The two shapes are cached separately and have different `ETag`s. Their
`Surrogate-Key`s are the same, so a publish purges both. `inflate` in
`@capacms/sdk/next` turns a flat response back into the nested one.

## Filtering

```
filter[<path>]=<value>              equality
filter[<path>][<op>]=<value>        an explicit operator
where=<json>                        boolean logic
```

`<path>` is one of:

| Path                                   | Example                               |
| -------------------------------------- | ------------------------------------- |
| a field namespace                      | `filter[views][gt]=10`                |
| a system key                           | `filter[publishedAt][gte]=2026-01-01` |
| a relation and one field of its target | `filter[author.name][eq]=Ada Vale`    |
| a media field's id                     | `filter[cover.id][eq]=<file id>`      |

The system keys you can filter on are `id`, `createdAt`, `updatedAt`,
`publishedAt` and `tags`. Where a field of yours has the same name, write the
system key as `$tags` (see [system keys and your fields](#system-keys-and-your-fields)).

Across a hop the reach is narrower: the target's own fields, plus its `id`.
`filter[author.createdAt][gte]=...` and the target's other system keys are
`400 unknown_field`, with a hint saying that only `author.id` and the target's
fields can be filtered. The hop is a semi-join and only the target's id is
carried across it.

Which operators a path takes depends on its type:

| Type                                            | Operators                                                                                                                |
| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| string, markdown, html, code, enum, color       | `eq` `ne` `in` `nin` `contains` `startsWith` `endsWith` `exists` `null`                                                  |
| number                                          | `eq` `ne` `in` `nin` `lt` `lte` `gt` `gte` `exists` `null`                                                               |
| true_false                                      | `eq` `ne` `exists` `null`                                                                                                |
| date, and `createdAt` `updatedAt` `publishedAt` | `eq` `ne` `lt` `lte` `gt` `gte` `exists` `null`                                                                          |
| array, and the entry's own `tags`               | `has` `hasAny` `hasAll` `exists` `null`. The values are the array's items: numbers, `true` or `false`, dates, or strings |
| relation, single                                | `eq` `ne` `in` `nin` `exists` `null`, plus one dotted hop                                                                |
| relation, array                                 | `has` `hasAny` `hasAll` `exists` `null`, plus one dotted hop                                                             |
| media                                           | `exists` `null`, and `eq` on `.id`                                                                                       |
| `id`                                            | `eq` `ne` `in` `nin`                                                                                                     |

Anything else is `400 invalid_operator`, and the hint lists what that type does
take.

`exists` and `null` ask whether a value is stored, and an empty array or blank
text is stored. `filter[tags][exists]=true` matches an entry whose `tags` is
`[]`, and `filter[tags][null]=true` matches only an entry where `tags` is
absent or null. The entry's own tags always hold a list, so on `$tags`,
`exists` means at least one tag and `null` means none.

`in`, `nin`, `hasAny` and `hasAll` take a comma-separated list, up to 200
values:

```
?filter[id][in]=00000000-0000-4000-8000-000000000021,00000000-0000-4000-8000-000000000022
?filter[tags][hasAny]=news,tech
```

`contains`, `startsWith` and `endsWith` match literally and ignore case:
`filter[title][contains]=kit` matches `Kitchen` and `KIT`. `%` and `_` in your
value are characters, not wildcards. No operator matches part of a value with
its case yet; `eq` compares the whole value, case included.

Three things follow from how SQL compares nulls and are worth knowing before they
surprise you:

* `ne` and `nin` exclude rows whose value is missing. An entry with no `views`
  is not returned by `filter[views][ne]=5`. Ask for the missing ones with
  `filter[views][null]=true`, or put both sides in one `where` under `or`.
* A stored value of the wrong type compares as missing rather than raising. An
  entry whose `views` holds the string `"ten"` is excluded from every numeric
  comparison instead of turning the request into a 500.
* `not` keeps a missing value missing. `where={"not":{"tags":{"has":"news"}}}`
  does not return an entry with no `tags`, or with `tags` that is not an array,
  and the same holds for `eq` on a single relation. Ask for those with `null`,
  under `or`.

Values are checked before they reach the database. A number field wants a
finite number, a `true_false` field or an array of them wants `true` or
`false` (the strings `"true"` and `"false"` are read as the same), a date or
an array of dates wants ISO 8601, an id wants a UUID. A `null` value, and an
operator object that names no operator (`{"views":{}}`), are refused rather
than read as "no condition": ask for a missing value with `null`, and leave the
key out for no condition. A date is written `YYYY-MM-DD`, with an
optional time (`THH:MM`, then optional seconds with up to six fraction digits)
and zone (`Z` or an offset up to `±14:59`). A date or time without a zone
(`2026-07-01`, `2026-07-01T09:30`) is read as UTC, whatever zone the server
runs in. Dates compare as instants to the microsecond, so
`2023-12-31T10:00:00.000001Z` and `2023-12-31T15:30:00.000001+05:30` are the
same value and `2023-12-31T10:00:00Z` is earlier than both. That holds where an
offset carries the instant out of the years 1 to 9999:
`9999-12-31T23:59:59.999-14:00` is `10000-01-01T13:59:59.999Z`, later than
every instant in 9999, and `0001-01-01T00:00:00+14:00` falls in 1 BC. The
items of an array of dates compare the same way, so `has` with
`2024-01-01T00:00:00Z` matches an item saved as `2024-01-01`. Anything
else is `400 invalid_filter_value` with the expected form in the hint.

### Boolean logic: `where`

`filter[...]` is a list of conditions and they are ANDed. For `or` and `not`,
send a JSON object as `where`:

```
?where={"and":[{"views":{"gt":10}},{"or":[{"tags":{"has":"news"}},{"featured":{"eq":true}}]}]}
```

The object's keys are `and` (an array), `or` (an array), `not` (an object), or a
path whose value is `{"<op>": value}` or a bare scalar for equality. It is
capped at 8 levels deep and 50 leaf conditions. `filter[...]` and `where` in the
same request are ANDed together.

Remember to URL-encode it. `where` that is not JSON, or is JSON but not an
object, is `400 invalid_filter_value` on `param: "where"`. So is a part of it
of the wrong shape, and the message names that part: `where.not is null.`,
`where.or is not an array.`, `where.and[1] is not a JSON object.`

## Sorting

```
?sort=-views,title,author.name
```

Up to three keys, comma-separated, `-` for descending. One of them may be a
single relation hop. Nulls sort last in both directions, and `id asc` is always
appended as the final tiebreak, so a page boundary never lands in the middle of
a tie and shows you the same entry twice.

Sortable: string, markdown, html, code, enum, color, number, true_false and date
fields, plus `createdAt`, `updatedAt`, `publishedAt` and `id`. Anything else,
an array field for instance, is `400 invalid_operator` on `param: "sort"`.

With no `sort`, entries come back `createdAt` descending, newest first.

What a sort costs. The default order (`-createdAt`) is read from an index, so
every page, however deep, costs about the same. Any other sort orders the
whole model on every page, and a sort on one of your fields, or through a
relation, also computes that value for every entry, because no index holds it:
the cost grows with the model's size, not the page's. On a model of 20,000
entries that is about 20 ms a page; filter first to narrow the set when you can.

Text sorts use a language-aware collation, the same one the existing sorted list
endpoints use, so accented characters land where a reader expects rather than
where their byte value would put them.

## Paging

| Parameter | Values                              | Default |
| --------- | ----------------------------------- | ------- |
| `limit`   | 1 to 200                            | 25      |
| `after`   | a cursor from `page.next`           | none    |
| `before`  | a cursor from `page.prev`, or `end` | none    |
| `count`   | `true` or `false`                   | `false` |

There are no page numbers. Walk the list by following `page.next` until
`hasNext` is `false`:

```bash
url='https://cdn.capacms.com/api/entries/articles?limit=50&sort=title'
while [ -n "$url" ]; do
  body=$(curl -s "$url" -H "x-api-key: $CAPA_KEY")
  echo "$body" | jq -c '.data[]'
  next=$(echo "$body" | jq -r '.page.next // empty')
  url=$([ -n "$next" ] && echo "https://cdn.capacms.com/api/entries/articles?limit=50&sort=title&after=$next")
done
```

`page.hasPrev` and `page.prev` are set on any page you reached with `after`, so
you can walk backwards; `before` is the mirror image and sets `hasNext` and
`next`.

`before=end` reads the last `limit` entries of the list, in list order: the
page a "load older" view starts from. Nothing follows it, so `hasNext` is
`false`; `hasPrev` and `prev` say whether entries come before it, and `prev`
walks back from there. `end` is not a cursor, and `after=end` is
`400 invalid_cursor`.

A cursor is an opaque signed token, `c1.<payload>.<signature>`. Do not build one
or parse one. It records the sort you were using and the platform version it was
minted on, and it is signed, so these answer `400 invalid_cursor`:

| What happened                                                                                   | The message                                                          |
| ----------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
| the token was edited or truncated                                                               | This cursor is not valid.                                            |
| you changed `sort` between pages                                                                | This cursor was issued for a different sort.                         |
| the cursor was minted on a different platform version                                           | This cursor was issued for version …; this request runs on ….        |
| a relation cursor was replayed on another entry                                                 | This cursor belongs to another entry.                                |
| the page was sorted by a text value over 128 bytes, and that entry has changed or is gone since | The entry this cursor points at has changed since the page was read. |
| a relation page in stored order, and the cursor's id has been taken out of the list since       | The entry this cursor points at has changed since the page was read. |

In every case the hint is the same: request the first page again without a
cursor. Sending `after` and `before` together is also `invalid_cursor`, with
the message `This request sent after and before.` and the hint to send one of
them.

### Totals

`count=true` adds `page.total`. It is off by default because counting costs a
second pass over the same rows and most listings do not need it. One case
costs nothing extra: when `where` or `sort` names your model's own fields and
the first page holds every match (no cursor, fewer matches than `limit`), the
total is the page's length and no second pass runs.

When the filter crosses a relation, the total is answered through a probe that
stops at 50,001 rows. Past that you get `400 count_unavailable`, with the hint
to drop the relation filter or drop `count=true`. Counting without a relation
filter has no such ceiling.

## Reading one entry

```
GET /api/entries/articles/00000000-0000-4000-8000-000000000021?select=title,author(name)
```

`select` works exactly as it does on a list. Everything else does not:
`limit`, `after`, `before`, `count`, `sort`, `filter` and `where` on a single
entry answer `400 invalid_parameter` with the hint that only `select` applies.

`404 entry_not_found` covers an id that is not a UUID, an entry in a different
model, an entry in a different project, a deleted entry, and, for a production
key, an entry that has never been published. They are one answer on purpose:
telling them apart would tell a caller from another project which ids exist.

## Drafts and environments

A key carries an environment, and the environment decides what you can see.

| Key environment | Sees                                       | `status` can be                 |
| --------------- | ------------------------------------------ | ------------------------------- |
| `production`    | published entries only, published data     | `published`                     |
| anything else   | every entry, draft data where there is any | `published`, `draft`, `changed` |

`changed` means the entry is published and has a newer draft on top. A
development key reading it gets the draft data. A production key gets the
published data and sees `status: "published"`, because as far as that key is
concerned the draft does not exist. The same goes for `updatedAt` and
`tags`: a production key reads when the published version was saved, and the
tags the entry had when it was last published, in the entry, in a sort and in
a filter, so saving a draft changes nothing that key can see. A development
key reads the entry's own `updatedAt`, which every save moves, and its current
tags. Publishing makes the current tags the published ones.

`folder` is the exception, on purpose: it is where the entry is filed in the
admin, not part of its content, so moving an entry to another folder applies
to every key at once, published or not, and purges the entry's cached
responses.

This mirrors what `/v2/api` and `/v3/api` already do for the same key. It is a
read-visibility dial and not a sandbox: there is one database, and a test key
that can publish publishes to your live site.

## Per-model keys

A `cap_` key can be restricted to one model. The scope is
`instance:read:<modelId>`, where the third segment is the model's id, not its
namespace, because ids never change.

A key holding `instance:read` reads every model. A key holding
`instance:read:00000000-0000-4000-8000-000000000003` reads that model and gets
`403 scope_missing` on every other one, with the hint naming the scope it would
need:

```json
{ "error": { "type": "permission", "code": "scope_missing",
             "message": "This key cannot do instance:read.",
             "hint": "This key needs instance:read, or instance:read:00000000-0000-4000-8000-000000000010 for authors.",
             "docs": "https://docs.capacms.com/errors/scope_missing" },
  "meta": { "version": "2026-10-01", "contract": 1, "requestId": "req_0f3c…" } }
```

Both key families reach these routes. A `pk_` key uses the bundle it already
has, so a `read` key works here with no change. A `cap_` key uses its scopes.
`GET /api/me` lists them, and its `models` array tells you exactly which models
the key in your hand can read.

An unknown namespace is `404 model_not_found` for every key, checked before the
scope. The order is deliberate, and not as an anti-enumeration measure: it is so
that a restricted key asking for a model it does not hold is told it cannot read
that model rather than that the model is missing, which is the answer that helps
whoever is debugging a scope. The `hint` on that 404 lists only the namespaces
this key can read.

A `403` therefore does confirm that the namespace exists on your project. A
restricted key can tell a model it may not read from one that is not there. It
cannot read a byte of either.

**What the restriction covers.** It covers rows, not only routes. The schema a
restricted key is read against holds only the models it can read, so a relation
pointing at a model it cannot read is not a path it can travel:

| You write                          | You get                                                            |
| ---------------------------------- | ------------------------------------------------------------------ |
| `select=author(name)`              | `400 invalid_select`, `author does not point at a readable model.` |
| `filter[author.name][eq]=Ada Vale` | `400 unknown_field`, same message                                  |
| `filter[author.id][eq]=...`        | `400 unknown_field`, same message                                  |
| `sort=author.name`                 | `400 unknown_field`, same message                                  |
| `select=title,author`              | `200`, and `author` is `{ "id": "...", "model": null }`            |

The reference keeps its id, because the id is on the article and the key is
allowed to read the article. What it loses is the namespace, which is the
target model's, and every way of turning that id into content. An array
relation into an unreadable model is the same: each item keeps its id and
carries `model: null`.

So a per-model key is a wall around the rows, not only a lock on the routes.
The one thing it still tells you about a model you cannot read is that it
exists, through the `403` above.

## Caching

A production key gets cacheable responses. A development key never does,
because a draft has no business in a shared cache.

| Header                                         | Production key                                                         | Development key      |
| ---------------------------------------------- | ---------------------------------------------------------------------- | -------------------- |
| `Cache-Control`                                | `public, max-age=60`                                                   | `no-store, no-cache` |
| `Surrogate-Control`                            | the configured edge lifetime                                           | `no-store`           |
| `Surrogate-Key`                                | `t:… c:… k:… m:… e:…`                                                  | absent               |
| `ETag`                                         | a strong tag over `data` and `page`, and `included` on a flat response | absent               |
| `Cloudflare-CDN-Cache-Control`                 | `no-store`                                                             | `no-store`           |
| `Vary`                                         | `Origin, x-api-key, Capa-Version, Capa-Contract`                       | same                 |
| `Capa-Version`, `Capa-Contract`, `Capa-Key-Id` | always                                                                 | always               |

Every 4xx is `Cache-Control: no-store` with no `ETag` and no `Surrogate-Key`,
with one exception. A production key's `404 entry_not_found` for a
well-formed id is `public, max-age=60` with the configured `Surrogate-Control`
and the keys `t:… c:… k:… m:<model> e:<id>`, and no `ETag`. When an entry is
taken down, the edge fetches the 404 in place of the cached body, and the
publish that brings the entry back purges the 404. A development key's 404 is
`no-store`.

### Revalidation

Send the `ETag` back as `If-None-Match` and an unchanged response is a `304`
with an empty body and the same headers:

```bash
curl -sD - -o /dev/null 'https://cdn.capacms.com/api/entries/articles?limit=2' \
  -H "x-api-key: $CAPA_KEY" -H 'If-None-Match: "3f9c1a…"'
```

The tag hashes `data` and `page`. It deliberately does not hash `meta`, because
`meta.requestId` is different on every request and a tag that included it would
never match.

### Surrogate keys

Each production response carries the tags the edge purges it by.
Publishing an entry, or a save that moves it to another folder, purges its
`e:` key and its model's `m:` key, so every page that showed the entry and
every list of its model are fetched fresh, including a list whose order,
`total` or next page the change moved. Unpublishing or deleting an entry
purges the same keys hard, so the edge drops the old bodies at once rather
than serving them while it fetches again; deleting a model purges its `m:` key
hard, and deleting a project its `t:` key.

| Key                       | One per                                                                                             |
| ------------------------- | --------------------------------------------------------------------------------------------------- |
| `t:<tenantId>`            | response                                                                                            |
| `c:<tenantId>:<contract>` | response                                                                                            |
| `k:<keyId>`               | response                                                                                            |
| `m:<modelId>`             | the root model, every expanded relation's model, and every model a filter or sort hops into         |
| `e:<entryId>`             | entry in `data`, and every expanded entry                                                           |
| `f:<fileId>`              | file a media value renders; editing the file's alt text purges it, deleting the file purges it hard |

The header is capped at 12 KB. A response with more entries than fit keeps the
`t:`, `c:`, `k:`, `m:` and `f:` keys, drops the `e:` keys and sets
`Capa-Cache-Scope: model`. If the `f:` keys still do not fit, they give way to
`f:<tenantId>`, which every file purge of the project also sends. A purge is
then broader than it needed to be, never narrower, so nothing goes stale.

Deleting or deactivating a key, rotating it, narrowing its scopes or its
allowed origins, or setting or bringing forward its expiry purges its `k:` key.
A key with an expiry also caps `Surrogate-Control`: the edge lifetime, stale
windows included, ends when the key does.

### Rate limits

Nothing limits how many reads a key makes per minute. What is limited is how
many reads run at once, for a key and for a project. Over those shares is
`429 rate_limit_exceeded` with `Retry-After` in seconds. See the `429` row
under [Errors](#errors). No response carries an `X-RateLimit-*` header.

## Coming from `/v2/api` or `/v3/api`

Your existing integration does not change. When you port a page, this is the
translation. `articles` and its `author` and `coauthors` are the sample
project's models, so those rows run as written against it. The
`employer`, `city`, `country` and `gallery` fields stand for relation and media
fields of your own models: the sample project has none, so put your own field names in
those rows.

| Legacy                               | `/api/`                                                                                                                                 |
| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- |
| `GET /v2/api/articles`               | `GET /api/entries/articles`                                                                                                             |
| `?depth=1`                           | `?select=*,author(*),coauthors(*)`, naming each relation field                                                                          |
| `?depth=1&nestedLimit=100`           | `?select=*,coauthors(*,limit:100)`                                                                                                      |
| `?depth=2`                           | `?select=*,author(*,employer(*)),coauthors(*)`                                                                                          |
| `?depth=4`, the deepest legacy read  | `?select=*,author(*,employer(*,city(*,country(*))))`: five levels of entries, the deepest `select` reads                                |
| `?limit=50&page=3`                   | `?limit=50&after=<page.next from page 2>`                                                                                               |
| `?sort=-views`                       | `?sort=-views`, unchanged                                                                                                               |
| `?views[gt]=10`                      | `?filter[views][gt]=10`                                                                                                                 |
| `?ids=a,b,c`                         | `?filter[id][in]=a,b,c`                                                                                                                 |
| `?title[string_contains]=…`          | `?filter[title][contains]=…`, or `startsWith` / `endsWith`                                                                              |
| `?limit=500`                         | two or three pages: `/api/` caps `limit` at 200                                                                                         |
| `?nestedPage=2`                      | the relation's `pageInfo.next` inside `after:`                                                                                          |
| `?structure=relations` (the default) | `?shape=flat`: each related entry once, in `included`, and every field in one place rather than under `data` and again at the top level |

### What reads differently

These change what a ported page gets back, or refuse a request legacy
answered. Each row gives the legacy call, the `/api/` call that matches it,
and what to check.

| Legacy                                                                               | `/api/`                                                    | What changes                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| ------------------------------------------------------------------------------------ | ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET /v2/api/articles`                                                               | `GET /api/entries/articles?sort=id`                        | **Default order.** Legacy lists entries by `id`, ascending. `/api/` lists them newest first (`createdAt` descending). Pass `sort=id` to keep the legacy order, or `sort: [id_ASC]` in GraphQL                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| `GET /v2/api/articles`, where the `gallery` images have no alt text                  | `GET /api/entries/articles?select=title,gallery`           | **`alt` is filled.** Legacy fills an empty `alt` from the file only on a single image or video field. `/api/` fills it on every media value: lists of images, files, and related entries at any depth. A page that renders `alt \|\| title` now shows the file's alt text where it used to show the title                                                                                                                                                                                                                                                                                                                                       |
| `GET /v2/api/articles?subtitle=x&sort=-subtitle`, where `articles` has no `subtitle` | `GET /api/entries/articles`, with the name left out        | **Field names are strict.** Legacy ignores a filter or sort on a field the model does not have and answers the whole list. `/api/` refuses the same name in `select`, `filter`, `where` or `sort` with `400 unknown_field` and lists the model's fields in `hint`: `?filter[subtitle][eq]=x` is refused. Remove the name, or fix its spelling                                                                                                                                                                                                                                                                                                   |
| `GET /v2/api/home_pages?slug=/`                                                      | `GET /api/entries/home_pages?filter[slug]=/`               | **Unknown parameters are refused.** Legacy reads `?slug=/` as equality on `slug`, and `?ids=`, `?depth=`, `?page=` and the others in the table above as its own parameters. `/api/` reads only [its own parameters](#parameters): any other is `400 invalid_parameter`, and the hint gives the `/api/` form, as `?slug=/ is legacy equality: send filter[slug]=/.` or `?page= is legacy paging: pass after=<page.next> from the page before, since /api/ pages by cursor.` Ignored, the old URL would answer the first page of the whole list, and a ported home page would show another page. A name that starts with `_` or `utm_` is ignored |
| `GET /v2/api/menu`, where `menu` has a field named `price.usd`                       | `GET /api/entries/menu?select=title,"price.usd"`           | **Some field names are quoted.** A name that holds `.` `,` `(` `)` `:` `"` `*` `[` `]` or a space is written in double quotes in `select`, `sort`, `where` and `filter[...]`, so it is never read as a relation hop or a list separator. Other names, `am/pm_indicator` among them, are written as they are. A name that starts with `$` cannot be named: `select=*` returns it. See [Any field name](#field-names)                                                                                                                                                                                                                             |
| `GET /v2/api/articles?title[string_contains]=Kit`                                    | `GET /api/entries/articles?filter[title][contains]=Kit`    | **Text matching ignores case.** Legacy's `string_contains` matches the case you wrote, so `Kit` misses `kitchen`. `/api/`'s `contains`, `startsWith` and `endsWith` ignore case, so `Kit` matches `Kitchen`, `kitchen` and `KIT`, and a page can list more entries than it did. No `/api/` operator matches part of a value case-sensitively yet. Where case matters, check the value in your own code after the read; `eq` still compares a whole value exactly, case included                                                                                                                                                                 |
| `GET /v2/api/articles?depth=1&nestedLimit=10`                                        | `GET /api/entries/articles?select=*,coauthors(*,limit:10)` | **Nested limits are per list.** Legacy's `nestedLimit` sets every list at once, 100 by default and up to 500. `/api/` has no request-wide setting: each list takes its own `limit:`, 1 to 200, and reads up to 100 without one, which the [node cap](#the-size-caps) counts as 10 before the read. Name the number the page shows on every list: the response stays small, and a list that holds more than it was counted for cannot get the read refused after the fetch                                                                                                                                                                       |
| `GET /v2/api/articles?limit=500&page=3`                                              | `GET /api/entries/articles?limit=200&after=<page.next>`    | **Cursor paging, at most 200 a page.** There are no page numbers and no 500-item pages. Walk the list with `page.next` (`first: 200, after: $endCursor` in GraphQL), so a build that jumped to page 7 now follows cursors and a legacy call above 200 becomes several requests. See [Paging](#paging)                                                                                                                                                                                                                                                                                                                                           |
| `GET /v2/api/search?q=kitchen`                                                       | none yet                                                   | **Search stays on legacy.** `/api/` has no search. Keep calling `/v2/api/search` for it, with the same legacy key, until `/api/` gains one; everything else on the page can move                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `GET /v2/api/articles?depth=0`, where `hero_image` was never set                     | `GET /api/entries/articles?select=title,hero_image`        | **An empty media field is `null`.** Legacy sends an unset image, video or file as an empty file object (`url`, `alt`, `id` and the rest all `""`), which is truthy, and leaves out a field the entry never stored. `/api/` sends `null` for both. A page that tests `if (image)` or `image ? ... : fallback` renders the fallback on `/api/` where legacy rendered an empty image. Test `image?.url` on both                                                                                                                                                                                                                                    |
| `GET /v2/api/menu?depth=1`, where `items` lists an entry that was deleted            | `GET /api/entries/menu?select=items(title)`                | **A dangling reference keeps its slot in REST.** Legacy leaves the id in the list and the entry out of `relations`, so site code skips it. REST sends `{ "id": "...", "model": "...", "missing": true }` in its place, with no `fields`, and GraphQL leaves it out. Skip items with `missing` before reading `fields`                                                                                                                                                                                                                                                                                                                           |

## Errors

Every failure is the envelope from [API reference](https://capacms.com/docs/api), and most carry a
`hint`. The hint is the useful part: it names the fields you could have asked
for, the operators that type takes, or the scope your key is missing. Five
carry none on REST, and say so in the table: `missing_key`, `invalid_key`,
`origin_refused`, `mutations_not_enabled` and `internal`. `/api/graphql` adds a
hint to the first four.

```json
{ "error": { "type": "invalid_request", "code": "unknown_field",
             "message": "articles has no field \"subtitle\" in contract 1.",
             "param": "select",
             "hint": "Fields: title, body, views, featured, tags, author, coauthors. System keys: id, model, status, createdAt, updatedAt, publishedAt, version, folder, $tags.",
             "docs": "https://docs.capacms.com/errors/unknown_field" },
  "meta": { "version": "2026-10-01", "contract": 1, "requestId": "req_0f3c…" } }
```

| Status | `type`            | `code`                  | When                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              | What the hint tells you                                                                                                                                                                                           |
| ------ | ----------------- | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 400    | `invalid_request` | `invalid_version`       | `Capa-Version` is not a supported date                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            | the versions that exist                                                                                                                                                                                           |
| 400    | `invalid_request` | `unknown_field`         | a name in `select`, `filter`, `where` or `sort` is not a field of that model, or a `filter` or `sort` hop into a model [this key may not read](#per-model-keys)                                                                                                                                                                                                                                                                                                                                                                                                                   | every field of the model, and the system keys at the root                                                                                                                                                         |
| 400    | `invalid_request` | `invalid_select`        | unbalanced parentheses, an empty item, a duplicate item, parentheses on something that is not a relation, a modifier on a single relation, a malformed `sort:` inside `select`, or an expansion into a model [this key may not read](#per-model-keys)                                                                                                                                                                                                                                                                                                                             | the grammar, with a worked example; for a name that holds a space or a character the grammar uses, its [quoted form](#field-names)                                                                                |
| 400    | `invalid_request` | `invalid_operator`      | an operator the type does not take, or an unsortable field in `sort`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              | the operators that type does take, or the sortable types                                                                                                                                                          |
| 400    | `invalid_request` | `invalid_filter_value`  | a value of the wrong shape, `where` that is not a JSON object, or a part of `where` of the wrong shape, which the message names (`where.not is null.`)                                                                                                                                                                                                                                                                                                                                                                                                                            | the expected form: a number, `true` or `false`, an ISO 8601 date, a UUID, a comma-separated list; for `where`, a worked example of the object                                                                     |
| 400    | `invalid_request` | `invalid_cursor`        | a tampered, foreign-version, wrong-sort or wrong-parent cursor, or `after` and `before` together                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  | request the first page again without a cursor; for `after` and `before` together, send one of them                                                                                                                |
| 400    | `invalid_request` | `invalid_parameter`     | a query parameter the route does not read, `limit` or `count` malformed, a malformed `sort` or more than three sort keys, a repeated query key, or a paging parameter on a single entry                                                                                                                                                                                                                                                                                                                                                                                           | for a legacy parameter, the `/api/` form; for any other name, the parameters there are; the accepted range, the sort grammar, or that only `select` applies here                                                  |
| 400    | `invalid_request` | `query_too_complex`     | past 5 levels of entries, 12 expansions or 5,000 nodes                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            | for depth, the 5 levels and to read the deeper entries with a second request; for nodes, the list to change and a `limit:` that fits; otherwise the three caps, and to lower a `limit:` or expand fewer relations |
| 400    | `invalid_request` | `count_unavailable`     | `count=true` with a relation filter over 50,000 rows                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              | drop the relation filter or drop `count=true`                                                                                                                                                                     |
| 504    | `api_error`       | `query_timeout`         | the read hit the server-side statement timeout (5 seconds by default)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             | narrow the filter, expand fewer relations or request a smaller `limit`, then retry                                                                                                                                |
| 401    | `authentication`  | `missing_key`           | no `x-api-key` header                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             | no hint on REST: send the header, with a key from Developers > Keys                                                                                                                                               |
| 401    | `authentication`  | `invalid_key`           | unknown, revoked or expired key                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   | no hint on REST: check the key's expiry and active flag under Developers > Keys                                                                                                                                   |
| 402    | `payment`         | `subscription_required` | the project has no active subscription                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            | check the plan on Settings > Billing                                                                                                                                                                              |
| 403    | `permission`      | `origin_refused`        | the key is bound to origins and this `Origin` is not one                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          | no hint on REST; the message names the refused origin. Add it to the key under Developers > Keys, or send the request from a server                                                                               |
| 403    | `permission`      | `scope_missing`         | the key does not hold `instance:read` for this model                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              | the scope to add, unscoped or for this model                                                                                                                                                                      |
| 403    | `permission`      | `edge_only`             | the request reached the origin directly instead of through the CDN                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                | send it to the API hostname                                                                                                                                                                                       |
| 404    | `not_found`       | `model_not_found`       | no model with that namespace                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      | the namespaces this key can read                                                                                                                                                                                  |
| 404    | `not_found`       | `entry_not_found`       | no such entry for this key                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        | development keys read drafts, production keys read published entries only                                                                                                                                         |
| 404    | `not_found`       | `contract_not_found`    | `Capa-Contract` is not `1`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        | the contracts that exist                                                                                                                                                                                          |
| 405    | `method`          | `mutations_not_enabled` | a POST, PUT, PATCH or DELETE                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      | no hint on REST: writes are not enabled yet                                                                                                                                                                       |
| 429    | `rate_limited`    | `rate_limit_exceeded`   | one client with 3 reads running with a key and 64 more waiting, the client with the most reads waiting when a key has 4 running and 256 waiting across its clients (a client with fewer waiting takes that client's newest place), a project whose keys together have 4 reads running (`This project has too many reads running at once across its keys.`), or a read that waited out the database budget behind one of those shares. GraphQL documents count toward the same shares, and another client of the key is still served. A client is its address, or its /64 for IPv6 | retry after `Retry-After`                                                                                                                                                                                         |
| 500    | `api_error`       | `internal`              | our fault                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         | no hint: quote `meta.requestId`                                                                                                                                                                                   |
| 503    | `api_error`       | `service_unavailable`   | the API process was full of other projects' reads for the whole database budget, the last slot being kept for a project with none running, or had no database connection free in time                                                                                                                                                                                                                                                                                                                                                                                             | nothing in the request is wrong: retry after `Retry-After`                                                                                                                                                        |

`401 invalid_key` is the same answer for an unknown key, a revoked key and an
expired key, and it does not say which. If a key that worked yesterday stops
working, read its row under Developers > Keys rather than the response body.

A `500` never carries details. A read that runs longer than the statement
timeout is not a `500`: it is `504 query_timeout` from the table above, with a
hint, and it is logged against the `requestId` in `meta`.
