Any language with 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. - 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 storeCheck 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:
| 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, 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")
doneNeed 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
429and503, afterRetry-After. Everything else fails the same way twice.
| You see | It usually means |
|---|---|
401 missing_key | No x-api-key header. |
401 invalid_key | The key is unknown, deactivated or expired. Check it under Developers > Keys. |
403 scope_missing | The key cannot read this model. |
403 origin_refused | A browser key bound to other origins. |
404 model_not_found | No model with that namespace. The hint lists the ones you can read. |
402 subscription_required | The project has no active subscription. |
Every code has its own page under Errors.
Related
- Entries reference for every parameter and operator.
- GraphQL for the same reads as a typed schema.
- Caching for what the CDN keeps and when it purges.