Legacy API: /v2/api and /v3/api
The frozen read API existing sites use: keys, routes, parameters, the response shape, caching and errors.
/v2/api and /v3/api are the read API every Capa site in production uses
today. This page is its whole contract: keys, routes, every parameter, the
response shape, caching, errors, and the edge cases that surprise people.
The surface is frozen. Its responses are byte for byte what the legacy platform served, apart from the privacy and tenant-isolation fixes listed under Fixed on this platform. This page describes what it does. Where that is odd, the page says so rather than papering over it, because your site already depends on the odd part.
Starting something new? Use /api/ instead. See
Moving to the new API.
| Route | What it answers |
|---|---|
GET /v2/api/{namespace} | a page of entries of one model |
GET /v2/api/{entryId} | one entry, by its id |
GET /v2/api/{modelId} | a page of entries, with the model named by its id |
GET /v2/api/{namespace}/types | the model's field names and types |
GET /v2/api/search | full-text search across the project |
Every route exists under /v3/api too, with the same parameters. The few
differences are in v2 and v3.
The examples read a small blog. articles has title, slug,
published_date, excerpt, body and author, a relation to authors.
authors has name, slug and bio. Where a rule needs a field type the
blog does not have, the example names one: a number field views, a
true/false field featured, a list field tags, and coauthors, a list of
authors.
A first request
export CAPA_KEY=... # your pk_ key, from your secret store
curl 'https://cdn.capacms.com/v2/api/articles?slug=a-preview-you-can-trust' \
-H "x-api-key: $CAPA_KEY"Send every /v2/api and /v3/api request to cdn.capacms.com, Capa's CDN.
Once the origin lock is enforced, /v2/api, /v3/api and /files answer
only through the CDN, and a request sent straight to Capa's servers gets the
403 for direct origin access.
{
"data": [
{
"id": "61001acc-e032-48cb-aeff-0f2d0bb69e6b",
"modelId": "3a96922b-d031-41fb-955b-7ca6434d10cd",
"data": {
"body": { "type": "markdown", "value": "Plenty of editors have learned to…", "sortOrder": 5 },
"slug": { "type": "string", "value": "a-preview-you-can-trust", "sortOrder": 1 },
"title": { "type": "string", "value": "A Preview You Can Trust", "sortOrder": 0 },
"author": { "type": "relation", "value": "854e6620-778b-4750-a93c-556e2e1516c4", "sortOrder": 4 },
"excerpt": { "type": "string", "value": "A preview is only useful if it is the real page…", "sortOrder": 3 },
"published_date": { "type": "date", "value": "2026-08-18", "sortOrder": 2 }
},
"draft": null,
"createdAt": "2026-09-23T04:36:07.033Z",
"updatedAt": "2026-09-25T18:32:14.190Z",
"tags": [],
"title": { "type": "string", "value": "A Preview You Can Trust", "sortOrder": 0 },
"deletedAt": null,
"indexed": true,
"integrationGenerated": false,
"sortOrder": 0,
"body": { "type": "markdown", "value": "Plenty of editors have learned to…", "sortOrder": 5 },
"slug": { "type": "string", "value": "a-preview-you-can-trust", "sortOrder": 1 }
/* …author, excerpt and published_date again, at the top level */
}
],
"relations": {},
"meta": { "total": 1, "totalPages": 1, "currentPage": 1, "hasNextPage": false,
"hasPrevPage": false, "limit": 50, "environment": "production",
"nestedLimit": 100, "nestedPage": 1, "depth": 0 }
}Three things to notice before anything else:
- Each field is an object. The content is its
value. - Every field appears twice: once under
data, and again at the top level of the entry. Read fromdata. Why. authorholds an id. Adddepth=1to get the author's entry, inrelations.
Keys
Send your key in the x-api-key header on every request. Nothing else
authenticates: not a query parameter, not Authorization.
curl https://cdn.capacms.com/v2/api/articles -H "x-api-key: $CAPA_KEY"A key belongs to one project and has an environment. The environment, and nothing in the request, decides what you can see.
| Key | Environment | Sees | Responses are |
|---|---|---|---|
pk_… | production | published entries, published data | cacheable for 60 seconds |
sk_… | anything else. The admin uses draft | every entry, the newest data, drafts included | never cached |
The prefix is set when the key is minted from its environment, so in practice
pk_ means production and sk_ means drafts. A key minted before the
prefixes existed has none: it is 40 hexadecimal characters, and it sees what
its environment allows, as the table says. meta.environment on a list
response tells you which one you sent.
A key's permission (read, write and so on) does not matter here. Any
active legacy key of the project, prefixed or not, can read every model.
Getting a key
curl -X POST https://api.capacms.com/v2/tenants/api-keys \
-H 'Authorization: Bearer <your session token>' \
-H 'content-type: application/json' \
-d '{"environment":"production","permission":"read"}'{ "apiKey": "pk_…", "environment": "production", "permission": "read" }This is an admin request, so it goes to api.capacms.com, as in
Keys and scopes, not to the CDN.
The answer is 201. Send "environment":"draft" for an sk_ key. Leave
scopes out: with it, the request can mint a cap_ key, and this API
refuses those.
On a project with scoped keys switched on, New key in the admin mints
cap_ keys only, so mint a pk_ or sk_ key with the request above. Rotate,
deactivate and origin-bind existing keys in the admin, or through the routes
in Keys and scopes.
What gets refused
| You send | You get |
|---|---|
no x-api-key header | 401 {"error":"API key required"} |
| an unknown, deactivated or expired key | 401 {"error":"Invalid API key"} |
a cap_ key from Keys and scopes | 401 {"error":"Invalid API key"}. Scoped keys work on /api/ only |
a key bound to origins, from an Origin it does not list | 403 {"error":"This API key is not allowed from https://evil.example.org"} |
| a key whose project has no active subscription | 402, see Limits |
The three 401 bodies for an unknown, deactivated and expired key are
identical on purpose. If a key that worked yesterday stops working, look at
the key under Developers > Keys, not at the response.
Origin binding reads Origin, or Referer when there is no Origin. A
request that carries neither still gets an answer: that is a server-side
fetch, and binding does not restrict it. Origin: null is refused.
Keys in the browser
A pk_ key reads published content only, which is why sites ship one in
client code. Anyone holding it can read every published entry of every model.
Treat an sk_ key as a secret: it reads every unpublished draft.
Every successful content response echoes the key it was sent with in an
API-Key response header. Keep that in mind before you log response headers
anywhere public.
CORS reflects the calling origin. A browser preflight for x-api-key answers
204 with Access-Control-Allow-Origin set to your origin and
Access-Control-Allow-Credentials: true.
A pk_ response does not vary on Origin, so the CDN caches the
Access-Control-Allow-Origin of the first site that asked and serves it to
the next. When two sites call from the browser with the same key, the second
one's requests can fail CORS until the cached copy expires. Give each site its
own key, or fetch on your server.
List entries
curl 'https://cdn.capacms.com/v2/api/articles?limit=2&page=2&sort=-published_date' \
-H "x-api-key: $CAPA_KEY"{namespace} is the model's namespace, matched exactly and case-sensitively.
/v2/api/Articles is 404 {"error":"Model not found"} when the model is
articles. There is no trailing slash: /v2/api/articles/ is a 404 route
error.
| Parameter | Default | Range | Notes |
|---|---|---|---|
limit | 50 | 1 to 500 | above 500 is 500. 0 or text is 50. A negative number is 1 |
page | 1 | 1 and up | 0 or text is 1 |
sort | none | see Sorting | |
ids | none | comma-separated entry ids, see Choosing entries by id | |
id | none | one entry id: switches the route to one entry | |
depth | 0 | 0 to 4 | above 4 is 4. See Related entries |
nestedLimit | 100 | 1 to 500 | items per array relation |
nestedPage | 1 | 1 and up | which slice of each array relation |
structure | relations | relations, tree | v2 only. Anything else is relations |
relationFilter.<field>.<field> | none | filters the items inside an array relation | |
relatedFilters | none | the same, as one JSON object | |
| any other name | none | a field filter if it names a field of the model, otherwise ignored |
Out-of-range numbers are corrected, never refused. Nothing on this surface
answers 400 for a bad limit, page or depth.
Paging
meta on a list tells you where you are. For the request above:
"meta": { "total": 6, "totalPages": 3, "currentPage": 2, "hasNextPage": true,
"hasPrevPage": true, "limit": 2, "environment": "production",
"nestedLimit": 100, "nestedPage": 1, "depth": 0 }total counts every entry that matches, across all pages. Walk pages until
hasNextPage is false, or fetch up to 500 at once with limit=500.
- A page past the end answers
200withdata: [].currentPagethen reports the last page that exists, not the one you asked for, andhasPrevPageisfalsefrom two pages past the end. - A filter that matches nothing answers
total: 0,totalPages: 0andcurrentPage: 0. - Send whole numbers.
meta.limitechoeslimit=1.5as1.5andtotalPagesis computed from it. The list reads one entry, or two when it is sorted.
Order
With no sort, entries come back in entry-id order. Ids are random, so for
real content that is an arbitrary but stable order, not creation order. If
order matters on your page, pass sort.
Read one entry
curl 'https://cdn.capacms.com/v2/api/61001acc-e032-48cb-aeff-0f2d0bb69e6b?depth=1' \
-H "x-api-key: $CAPA_KEY"When the path segment is an entry id, you get that entry. The answer has the
same shape as a list, with the entry as the only item in data, and a shorter
meta:
"meta": { "nestedLimit": 100, "nestedPage": 1, "depth": 1 }depth, nestedLimit, nestedPage, structure, relationFilter.*,
relatedFilters and the relationSort. form of sort apply. Every other
list parameter, and every field filter, is ignored.
The same read works as a query parameter on a namespace:
GET /v2/api/articles?id=61001acc-e032-48cb-aeff-0f2d0bb69e6bThat form also checks the entry belongs to articles.
A single entry carries more system keys than a list item: tenantId,
lastUpdatedBy, versionCount, currentVersionId, publishedVersionId,
deletedBy, deleted and folderId. A list removes them.
| You ask for | You get |
|---|---|
| an entry id that does not exist, is deleted, or belongs to another project | 404 {"error":"Model not found"} |
a draft-only entry, with a pk_ key | 404 {"error":"Model instance version not found"} |
?id= with a value that is not an entry of your project, another project's entry id included | 404 {"error":"Model instance version not found"} |
?id= naming an entry of a different model of your project | 404 {"error":"Model instance not found"} |
The first row answers "Model not found" because the path is tried as an entry id first and then as a model id.
Read a model by id
GET /v2/api/{modelId} is the list route with the model named by its id
instead of its namespace. Every list parameter works. It is useful when a
namespace might be renamed and the id will not.
Field types
curl https://cdn.capacms.com/v2/api/articles/types -H "x-api-key: $CAPA_KEY"{
"type": {
"title": "string",
"slug": "string",
"published_date": "date",
"excerpt": "string",
"author": "relation",
"body": "markdown"
}
}The keys are the fields' names as shown in the admin, not their namespaces.
In this model the two are the same. A field named "Published on" would appear
as "Published on". A relation array reports array, not its item type.
The namespace in the path is lowercased before the lookup, so
/v2/api/ARTICLES/types works. A model id in the path is not accepted. An
unknown model is 404 {"error":"Model not found"}. This route sends no cache
headers of its own.
The response
An entry
One entry of the authors model, from GET /v2/api/authors?slug=maya-lindqvist:
{
"id": "854e6620-778b-4750-a93c-556e2e1516c4",
"modelId": "858f79c2-69f1-4d2e-9724-b8115fd62d99",
"data": {
"bio": { "type": "markdown", "value": "Maya leads the web platform team…", "sortOrder": 3 },
"name": { "type": "string", "value": "Maya Lindqvist", "sortOrder": 1 },
"slug": { "type": "string", "value": "maya-lindqvist", "sortOrder": 2 },
"title": { "type": "string", "value": "Maya Lindqvist", "sortOrder": 0 }
},
"draft": null,
"createdAt": "2026-09-23T04:35:57.054Z",
"updatedAt": "2026-09-23T04:35:57.444Z",
"tags": [],
"title": { "type": "string", "value": "Maya Lindqvist", "sortOrder": 0 },
"deletedAt": null,
"indexed": true,
"integrationGenerated": false,
"sortOrder": 0,
"bio": { "type": "markdown", "value": "Maya leads the web platform team…", "sortOrder": 3 },
"name": { "type": "string", "value": "Maya Lindqvist", "sortOrder": 1 },
"slug": { "type": "string", "value": "maya-lindqvist", "sortOrder": 2 }
}| Key | Meaning |
|---|---|
id | the entry |
modelId | the model it belongs to |
data | the fields, by namespace. See Field values |
draft | a legacy column. Ignore it |
createdAt, updatedAt | ISO 8601, when the entry was created and last saved |
tags | the entry's own tags, an array of strings. [] when it has none |
title | a legacy column, replaced by your title field. See The top-level copy |
deletedAt | null in practice: deleted entries are never returned |
indexed | whether the entry is in the search index |
integrationGenerated | whether an integration, such as the Shopify sync, created it |
sortOrder | an integer the admin uses for manual ordering. 0 by default |
data is in storage order, not in the order of your model. Each field's
sortOrder is its position in the model, so sort on it to lay fields out.
The top-level copy
Every key of data is also copied onto the entry itself, after the system
keys. entry.data.slug and entry.slug are the same object.
The copy wins over a system key of the same name. The admin gives every model
a title field, so the top-level title is your field, not the legacy
column. A model with a tags field replaces the entry's own tags the same
way. A field named id, data or createdAt replaces those.
Read from data: it holds only your fields, and it is where
structure=tree leaves plain arrays intact. The exception
is a model with a field named data, whose top-level copy replaces it. Read
that model's fields from the top level.
The top-level copy is where one extra thing lives: the paging meta and
count of an array relation, when depth is at least 1. See
Array relations.
Field values
Entries saved from the Capa admin store each field as
{ "type", "value", "sortOrder" }. Read value, and ignore any key you do not
recognise: content imported by other means can carry fewer keys.
| Field type | value |
|---|---|
| string, markdown, html, code, enum, color | a string |
| number | a JSON number |
| true_false | true or false |
| date | the string you stored, for example "2026-08-18" |
| array | a JSON array |
| relation | the related entry's id, or null |
| array of relations | an array of entry ids |
| image, video, file | the media record, or null |
A field can also be null itself rather than an object. For example, a field
added to a model after an entry was saved can read null on that entry. Guard
the read: entry.data.excerpt?.value.
A media value is the file record as the admin stored it:
"cover": {
"type": "image",
"value": { "id": "00000000-0000-4000-8000-000000000051", "alt": "Logo",
"url": "https://cdn.capacms.com/file/brand/logo.png",
"name": "logo.png", "type": "image", "preview_url": null
/* …the rest of the file record */ },
"sortOrder": 9
}When an image or video value has an empty alt, the response fills it from
the alt text saved on the file in the media library.
Related entries
curl 'https://cdn.capacms.com/v3/api/articles?slug=a-preview-you-can-trust&depth=1' \
-H "x-api-key: $CAPA_KEY"depth=1 loads every entry your entries point at, into one map keyed by id.
The relation fields keep holding ids, and you look each id up in relations:
{
"data": [ { "id": "61001acc-…",
"data": { "author": { "type": "relation", "value": "854e6620-778b-4750-a93c-556e2e1516c4", "sortOrder": 4 }
/* … */ }
/* … */ } ],
"relations": {
"854e6620-778b-4750-a93c-556e2e1516c4": {
"id": "854e6620-778b-4750-a93c-556e2e1516c4",
"modelId": "858f79c2-69f1-4d2e-9724-b8115fd62d99",
"data": {
"bio": { "type": "markdown", "value": "Maya leads the web platform team…", "sortOrder": 3 },
"name": { "type": "string", "value": "Maya Lindqvist", "sortOrder": 1 },
"slug": { "type": "string", "value": "maya-lindqvist", "sortOrder": 2 },
"title": { "type": "string", "value": "Maya Lindqvist", "sortOrder": 0 }
},
"createdAt": "2026-09-23T04:35:57.054Z",
"updatedAt": "2026-09-23T04:35:57.444Z",
"tags": [],
"sortOrder": 0,
"modelType": "authors"
}
},
"meta": { /* … */ "depth": 1 }
}const res = await fetch(
"https://cdn.capacms.com/v3/api/articles?slug=a-preview-you-can-trust&depth=1",
{ headers: { "x-api-key": process.env.CAPA_KEY } },
).then((r) => r.json());
const article = res.data[0];
const author = res.relations[article.data.author.value];
console.log(author?.data.name.value); // "Maya Lindqvist"modelTypeis the related entry's model namespace.relationsis flat.depth=2adds the entries those entries point at, into the same map, not nested under their parents.- Each level costs another round of queries, and the map grows with every level. Ask for the depth your page actually renders.
- Related entries follow the same draft rule as the entries you asked for. A
pk_key never sees an unpublished related entry: it has no key inrelations. In a single relation its id stays in the field, so check for the key before you read it. In an array relation atdepth=1or more, its id is dropped fromvalue. - On
/v2/api, each related entry is the full stored row: it also carriestenantId,draft,lastUpdatedBy,versionCount,currentVersionId,publishedVersionId,title,deletedAt,deletedBy,deleted,folderId,indexed,integrationGenerated,modelInstanceVersions(the one version it serves, every field again),dataModel(the full model row) and_processedDepth./v3/apitrims each related entry to the keys shown above.
Array relations
When depth is at least 1, each array relation that holds at least one id
gains a meta and a count on the top-level copy. For coauthors, with
?depth=1&nestedLimit=1:
"coauthors": {
"type": "array",
"value": ["048497e0-5ec4-48fd-84d2-83e961824fc5"],
"sortOrder": 6,
"meta": { "total": 2, "page": 1, "limit": 1, "totalPages": 2,
"isPaginated": true, "isEmpty": false },
"count": 2
}The same field under data has the sliced value and no meta.
nestedLimit and nestedPage slice every array relation of every entry the
same way, one page at a time. There is no per-field paging.
meta.totalis the number of items the key can see, before slicing.countis the number of ids in the field, or the number a relation filter kept.isEmptyistruewhenvaluecame out empty although the field holds ids: a slice past the end, or items the key cannot see.relationsstill holds every related entry, including the ones outside the current slice.- At
depth=0nothing is sliced and there is nometa:nestedLimitandnestedPagedo nothing.
Filtering the items of an array relation
GET /v2/api/articles?depth=1&relationFilter.coauthors.name=Maya%20LindqvistThis keeps every article and removes from each article's coauthors the
authors whose name is not Maya Lindqvist. relations loses them too. It
never removes an article.
The same filter as one JSON value, {"coauthors.name":"Maya Lindqvist"},
URL-encoded:
GET /v2/api/articles?depth=1&relatedFilters=%7B%22coauthors.name%22%3A%22Maya%20Lindqvist%22%7DKeys are <relation field>.<field of the related model>, optionally with an
operator: {"coauthors.name[contains]":"*Lind*"}.
| Operator | Matches |
|---|---|
none, or equals | exactly |
notEquals | anything else |
contains | exactly, or as a substring when the value is *text*. a|b is either |
gt, gte, lt, lte | numbers and dates as numbers and dates, everything else as text |
range | min,max, inclusive |
Four rules decide whether a relation filter does anything:
- It needs
depth=1or more. Atdepth=0it clears every relation field of every entry: single relations becomenulland arrays become[]. - It applies to array relations only.
relationFilter.author.name=…on a single relation changes nothing. - It skips a relation field whose target model is recorded by id rather than by namespace. Nearly every field records the namespace.
- The nested JSON form
{"coauthors":{"name":"Maya Lindqvist"}}is not understood. It removes every item. Use the dotted key.
structure=tree
On /v2/api, structure=tree puts the related entries inside the fields that
point at them, and drops relations:
const res = await fetch(
"https://cdn.capacms.com/v2/api/articles?slug=a-preview-you-can-trust&depth=1&structure=tree",
{ headers: { "x-api-key": process.env.CAPA_KEY } },
).then((r) => r.json());
const article = res.data[0];
article.author.value.data.name.value; // "Maya Lindqvist": the top-level copy holds the entry
article.data.author.value; // "854e6620-…": data still holds the idIt is shallower than it looks:
- The nesting is on the top-level copy only.
datakeeps ids. - It nests one level. At
depth=2the second level is fetched and then lost, because there is norelationsmap to look it up in. - It empties every plain array on the top-level copy.
tags: ["news"]becomestags: [], because each item is looked up as a related entry and dropped when none is found. Read plain arrays fromdata. - At
depth=0it empties every array relation on the top-level copy too. - A related entry the key cannot see disappears from an array and stays an id in a single relation.
structure=tree on /v2/api/search?extended=true behaves differently again:
it replaces data itself, so the nesting is under data, the plain arrays
under data are emptied, and there is no top-level copy. /v3/api has no
tree format and ignores the parameter.
Sorting
GET /v2/api/articles?sort=-published_date,titleComma-separated field namespaces, - for descending. Numbers sort as
numbers, true_false fields as booleans, everything else as text in a
language-aware order. Entries with no value sort last in both directions.
| You write | You get |
|---|---|
sort=title | by your title field, A to Z |
sort=-published_date | newest date first, because ISO dates sort correctly as text |
sort=-featured,views | featured first, then fewest views |
sort=author.name | by a field of the entry a single relation points at |
sort=createdAt | nothing useful: system keys are not sortable, only fields |
A name that is not a field is not an error. The list comes back in an
arbitrary order instead. Entries with equal values also come back in no fixed
order, so add a field that is unique, such as slug, when you page through a
sort.
A sorted list shows the same data as the unsorted one. With a pk_ key that
is each entry's published data, and the list is ordered by the published
values, including the value sort=<relation>.<field> reads from the related
entry. An unpublished draft never appears and never moves an entry. An sk_
key sees, and sorts by, each entry's newest data.
Sorting the items of an array relation
GET /v2/api/articles?depth=1&sort=relationSort.coauthors.name
GET /v2/api/articles?depth=1&sort=-relationSort.coauthors.namerelationSort.<array relation>.<field> orders the ids inside that relation
field on every entry, and the slices that nestedLimit takes. The - for
descending goes in front of relationSort, not in front of the field.
- Only the first segment after
relationSort.and the last one are read.relationSort.a.b.namesortsabynameand ignoresb. - It applies wherever a relation field of that name appears, at every depth.
- A
sortparameter that contains onlyrelationSort.items still counts as a sorted list. The entries themselves come back in an arbitrary order. Add a plain sort in front if order matters:sort=-published_date,relationSort.coauthors.name. - It also orders the items a relation filter keeps.
Filtering
Any query parameter that names a field of the model is a filter. Filters are ANDed.
GET /v2/api/articles?slug=a-preview-you-can-trust
GET /v2/api/articles?author=854e6620-778b-4750-a93c-556e2e1516c4&published_date[gte]=2026-08-10<field>=<value> is equality. <field>[<operator>]=<value> picks an
operator. A parameter that names nothing on the model is ignored, so a typo
returns every entry instead of an error.
Looking up by slug
The most common read on this API is one entry by a field you control:
curl 'https://cdn.capacms.com/v2/api/articles?slug=a-preview-you-can-trust&limit=1&depth=2' \
-H "x-api-key: $CAPA_KEY"Read data[0]. When nothing matches, data is [] and the status is still
200, so test for an empty array rather than for a 404.
Values can contain /: ?slug=services/implants/all-on-4 matches that exact
string.
Equality
| You write | Matches |
|---|---|
slug=a-preview-you-can-trust | text fields equal to that string, case-sensitively |
featured=true | true_false fields that are true. Any other value, including 1, means false |
views=120 | nothing: equality does not convert numbers. Use views[equals]=120 |
title=*Preview* | text containing Preview, case-sensitively |
title=Can* or title=*Can | text containing Can anywhere. A * at either end means "contains", not "starts with", so title=Can* finds "A Preview You Can Trust" |
In a * match, % and _ inside the value are wildcards too: _ matches
any one character. A * match never matches an array field.
Operators
| Operator | Example | Matches |
|---|---|---|
equals | views[equals]=120 | equality, converting the value to a number on a number field |
not | title[not]=The%20Edit%20Is%20the%20Product | anything else. An entry without the field does not match |
gt, gte, lt, lte | views[gt]=100, published_date[gte]=2026-09-01 | numbers as numbers, dates and text as text |
range | published_date[range]=2026-08-10,2026-08-25 | between two values, inclusive. Anything but exactly two values is ignored |
contains | title[contains]=*Preview* | see below |
string_contains | title[string_contains]=Content | text containing the value, case-sensitively |
string_starts_with | title[string_starts_with]=The | text starting with the value |
string_ends_with | title[string_ends_with]=Tree | text ending with the value |
array_contains | tags[array_contains]=news | arrays holding the value |
array_starts_with, array_ends_with | tags[array_starts_with]=news | arrays whose first or last item is the value |
contains depends on the value and the field:
| You write | On a text field | On an array field |
|---|---|---|
[contains]=news | equal to news, not containing it | holds news |
[contains]=*news* | containing news | never matches |
[contains]=news,product | equal to both, which never matches | holds both |
[contains]=news|product | equal to either | holds either |
URL-encode | as %7C if your client does not.
Every other operator is a 500. That includes ne, in, nin,
notEquals, eq and exists. So is a non-number on a number field with
gt, gte, lt or lte, and a repeated [range] or string_* parameter.
Date comparisons are text comparisons of the stored string. They work for
dates stored as YYYY-MM-DD and send the same form.
filter[title][eq]=… is not part of this API. It names no field, so it is
ignored and every entry comes back.
A field whose namespace is also a parameter name (limit, page, sort,
id, ids, depth, structure, nestedLimit, nestedPage,
relatedFilters) cannot be filtered on. The parameter wins.
Filtering on a related entry's field
GET /v2/api/articles?author.name=Maya%20LindqvistThis does not select Maya's articles. It is all or nothing: when any entry in the list points at a related entry that matches, every entry that passes the other filters comes back. When none does, none comes back. The request above returns all six articles, two of them Maya's.
It also changes paging. total and totalPages then describe the current
page only, so hasNextPage is false and currentPage can be lower than the
page you asked for. Page on until a page comes back shorter than limit. The
order is not the default id order.
Only put a relation field before the dot. Any other name is not checked, and
what you get depends on your content: a 500 when your entries point at
entries of two or more models, otherwise a filter on the one related model.
To find the articles by one author, filter on the relation field itself, with
the author's id. For an array relation, use [contains]:
GET /v2/api/articles?author=854e6620-778b-4750-a93c-556e2e1516c4
GET /v2/api/articles?coauthors[contains]=854e6620-778b-4750-a93c-556e2e1516c4Choosing entries by id
GET /v2/api/articles?ids=f52b0204-9b6e-4e84-ba2c-15db014dad19,61001acc-e032-48cb-aeff-0f2d0bb69e6bids limits the list to those entries and combines with every other filter
and parameter.
- The order you list them in is not kept. Pass
sort, or reorder on your side. - An id that exists in another model or another project, or that a
pk_key cannot see, is skipped. - Any id that is not a UUID is
400 {"error":"One or more instance ids are invalid"}. So is a trailing comma. ids=with nothing after it is ignored.
Drafts
pk_ key | sk_ key | |
|---|---|---|
| Published entry | its published data | its newest data |
| Published entry with a newer draft | its published data | the draft |
| Draft-only entry | absent. One entry by id is 404 | the draft |
Related entries in relations | published only | newest, drafts included |
| Search | published entries, minus those with a pending draft | everything, newest text |
| Filters match | the published version | any version, including older ones |
| Caching | 60 seconds | none |
The last filter row means a draft key can return an entry whose current data no longer matches the filter, because an older version did.
v2 and v3
/v3/api is /v2/api with a lighter response. It serves the same data with
the same parameters, except:
/v2/api | /v3/api | |
|---|---|---|
structure=tree | supported | ignored, always relations |
Related entries in relations | the full stored row, including the version it serves and the model | id, modelId, data, createdAt, updatedAt, tags, sortOrder, modelType |
Extended search hits with depth | carry internal _ keys | clean |
sort=<relation>.<number field> | numeric | as text, so 10 sorts before 9 |
/{namespace}/types | answers even when the subscription is inactive | 402 when the subscription is inactive |
X-Response-Time header | sent | not sent |
The entries in a list or one-entry data, meta, filters, paging and every
error body are the same on both.
Caching
A pk_ response is built to be cached at the edge, and a draft never is.
| Header | pk_ key, 200 | sk_ key, 200 |
|---|---|---|
Cache-Control | public, max-age=60 | no-store, no-cache |
Surrogate-Control | max-age=…, stale-while-revalidate=…, stale-if-error=… | no-store |
Surrogate-Key | <key> api | absent |
Vary | x-api-key | Origin |
Cloudflare-CDN-Cache-Control | no-store | no-store |
These are the list and single-entry routes. /types, /search and every
error send no cache headers of their own.
What that means for your site:
- A browser or your own server may reuse a
pk_response for 60 seconds. - The CDN may keep it longer, for as long as
Surrogate-Controlsays, and separately for each key. The durations are server configuration, not a promise. Unconfigured,max-ageis 604800 seconds. This platform sendsmax-age=60, stale-while-revalidate=86400, stale-if-error=604800today. - When an entry is published, Capa purges every CDN copy that shows an entry of its model. After a publish, allow for the 60 seconds your side may still hold.
- The
<key>inSurrogate-Keyis fixed for one project, URL and set of query parameters. It is what the purge targets. - The CDN in front of the API removes
Surrogate-KeyandSurrogate-Controland replacesVarybefore the response reaches you. They are listed so you know they exist.
Other response headers on a content 200:
| Header | Value |
|---|---|
X-Tenant-Id | your project's id |
API-Key | the key you sent |
X-Request-ID | an id for this request. Quote it when you report a problem |
X-Response-Time | milliseconds, /v2/api only |
Limits
There is no per-key rate limit on /v2/api or /v3/api, and no
X-RateLimit-* header. Your plan's API call allowance is not checked on these
reads either.
The one check is that the project's subscription is active: active or
trialing, within its current billing period. A project on permanent free
access skips it. When it fails, every route except /v2/api/{namespace}/types
answers 402:
{ "success": false,
"error": "No active subscription found. Please subscribe to a plan to access this resource." }The error sentence names the reason: no subscription, canceled, past due,
unpaid, incomplete, paused, or a billing period that has not started or has
ended.
Errors
Every error is a JSON object with an error sentence. There are no error
codes. Branch on the status, and on data being empty for "nothing matched".
| Status | Body | When |
|---|---|---|
| 400 | {"error":"One or more instance ids are invalid"} | an ids value is not a UUID |
| 401 | {"error":"API key required"} | no x-api-key header |
| 401 | {"error":"Invalid API key"} | unknown, deactivated, expired, or a cap_ key |
| 402 | {"success":false,"error":"…"} | the subscription is not active |
| 403 | {"error":"This API key is not allowed from <origin>"} | an origin-bound key, from another origin |
| 403 | {"error":"Direct origin access is not allowed. Request this through the CDN hostname."} | the request reached Capa's servers without passing through the CDN |
| 404 | {"error":"Model not found"} | no model with that namespace or id, or no entry with that id |
| 404 | {"error":"Model instance version not found"} | an entry a pk_ key cannot see, or an ?id= that is not an entry of your project |
| 404 | {"error":"Model instance not found"} | ?id= names an entry of another model of your project |
| 404 | {"error":"Tenant not found"} | the key's project was deleted |
| 404 | {"message":"Route GET:/v2/api/articles/ not found","error":"Not Found","statusCode":404} | no such route: a trailing slash, an extra path segment, or POST, PUT, PATCH or DELETE |
| 500 | {"error":"Internal Server Error","details":{…}} | an unsupported operator, a bad filter value, or a fault on our side |
| 500 | {"error":"Failed to fetch search results","details":"…"} | search without q, or a search fault |
| 500 | {"error":"Failed to fetch model type definitions","details":{…}} | a /types fault |
Do not parse details. Its content is not part of the contract.
A filter that matches nothing, a page past the end, and a slug that does not
exist are all 200 with data: [].
Moving to the new API
/api/ is the dated, documented successor. Your legacy integration keeps
working unchanged, and you can move one page at a time.
- Entries is the read contract, and its section
Coming from
/v2/apior/v3/apiis the parameter-by-parameter translation. - GraphQL covers reading the same content through GraphQL.
- Keys and scopes explains why a new
cap_key works on/api/only, and how to run both keys while you move.
The legacy quirks on this page are the ones the new API was built to remove:
all-or-nothing related-field filters, 500s for unknown operators, and paging
totals that break under those filters.