Docs
Developer guides

Any language with fetch

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

View as Markdown

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

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

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.

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

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:

{ "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.

Choose, filter and sort

Four parameters cover most pages:

ParameterExampleWhat it does
selecttitle,slug,author(name)Return only these fields. author(name) expands the relation.
filter[...]filter[slug]=winter-field-guide-2026Equality, or filter[views][gte]=10 for an operator.
sort-publishedAt,titleUp to three keys. - means descending.
limit251 to 200 a page. The default is 25.

Any other parameter is refused with 400 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.

Read one entry

By id:

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:

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

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 and TypeScript.

In Python

Standard library only, Python 3.8 or later.

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.

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);
}
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:

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:

{ "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 seeIt usually means
401 missing_keyNo x-api-key header.
401 invalid_keyThe key is unknown, deactivated or expired. Check it under Developers > Keys.
403 scope_missingThe key cannot read this model.
403 origin_refusedA browser key bound to other origins.
404 model_not_foundNo model with that namespace. The hint lists the ones you can read.
402 subscription_requiredThe project has no active subscription.

Every code has its own page under Errors.