# Any language with fetch

Source: https://capacms.com/docs/guides/fetch

Read Capa from any stack with plain HTTP. curl first, then JavaScript and Python, with paging and error handling.

Capa's read API is plain HTTPS and JSON. Anything that can send a header can read your content. This guide uses `curl`, then the same calls in JavaScript and Python.

## Before you start

You need three things:

* **A key.** Create one under **Developers > Keys** in the admin. For a public site, a production `cap_live_` key with the **Read only** starting point is right. See [Keys](https://capacms.com/docs/concepts/keys).
* **The host.** Every read goes to `https://cdn.capacms.com`.
* **A version.** Send `Capa-Version: 2026-10-01`, or rely on your key's pin. See [Versions](https://capacms.com/docs/concepts/versions).

Keep the key in an environment variable, never in your code:

```bash
export CAPA_KEY=cap_live_...   # from your secret store
```

## Check the key

`GET /api/me` says what the key in your hand can do. Run it first, before you debug anything else.

```bash
curl https://cdn.capacms.com/api/me \
  -H "x-api-key: $CAPA_KEY" \
  -H 'Capa-Version: 2026-10-01'
```

Read `environment` (`production` sees published entries only), `scopes`, and `models`, the models this key can read.

## Read a list

```bash
curl -G https://cdn.capacms.com/api/entries/articles \
  -H "x-api-key: $CAPA_KEY" \
  -H 'Capa-Version: 2026-10-01' \
  --data-urlencode 'select=title,slug,author(name)' \
  --data-urlencode 'sort=-publishedAt' \
  --data-urlencode 'limit=10'
```

`articles` is the model's namespace. The response wraps entries in `data`, with paging in `page`:

```json
{ "data": [
    { "id": "…41", "model": "articles", "status": "published",
      "fields": { "title": "Winter Field Guide 2026: Layering Above Treeline",
                  "slug": "winter-field-guide-2026",
                  "author": { "id": "…07", "model": "authors", "status": "published",
                              "fields": { "name": "Ana Ruiz" } } } } ],
  "page": { "limit": 10, "hasNext": true, "next": "c1.eyJ2…", "hasPrev": false, "prev": null },
  "meta": { "version": "2026-10-01", "contract": 1, "environment": "production", "requestId": "req_0f3c…" } }
```

Your fields live under `fields`. `id`, `model` and `status` always come back. See [the entry shape](https://capacms.com/docs/api/entries#what-an-entry-looks-like).

## Choose, filter and sort

Four parameters cover most pages:

| Parameter     | Example                                | What it does                                                   |
| ------------- | -------------------------------------- | -------------------------------------------------------------- |
| `select`      | `title,slug,author(name)`              | Return only these fields. `author(name)` expands the relation. |
| `filter[...]` | `filter[slug]=winter-field-guide-2026` | Equality, or `filter[views][gte]=10` for an operator.          |
| `sort`        | `-publishedAt,title`                   | Up to three keys. `-` means descending.                        |
| `limit`       | `25`                                   | 1 to 200 a page. The default is 25.                            |

Any other parameter is refused with [`400 invalid_parameter`](https://capacms.com/docs/errors/invalid_parameter), so a typo never quietly returns the wrong entries. A parameter whose name starts with `_` or `utm_` is ignored, so cache busters and campaign tags are safe. The full grammar is in the [entries reference](https://capacms.com/docs/api/entries).

## Read one entry

By id:

```bash
curl -G https://cdn.capacms.com/api/entries/articles/<entryId> \
  -H "x-api-key: $CAPA_KEY" \
  --data-urlencode 'select=title,body,author(name)'
```

By slug, read a list of one:

```bash
curl -G https://cdn.capacms.com/api/entries/articles \
  -H "x-api-key: $CAPA_KEY" \
  --data-urlencode 'filter[slug]=winter-field-guide-2026' \
  --data-urlencode 'limit=1'
```

An id that does not exist, or that a production key cannot see, is [`404 entry_not_found`](https://capacms.com/docs/errors/entry_not_found). A filter that matches nothing is a `200` with an empty `data`.

## In JavaScript

This runs in Node 18 or later, Deno, Bun and the edge runtimes. It retries a `429` or `503` after `Retry-After`, and throws everything else with the request id.

```js
const BASE = "https://cdn.capacms.com";
const KEY = process.env.CAPA_KEY;

export async function capa(path, params = {}, attempt = 0) {
  const url = new URL(path, BASE);
  for (const [name, value] of Object.entries(params)) url.searchParams.set(name, String(value));

  const res = await fetch(url, {
    headers: { "x-api-key": KEY, "Capa-Version": "2026-10-01" },
  });

  if ((res.status === 429 || res.status === 503) && attempt < 3) {
    const wait = Number(res.headers.get("Retry-After") ?? 1);
    await new Promise((resolve) => setTimeout(resolve, wait * 1000));
    return capa(path, params, attempt + 1);
  }

  const body = await res.json().catch(() => null);
  if (!res.ok) {
    const error = body?.error ?? { code: "http_" + res.status, message: res.statusText };
    throw new Error(
      `${res.status} ${error.code}: ${error.message}` +
        (error.hint ? ` ${error.hint}` : "") +
        (body?.meta?.requestId ? ` (${body.meta.requestId})` : ""),
    );
  }
  return body;
}

const latest = await capa("/api/entries/articles", {
  select: "title,slug,author(name)",
  sort: "-publishedAt",
  limit: 10,
});

for (const article of latest.data) {
  console.log(article.fields.title, "by", article.fields.author?.fields?.name);
}
```

Prefer TypeScript? The SDK does all of this with types. See [the SDK](https://capacms.com/docs/sdk) and [TypeScript](https://capacms.com/docs/guides/typescript).

## In Python

Standard library only, Python 3.8 or later.

```python
import json
import os
import time
import urllib.error
import urllib.parse
import urllib.request

BASE = "https://cdn.capacms.com"
KEY = os.environ["CAPA_KEY"]


class CapaError(Exception):
    pass


def capa(path, params=None, attempt=0):
    url = BASE + path
    if params:
        url += "?" + urllib.parse.urlencode(params)
    request = urllib.request.Request(
        url, headers={"x-api-key": KEY, "Capa-Version": "2026-10-01"}
    )
    try:
        with urllib.request.urlopen(request) as response:
            return json.load(response)
    except urllib.error.HTTPError as err:
        if err.code in (429, 503) and attempt < 3:
            time.sleep(int(err.headers.get("Retry-After", "1")))
            return capa(path, params, attempt + 1)
        try:
            body = json.load(err)
        except ValueError:
            body = {}
        error = body.get("error", {})
        request_id = body.get("meta", {}).get("requestId", "")
        raise CapaError(
            f"{err.code} {error.get('code')}: {error.get('message')} "
            f"{error.get('hint') or ''} ({request_id})"
        ) from None


latest = capa(
    "/api/entries/articles",
    {"select": "title,slug,author(name)", "sort": "-publishedAt", "limit": 10},
)
for article in latest["data"]:
    print(article["fields"]["title"])
```

## Page through everything

There are no page numbers. Follow `page.next` until `hasNext` is `false`. Keep the same `sort` on every page: a cursor is tied to the sort it was made with.

```js
export async function* allEntries(namespace, params = {}) {
  let after;
  do {
    const page = await capa(`/api/entries/${namespace}`, after ? { ...params, after } : params);
    yield* page.data;
    after = page.page.next;
  } while (after);
}

for await (const article of allEntries("articles", { select: "title,slug", limit: 200 })) {
  console.log(article.fields.slug);
}
```

```python
def all_entries(namespace, params=None):
    params = dict(params or {})
    while True:
        page = capa(f"/api/entries/{namespace}", params)
        yield from page["data"]
        if not page["page"]["hasNext"]:
            return
        params["after"] = page["page"]["next"]


for article in all_entries("articles", {"select": "title,slug", "limit": 200}):
    print(article["fields"]["slug"])
```

With `curl`, the same loop:

```bash
url='https://cdn.capacms.com/api/entries/articles?limit=200&sort=title&select=title,slug'
while [ -n "$url" ]; do
  body=$(curl -s "$url" -H "x-api-key: $CAPA_KEY")
  echo "$body" | jq -c '.data[] | .fields.slug'
  next=$(echo "$body" | jq -r '.page.next // empty')
  url=$([ -n "$next" ] && echo "https://cdn.capacms.com/api/entries/articles?limit=200&sort=title&select=title,slug&after=$next")
done
```

Need a total? Add `count=true` and read `page.total`. It costs a second pass, so leave it off when you do not show the number.

## Handle errors

Every error on `/api/` has one shape:

```json
{ "error": { "type": "invalid_request", "code": "unknown_field",
             "message": "articles has no field \"subtitle\" in contract 1.",
             "param": "select",
             "hint": "Fields: title, slug, excerpt, body, cover, author, tags. System keys: id, model, status, createdAt, updatedAt, publishedAt, version, folder, $tags.",
             "docs": "https://docs.capacms.com/errors/unknown_field" },
  "meta": { "version": "2026-10-01", "contract": 1, "requestId": "req_0f3c…" } }
```

* Branch on `error.code`. It is stable. The message is for people.
* Read `error.hint`. It usually names the fix: the fields you could have asked for, or the scope your key lacks.
* Log `meta.requestId`. Quote it when you ask for help.
* Retry only `429` and `503`, after `Retry-After`. Everything else fails the same way twice.

| You see                                                           | It usually means                                                              |
| ----------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| [`401 missing_key`](https://capacms.com/docs/errors/missing_key)                     | No `x-api-key` header.                                                        |
| [`401 invalid_key`](https://capacms.com/docs/errors/invalid_key)                     | The key is unknown, deactivated or expired. Check it under Developers > Keys. |
| [`403 scope_missing`](https://capacms.com/docs/errors/scope_missing)                 | The key cannot read this model.                                               |
| [`403 origin_refused`](https://capacms.com/docs/errors/origin_refused)               | A browser key bound to other origins.                                         |
| [`404 model_not_found`](https://capacms.com/docs/errors/model_not_found)             | No model with that namespace. The hint lists the ones you can read.           |
| [`402 subscription_required`](https://capacms.com/docs/errors/subscription_required) | The project has no active subscription.                                       |

Every code has its own page under [Errors](https://capacms.com/docs/errors).

## Related

* [Entries reference](https://capacms.com/docs/api/entries) for every parameter and operator.
* [GraphQL](https://capacms.com/docs/api/graphql) for the same reads as a typed schema.
* [Caching](https://capacms.com/docs/concepts/caching) for what the CDN keeps and when it purges.
