Public read API (v1)
Generated from backend/src/public/api-docs.ts. Do not edit this file by hand — backend/test/public-api-docs.test.ts fails when it stops matching the routes the worker actually mounts.
Every endpoint below resolves the site from the Host header and from nothing else. There is no site parameter, no site header, and no way for a caller to select one — which is what makes a cross-site read structurally impossible rather than merely forbidden.
GET /health
Section titled “GET /health”Is this host serving a site. Answers only for a host this deployment knows. An unknown host is a 404 here exactly as it is everywhere else, so this cannot be used to discover which sites exist.
| Status | Meaning |
|---|---|
| 200 | This host serves a site, and its slug is in the body. |
| 404 | No site answers on this host. |
curl https://example.com/health{ "ok": true, "site": "acme" }GET /v1/:model
Section titled “GET /v1/:model”List published items of one model. Published items only. Ordering, filtering and pagination all happen in the database, so a page is never silently shorter than limit because something was dropped after the query.
| Parameter | Required | Meaning |
|---|---|---|
locale |
no | Which language to serve. Defaults to the site default. Whether a missing translation falls back or 404s is the SITE’s locale policy, not this parameter — a consumer cannot override it, deliberately. |
limit |
no | Page size. Capped by the server; asking for more returns the cap. |
cursor |
no | Opaque. Take it from next_cursor and send it back unchanged — its contents are not a contract and will change. |
order |
no | asc, desc — Which end of the list to read from, by publication date. desc is newest first and is the default. |
relation |
no | Filter by a relation field, as relation=field:id. Repeatable; repeats are ANDed, which is the only reading that lets a consumer narrow rather than widen. |
tag |
no | Filter by a tag field, as tag=field:value. Repeatable, ANDed. At most 16 relation and tag pairs per request, counted as repetitions: more is refused with 400 public_filter_too_many, which carries max. A request is never silently narrowed to fit. |
| Status | Meaning |
|---|---|
| 200 | A page of items in data, with page.limit, page.has_more and page.next_cursor beside it. |
| 400 | A parameter this endpoint does not accept, or a malformed value. |
| 404 | No such model on this site, this host serves no site, OR the model is private and no valid token was sent. One answer for all of them, deliberately: §7.1 keeps the public surface from leaking a site’s shape, and a 401 would confirm that the model exists. |
curl 'https://example.com/v1/article?locale=th&limit=10&order=desc'{ "data": [ { "id": "ci_…", "slug": "hello", "locale": "th", "fields": { … } } ], "page": { "limit": 10, "has_more": true, "next_cursor": "…" } }GET /v1/:model/:slug
Section titled “GET /v1/:model/:slug”Read one published item. One item, by slug. A slug is unique within a model on a site, never across sites — the site is decided by the host and by nothing a caller sends.
| Parameter | Required | Meaning |
|---|---|---|
locale |
no | Which language to serve. Defaults to the site default. Whether a missing translation falls back or 404s is the SITE’s locale policy, not this parameter — a consumer cannot override it, deliberately. |
preview |
no | A preview capability minted by the CMS. It serves the DRAFT of ONE item, expires in minutes, and is never cached. It does not make a private model readable, and a refused preview answers 404 rather than 403 so that a refusal confirms nothing. |
| Status | Meaning |
|---|---|
| 200 | The item, projected into the requested locale. |
| 400 | A parameter this endpoint does not accept. |
| 404 | No such item, not published, no content in a locale the policy will serve, a preview token that did not verify, or a private model with no valid token. One answer for all of them, deliberately (§7.1). |
curl 'https://example.com/v1/article/hello?locale=th'{ "data": { "id": "ci_…", "slug": "hello", "locale": "th", "fields": { … } } }Private models
Section titled “Private models”A model marked private is readable only with a site API token, sent as
Authorization: Bearer dw_site_…. Tokens are minted in the admin under
Settings → Public API, carry a scope naming which models they may read, and are
shown once.
A request with no token and a request with a wrong one get the SAME answer. That is deliberate: distinguishing them would tell a caller holding a guess that the model exists.
Caching
Section titled “Caching”By default this API answers no-store: nothing is cached at the edge, and every
read reaches the worker. That is the only default correct without a purge
credential.
Caching is opt-in. Setting PUBLIC_CACHE_MAX_AGE to a positive number of
seconds — on both the public and the admin Worker — turns it on: responses are
then cached and tagged, and publishing an item purges exactly the responses that
mention it. It needs a CF_PURGE_ZONE_ID and a CF_PURGE_API_TOKEN with
Zone → Cache Purge; without one, a change reports that it could not evict the
cache rather than hiding it.
Two things are never cached, whatever the TTL: a response to a request carrying a site API token, and a preview.
What is stable, and what is not
Section titled “What is stable, and what is not”STABLE within v1: the paths, the parameter names, the
meanings of the status codes, and the top-level shape of a response — data,
and, on the list route, a page object holding limit, has_more and
next_cursor. An item’s own keys are id, slug, model, locale,
version, published_at, fields, relations and media.
NOT stable, and do not parse: the contents of next_cursor, the exact text of
an error message, and the order of keys. A new optional parameter or a new field
inside an item may appear within v1; nothing already
documented here will change meaning without a new version in the path.
Consumer examples
Section titled “Consumer examples”Fetch, with the cursor loop written correctly
Section titled “Fetch, with the cursor loop written correctly”async function* everyArticle(origin, locale = 'en') { let cursor; do { const url = new URL(`${origin}/v1/article`); url.searchParams.set('locale', locale); url.searchParams.set('limit', '50'); // The cursor is opaque. Send back exactly what you were given; do not // decode it, and do not construct one. if (cursor) url.searchParams.set('cursor', cursor);
const response = await fetch(url); if (!response.ok) throw new Error(`${response.status} from ${url}`);
const body = await response.json(); yield* body.data; // Pagination lives in `page`, beside `data` rather than above it. cursor = body.page.has_more ? body.page.next_cursor : undefined; } while (cursor);}A private model
Section titled “A private model”curl -H "Authorization: Bearer $DEE_WAN_SITE_TOKEN" \ 'https://example.com/v1/pricing'A private model with no valid token answers 404, not 401 — the same answer as a model that does not exist. That is deliberate (§7.1): a 401 would confirm the model is there, and the public surface leaks nothing about a site’s shape. It also means a 404 cannot be read as “wrong URL”; check the token first.
Use the generated TypeScript client
Section titled “Use the generated TypeScript client”The authenticated admin API emits a dependency-free client for the selected site:
curl -H "X-Site-Id: $DEE_WAN_SITE_ID" -b "$DEE_WAN_SESSION_COOKIE" \ 'https://admin.example.com/api/models/public-client.ts' \ > src/dee-wan-public.tsIt contains the response envelope, locale union, exact field types, model-aware query types and one method per model. Regenerate it after a model changes; Dee Wan does not update consumer repositories.
If you call the endpoint without the generated client, type the response envelope and model fields directly:
type Item<F> = { id: string; slug: string; locale: string; fields: F };type Page<F> = { data: Item<F>[]; page: { limit: number; has_more: boolean; next_cursor: string | null };};
type Article = { title: string; body: string };
const page: Page<Article> = await (await fetch(url)).json();