Docs

Legacy API: /v2/api and /v3/api

The frozen read API existing sites use: keys, routes, parameters, the response shape, caching and errors.

View as Markdown

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

RouteWhat 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}/typesthe model's field names and types
GET /v2/api/searchfull-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 from data. Why.
  • author holds an id. Add depth=1 to get the author's entry, in relations.

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.

KeyEnvironmentSeesResponses are
pk_…productionpublished entries, published datacacheable for 60 seconds
sk_…anything else. The admin uses draftevery entry, the newest data, drafts includednever 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 sendYou get
no x-api-key header401 {"error":"API key required"}
an unknown, deactivated or expired key401 {"error":"Invalid API key"}
a cap_ key from Keys and scopes401 {"error":"Invalid API key"}. Scoped keys work on /api/ only
a key bound to origins, from an Origin it does not list403 {"error":"This API key is not allowed from https://evil.example.org"}
a key whose project has no active subscription402, 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.

ParameterDefaultRangeNotes
limit501 to 500above 500 is 500. 0 or text is 50. A negative number is 1
page11 and up0 or text is 1
sortnonesee Sorting
idsnonecomma-separated entry ids, see Choosing entries by id
idnoneone entry id: switches the route to one entry
depth00 to 4above 4 is 4. See Related entries
nestedLimit1001 to 500items per array relation
nestedPage11 and upwhich slice of each array relation
structurerelationsrelations, treev2 only. Anything else is relations
relationFilter.<field>.<field>nonefilters the items inside an array relation
relatedFiltersnonethe same, as one JSON object
any other namenonea 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 200 with data: []. currentPage then reports the last page that exists, not the one you asked for, and hasPrevPage is false from two pages past the end.
  • A filter that matches nothing answers total: 0, totalPages: 0 and currentPage: 0.
  • Send whole numbers. meta.limit echoes limit=1.5 as 1.5 and totalPages is 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-0f2d0bb69e6b

That 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 forYou get
an entry id that does not exist, is deleted, or belongs to another project404 {"error":"Model not found"}
a draft-only entry, with a pk_ key404 {"error":"Model instance version not found"}
?id= with a value that is not an entry of your project, another project's entry id included404 {"error":"Model instance version not found"}
?id= naming an entry of a different model of your project404 {"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 }
}
KeyMeaning
idthe entry
modelIdthe model it belongs to
datathe fields, by namespace. See Field values
drafta legacy column. Ignore it
createdAt, updatedAtISO 8601, when the entry was created and last saved
tagsthe entry's own tags, an array of strings. [] when it has none
titlea legacy column, replaced by your title field. See The top-level copy
deletedAtnull in practice: deleted entries are never returned
indexedwhether the entry is in the search index
integrationGeneratedwhether an integration, such as the Shopify sync, created it
sortOrderan 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 typevalue
string, markdown, html, code, enum, colora string
numbera JSON number
true_falsetrue or false
datethe string you stored, for example "2026-08-18"
arraya JSON array
relationthe related entry's id, or null
array of relationsan array of entry ids
image, video, filethe 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.

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"
  • modelType is the related entry's model namespace.
  • relations is flat. depth=2 adds 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 in relations. In a single relation its id stays in the field, so check for the key before you read it. In an array relation at depth=1 or more, its id is dropped from value.
  • On /v2/api, each related entry is the full stored row: it also carries tenantId, 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/api trims 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.total is the number of items the key can see, before slicing. count is the number of ids in the field, or the number a relation filter kept.
  • isEmpty is true when value came out empty although the field holds ids: a slice past the end, or items the key cannot see.
  • relations still holds every related entry, including the ones outside the current slice.
  • At depth=0 nothing is sliced and there is no meta: nestedLimit and nestedPage do nothing.

Filtering the items of an array relation

GET /v2/api/articles?depth=1&relationFilter.coauthors.name=Maya%20Lindqvist

This 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%7D

Keys are <relation field>.<field of the related model>, optionally with an operator: {"coauthors.name[contains]":"*Lind*"}.

OperatorMatches
none, or equalsexactly
notEqualsanything else
containsexactly, or as a substring when the value is *text*. a|b is either
gt, gte, lt, ltenumbers and dates as numbers and dates, everything else as text
rangemin,max, inclusive

Four rules decide whether a relation filter does anything:

  • It needs depth=1 or more. At depth=0 it clears every relation field of every entry: single relations become null and 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 id

It is shallower than it looks:

  • The nesting is on the top-level copy only. data keeps ids.
  • It nests one level. At depth=2 the second level is fetched and then lost, because there is no relations map to look it up in.
  • It empties every plain array on the top-level copy. tags: ["news"] becomes tags: [], because each item is looked up as a related entry and dropped when none is found. Read plain arrays from data.
  • At depth=0 it 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,title

Comma-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 writeYou get
sort=titleby your title field, A to Z
sort=-published_datenewest date first, because ISO dates sort correctly as text
sort=-featured,viewsfeatured first, then fewest views
sort=author.nameby a field of the entry a single relation points at
sort=createdAtnothing 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.name

relationSort.<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.name sorts a by name and ignores b.
  • It applies wherever a relation field of that name appears, at every depth.
  • A sort parameter that contains only relationSort. 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 writeMatches
slug=a-preview-you-can-trusttext fields equal to that string, case-sensitively
featured=truetrue_false fields that are true. Any other value, including 1, means false
views=120nothing: equality does not convert numbers. Use views[equals]=120
title=*Preview*text containing Preview, case-sensitively
title=Can* or title=*Cantext 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

OperatorExampleMatches
equalsviews[equals]=120equality, converting the value to a number on a number field
nottitle[not]=The%20Edit%20Is%20the%20Productanything else. An entry without the field does not match
gt, gte, lt, lteviews[gt]=100, published_date[gte]=2026-09-01numbers as numbers, dates and text as text
rangepublished_date[range]=2026-08-10,2026-08-25between two values, inclusive. Anything but exactly two values is ignored
containstitle[contains]=*Preview*see below
string_containstitle[string_contains]=Contenttext containing the value, case-sensitively
string_starts_withtitle[string_starts_with]=Thetext starting with the value
string_ends_withtitle[string_ends_with]=Treetext ending with the value
array_containstags[array_contains]=newsarrays holding the value
array_starts_with, array_ends_withtags[array_starts_with]=newsarrays whose first or last item is the value

contains depends on the value and the field:

You writeOn a text fieldOn an array field
[contains]=newsequal to news, not containing itholds news
[contains]=*news*containing newsnever matches
[contains]=news,productequal to both, which never matchesholds both
[contains]=news|productequal to eitherholds 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.

GET /v2/api/articles?author.name=Maya%20Lindqvist

This 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-556e2e1516c4

Choosing entries by id

GET /v2/api/articles?ids=f52b0204-9b6e-4e84-ba2c-15db014dad19,61001acc-e032-48cb-aeff-0f2d0bb69e6b

ids 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_ keysk_ key
Published entryits published dataits newest data
Published entry with a newer draftits published datathe draft
Draft-only entryabsent. One entry by id is 404the draft
Related entries in relationspublished onlynewest, drafts included
Searchpublished entries, minus those with a pending drafteverything, newest text
Filters matchthe published versionany version, including older ones
Caching60 secondsnone

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=treesupportedignored, always relations
Related entries in relationsthe full stored row, including the version it serves and the modelid, modelId, data, createdAt, updatedAt, tags, sortOrder, modelType
Extended search hits with depthcarry internal _ keysclean
sort=<relation>.<number field>numericas text, so 10 sorts before 9
/{namespace}/typesanswers even when the subscription is inactive402 when the subscription is inactive
X-Response-Time headersentnot 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.

Headerpk_ key, 200sk_ key, 200
Cache-Controlpublic, max-age=60no-store, no-cache
Surrogate-Controlmax-age=…, stale-while-revalidate=…, stale-if-error=…no-store
Surrogate-Key<key> apiabsent
Varyx-api-keyOrigin
Cloudflare-CDN-Cache-Controlno-storeno-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-Control says, and separately for each key. The durations are server configuration, not a promise. Unconfigured, max-age is 604800 seconds. This platform sends max-age=60, stale-while-revalidate=86400, stale-if-error=604800 today.
  • 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> in Surrogate-Key is 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-Key and Surrogate-Control and replaces Vary before the response reaches you. They are listed so you know they exist.

Other response headers on a content 200:

HeaderValue
X-Tenant-Idyour project's id
API-Keythe key you sent
X-Request-IDan id for this request. Quote it when you report a problem
X-Response-Timemilliseconds, /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".

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

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.