Docs

Entries

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

View as Markdown

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 for how a /api/ request is shaped and Keys and scopes for how to mint a key.

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

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:

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

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

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

KeyTypeMeaning
iduuidthe entry
modelstringthe namespace, so a mixed list stays readable
statuspublished, draft, changedsee Drafts
createdAtISO 8601when the entry was created
updatedAtISO 8601when 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)
publishedAtISO 8601 or nullwhen the entry was last published, null if it never was
versionintegerthe version number of the row you are reading
folderuuid or nullthe entry's folder
tagsarray of stringsthe entry's own tags, not a field. A production key reads the tags the entry had when it was last published (see Drafts)
fieldsobjectyour fields, in the order you laid them out on the model

Inside fields:

Field typeRenders as
string, markdown, html, code, enum, colorthe stored string
numbera JSON number
true_falsetrue or false
datean ISO 8601 string
arraya JSON array
array of image, video or filea 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
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:

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

FieldYou write
am/pm_indicator, open-time, émoji_ñas it is: select=am/pm_indicator
price.usdselect="price.usd", sort=-"price.usd", where={"\"price.usd\"":{"gt":3}}
a,b, paren(x), colon:x, has spaceselect="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 writeYou get
(nothing)every system key, every field, relations as references
select=title,viewsid, model, status and those two fields
select=*every system key and every field, as with no select
select=title,publishedAt,versionid, model, status, the two system keys you named, and title
select=tags,$tagson a model with its own tags field: your field under fields, and the entry's tags on top
select=title,authorauthor 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, 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 writeIt reads
select=tagsyour tags field
select=$tagsthe entry's own tags
sort=-createdAtyour createdAt field
sort=-$createdAtwhen 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:

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

ModifierRangeDefault
limit:1 to 200100, counted as 10 by the node cap (below)
sort:one field of the target model, - for descendingthe order the items are stored in
after:a cursor from that relation's pageInfo.nextnone

The size caps

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

CapValue
Depth5 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 request12
Nodes per request5,000
Top-level limit200

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:

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"
{ "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 ETags. Their Surrogate-Keys 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:

PathExample
a field namespacefilter[views][gt]=10
a system keyfilter[publishedAt][gte]=2026-01-01
a relation and one field of its targetfilter[author.name][eq]=Ada Vale
a media field's idfilter[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).

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:

TypeOperators
string, markdown, html, code, enum, coloreq ne in nin contains startsWith endsWith exists null
numbereq ne in nin lt lte gt gte exists null
true_falseeq ne exists null
date, and createdAt updatedAt publishedAteq ne lt lte gt gte exists null
array, and the entry's own tagshas hasAny hasAll exists null. The values are the array's items: numbers, true or false, dates, or strings
relation, singleeq ne in nin exists null, plus one dotted hop
relation, arrayhas hasAny hasAll exists null, plus one dotted hop
mediaexists null, and eq on .id
ideq 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

ParameterValuesDefault
limit1 to 20025
aftera cursor from page.nextnone
beforea cursor from page.prev, or endnone
counttrue or falsefalse

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

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 happenedThe message
the token was edited or truncatedThis cursor is not valid.
you changed sort between pagesThis cursor was issued for a different sort.
the cursor was minted on a different platform versionThis cursor was issued for version …; this request runs on ….
a relation cursor was replayed on another entryThis cursor belongs to another entry.
the page was sorted by a text value over 128 bytes, and that entry has changed or is gone sinceThe 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 sinceThe 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 environmentSeesstatus can be
productionpublished entries only, published datapublished
anything elseevery entry, draft data where there is anypublished, 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:

{ "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 writeYou get
select=author(name)400 invalid_select, author does not point at a readable model.
filter[author.name][eq]=Ada Vale400 unknown_field, same message
filter[author.id][eq]=...400 unknown_field, same message
sort=author.name400 unknown_field, same message
select=title,author200, 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.

HeaderProduction keyDevelopment key
Cache-Controlpublic, max-age=60no-store, no-cache
Surrogate-Controlthe configured edge lifetimeno-store
Surrogate-Keyt:… c:… k:… m:… e:…absent
ETaga strong tag over data and page, and included on a flat responseabsent
Cloudflare-CDN-Cache-Controlno-storeno-store
VaryOrigin, x-api-key, Capa-Version, Capa-Contractsame
Capa-Version, Capa-Contract, Capa-Key-Idalwaysalways

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:

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.

KeyOne 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. 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/articlesGET /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=500two or three pages: /api/ caps limit at 200
?nestedPage=2the 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/articlesGET /api/entries/articles?sort=idDefault 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 textGET /api/entries/articles?select=title,galleryalt 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 subtitleGET /api/entries/articles, with the name left outField 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: 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.usdGET /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
GET /v2/api/articles?title[string_contains]=KitGET /api/entries/articles?filter[title][contains]=KitText 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=10GET /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 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=3GET /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
GET /v2/api/search?q=kitchennone yetSearch 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 setGET /api/entries/articles?select=title,hero_imageAn 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 deletedGET /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, 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.

{ "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…" } }
StatustypecodeWhenWhat the hint tells you
400invalid_requestinvalid_versionCapa-Version is not a supported datethe versions that exist
400invalid_requestunknown_fielda 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 readevery field of the model, and the system keys at the root
400invalid_requestinvalid_selectunbalanced 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 readthe grammar, with a worked example; for a name that holds a space or a character the grammar uses, its quoted form
400invalid_requestinvalid_operatoran operator the type does not take, or an unsortable field in sortthe operators that type does take, or the sortable types
400invalid_requestinvalid_filter_valuea 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
400invalid_requestinvalid_cursora tampered, foreign-version, wrong-sort or wrong-parent cursor, or after and before togetherrequest the first page again without a cursor; for after and before together, send one of them
400invalid_requestinvalid_parametera 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 entryfor 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
400invalid_requestquery_too_complexpast 5 levels of entries, 12 expansions or 5,000 nodesfor 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
400invalid_requestcount_unavailablecount=true with a relation filter over 50,000 rowsdrop the relation filter or drop count=true
504api_errorquery_timeoutthe read hit the server-side statement timeout (5 seconds by default)narrow the filter, expand fewer relations or request a smaller limit, then retry
401authenticationmissing_keyno x-api-key headerno hint on REST: send the header, with a key from Developers > Keys
401authenticationinvalid_keyunknown, revoked or expired keyno hint on REST: check the key's expiry and active flag under Developers > Keys
402paymentsubscription_requiredthe project has no active subscriptioncheck the plan on Settings > Billing
403permissionorigin_refusedthe key is bound to origins and this Origin is not oneno hint on REST; the message names the refused origin. Add it to the key under Developers > Keys, or send the request from a server
403permissionscope_missingthe key does not hold instance:read for this modelthe scope to add, unscoped or for this model
403permissionedge_onlythe request reached the origin directly instead of through the CDNsend it to the API hostname
404not_foundmodel_not_foundno model with that namespacethe namespaces this key can read
404not_foundentry_not_foundno such entry for this keydevelopment keys read drafts, production keys read published entries only
404not_foundcontract_not_foundCapa-Contract is not 1the contracts that exist
405methodmutations_not_enableda POST, PUT, PATCH or DELETEno hint on REST: writes are not enabled yet
429rate_limitedrate_limit_exceededone 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 IPv6retry after Retry-After
500api_errorinternalour faultno hint: quote meta.requestId
503api_errorservice_unavailablethe 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 timenothing 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.