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

Source: https://capacms.com/docs/legacy

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](https://capacms.com/docs/legacy/october-2026#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/`](https://capacms.com/docs/api) instead. See
[Moving to the new API](#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](#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

```bash
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`](#errors) for direct origin access.

```json
{
  "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](#the-top-level-copy).
* `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`.

```bash
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

```bash
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"}'
```

```json
{ "apiKey": "pk_…", "environment": "production", "permission": "read" }
```

This is an admin request, so it goes to `api.capacms.com`, as in
[Keys and scopes](https://capacms.com/docs/api/authentication), 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](https://capacms.com/docs/api/authentication).

### 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](https://capacms.com/docs/api/authentication) | `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](#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

```bash
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](#sorting)                                                          |
| `ids`                            | none        |                     | comma-separated entry ids, see [Choosing entries by id](#choosing-entries-by-id) |
| `id`                             | none        |                     | one entry id: switches the route to [one entry](#read-one-entry)                 |
| `depth`                          | `0`         | 0 to 4              | above 4 is 4. See [Related entries](#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](#filtering) 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:

```json
"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

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

```json
"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 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

```bash
curl https://cdn.capacms.com/v2/api/articles/types -H "x-api-key: $CAPA_KEY"
```

```json
{
  "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`:

```json
{
  "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](#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](#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`](#structuretree) 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](#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:

```json
"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

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

```json
{
  "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 }
}
```

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

```json
"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*"}`.

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

```js
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](#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 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.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](#filtering-the-items-of-an-array-relation)
  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:

```bash
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%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_` 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-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`:

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

```json
{ "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](https://capacms.com/docs/api/entries) is the read contract, and its section
  [Coming from `/v2/api` or `/v3/api`](https://capacms.com/docs/api/entries#coming-from-v2api-or-v3api)
  is the parameter-by-parameter translation.
* [GraphQL](https://capacms.com/docs/api/graphql) covers reading the same content through GraphQL.
* [Keys and scopes](https://capacms.com/docs/api/authentication) 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, `500`s for unknown operators, and paging
totals that break under those filters.
