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 for how a /api/ request is shaped and
Keys and scopes 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.
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, theETagand theSurrogate-Keyare 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.
| Key | Type | Meaning |
|---|---|---|
id | uuid | the entry |
model | string | the namespace, so a mixed list stays readable |
status | published, draft, changed | see Drafts |
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) |
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) |
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 |
| 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.
| 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,
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:
"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 with400 invalid_cursorand the messageThe 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,endsWithornecondition 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 initems. includedholds every expanded entry once, by model and then id, nested expansions too. It is{}when nothing was expanded.- An entry that is already in
datais not repeated inincluded: look it up indata. 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
relatedwithlimit:1, the other withlimit: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 asshape=treerenders it there. An entry every path renders the same way is still carried once. inflatereturns theshape=treebody exactly, for everyselect.page, cursors andtotalare the same as the default shape.shape=treeis the default. Anything other thantreeorflatis400 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:
| 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).
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,techcontains, 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:
neandninexclude rows whose value is missing. An entry with noviewsis not returned byfilter[views][ne]=5. Ask for the missing ones withfilter[views][null]=true, or put both sides in onewhereunderor.- A stored value of the wrong type compares as missing rather than raising. An
entry whose
viewsholds the string"ten"is excluded from every numeric comparison instead of turning the request into a 500. notkeeps a missing value missing.where={"not":{"tags":{"has":"news"}}}does not return an entry with notags, or withtagsthat is not an array, and the same holds foreqon a single relation. Ask for those withnull, underor.
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.nameUp 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:
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")
donepage.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:
{ "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:
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. 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: 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 |
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 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 |
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, 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…" } }| 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 | 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 | the grammar, with a worked example; for a name that holds a space or a character the grammar uses, its quoted form |
| 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.