Public read API (v1)
The public read API serves published content to your site. Every endpoint below resolves the site from the Host header and from nothing else. There is no site parameter or site header for selecting another site. Site tokens remain bound to the resolved site. An explicit shared-content grant can add a source-owned published public projection; it never permits arbitrary source-site queries or source editing.
This API only reads. To create, edit, publish or upload from a script or an AI agent, use the CMS management API with a management credential.
Hostnames, CORS and rate limits
Section titled “Hostnames, CORS and rate limits”The public API runs as its own Worker. It serves a site on the hostname stored as
that site’s domain: the Host header, lowercased and without a port, must equal
it exactly. There is no www aliasing and no fallback to another site.
Browser origins
Section titled “Browser origins”Under Settings → API → Browser origins, an infrastructure administrator can
allow website origins to read this live API directly. Enter one complete HTTP(S)
origin per line, without a trailing slash or path: https://www.example.com or
http://localhost:5173. The default empty list allows no cross-origin browser
reads. * allows public reads from every origin. It never enables browser
cookies; no response grants credentialed CORS. Keep private site API tokens on
your server.
Allowed origins receive CORS headers on successful reads and errors. An
OPTIONS preflight accepts only GET, HEAD and OPTIONS, with
Authorization, Content-Type, X-Site-Key, X-Site-Token and
X-Dee-Wan-Preview-Challenge headers. Unknown origins receive no allow-origin
header. Every request still resolves the site by hostname and checks its normal
publication and token rules. This setting affects only the live /v1 reader;
it does not change the admin, management or generated application API.
Server-side and build-time calls do not need CORS configuration.
The Worker applies no rate limit of its own. If you need one, configure it on the Cloudflare zone that serves the public hostname.
GET /v1/_sync
Section titled “GET /v1/_sync”Synchronize published content. Read a bounded initial snapshot, then ordered entry and public-media changes with signed checkpoints. Existing scoped site tokens and CORS apply.
| Parameter | Required | Meaning |
|---|---|---|
cursor |
no | Unmodified next_cursor or checkpoint from the previous page. |
limit |
no | Page size 1–100; default 50. |
model |
no | Restrict entries to one authorized model slug. |
locale |
no | Restrict entries to one locale. |
| Status | Meaning |
|---|---|
| 200 | Snapshot or ordered changes, plus continuation or checkpoint. |
| 400 | sync_query_invalid: invalid or unauthorized model/query. |
| 404 | Unknown host or invalid site token. |
| 409 | reset_required: start a new snapshot. |
| 503 | sync_dependency_unavailable: retry the same checkpoint. |
GET /v1/_sync?limit=50{ "data": [], "deleted": [], "changes": [], "phase": "entries", "next_cursor": "opaque", "checkpoint": null, "reset_reason": null }GET /health
Section titled “GET /health”Check whether this host serves a site. Answers only for a host this deployment knows. An unknown host is a 404 here, as it is on every other route, so this route cannot be used to discover which sites exist.
| Status | Meaning |
|---|---|
| 200 | This host serves a site. The body carries the site’s slug. |
| 404 | unknown_host: no active 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, ordered by publication time and then by id unless sort names a field. Locale membership, filters and pagination are handled in the database. So a page is never shorter than limit because something was dropped afterwards. A page shorter than limit with has_more: false is the end of the list.
| Parameter | Required | Meaning |
|---|---|---|
locale |
no | Which language to serve, as a code matching ^[A-Za-z0-9_-]{1,64}$. Defaults to the site’s default language. What happens when an item has no content in that language is decided by the site’s locale policy, not by this parameter. See Locales below. |
origin |
no | Latitude,longitude for nearest point sorting; required with sort on a point field. |
limit |
no | Page size, a whole number from 1 to 100. Default 20. Anything else, including a value over 100, is refused with 400 public_filter_invalid. It is never clamped. |
cursor |
no | Opaque. Take it from page.next_cursor and send it back unchanged, with exactly the query it was issued under: the same model path, locale, order or sort, filters and their values, fields and expand. Only limit may change. A cursor sent with any other query is refused with 400 public_cursor_mismatch. One that does not decode, or one issued before cursors carried their query, is refused with 400 public_filter_invalid. Start again from the first page. Its contents are not a contract and will change. |
order |
no | asc, desc, manual — Direction by publication time. desc (newest first) is the default. manual requests the collection-wide editorial order when enabled; it is shared across languages. Do not use order with sort. A manual-order cursor is refused after that order changes; restart from the first page. |
filter |
no | filter[<field>]=<value> keeps items whose field equals the value in the served locale. Repeat it for alternatives: filter[kind]=guide&filter[kind]=news keeps items whose kind is either, at most 10 values per field. Text, slug and select fields compare text. A boolean field takes true or false. A number field takes a decimal number. A date field takes YYYY-MM-DD and compares the calendar day. On number and date fields, filter[<field>][gt] and [lt] are exclusive bounds, [gte] and [lte] inclusive ones. A field takes one lower bound (gt or gte) and one upper bound (lt or lte), never both kinds of one side and never with equality. A range no value can satisfy, such as [gt]=5&[lt]=5, is public_filter_invalid. Different fields are ANDed, at most 4 fields per request. Each value is at most 255 characters. More fields is public_filter_too_many, more values public_filter_too_many_values, a field whose type cannot be filtered this way public_filter_type_unsupported, an unknown field or a malformed value public_filter_invalid. A request is never narrowed. A text value longer than 255 characters is never matched or sorted on. |
sort |
no | One number, date, text or select field: sort=<field> ascending, sort=-<field> descending, then by id. Items without a value of that type come last in both directions. Do not use it with order. |
fields |
no | Comma-separated top-level field keys to return, at most 32, no repeats. id, slug and locale are always returned. |
relation |
no | Filter by a relation field, as relation=<field>:<target item id>. Keeps items whose relation field serves that target in relations. A target this request cannot read never matches. <field> must be a relation field of this model that accepts one model; a field that accepts several is refused with public_filter_type_unsupported. You can repeat this parameter. Repeated parameters are ANDed. |
tag |
no | Filter by a relation field, matched by the target’s slug: tag=<field>:<target slug>. Only a target the item serves in relations matches, by the published address served there: in the language the item is served in, then along the site’s fallback chain. Under strict, a translated target with no published version in that language does not match. An untranslated target matches in any language. There is no separate tag field type. <field> must be a relation field of this model that accepts one model; a field that accepts several is refused with public_filter_type_unsupported. You can repeat this parameter. Repeated parameters are ANDed. At most 16 relation and tag pairs per request in total. More is refused with 400 public_filter_too_many, whose body carries max. A request is never silently narrowed to fit. |
expand |
no | For ordinary reads and older preview links without the related-draft grant: Comma-separated relation fields of this model, at most 3, no repeats. Each item then carries expanded.<field>: { data, page }, where data holds the targets as items of the field’s target model, in the field’s order. Only published targets of this site that this request may read are served, in the requested locale under the site’s locale policy, the same as a read of the target model. A private target model is served only on a private model’s read whose token names it. At most 25 targets per field are inline; when there are more, page.next_cursor reads the rest from /v1/_related/:model. One level only: an expanded item carries relations, never expanded. A dotted path is refused with public_expand_too_deep, more fields with public_expand_too_many, and a field that is not a relation field of this model, or that accepts several models, with public_expand_invalid. New shared preview links automatically expand authorized saved drafts instead; see draft previews. |
| Status | Meaning |
|---|---|
| 200 | A page of items in data, with page.limit, page.has_more and page.next_cursor beside it. next_cursor is null on the last page. |
| 400 | public_filter_invalid: an unknown parameter, an empty value, a repeated parameter that is not relation, tag or filter[<field>], or a malformed value. This reason also covers a relation or tag field that is not a relation field of this model, or a cursor that does not decode. public_filter_too_many, public_filter_too_many_values: past a cap, which max names. public_filter_type_unsupported: a field filter on a type that cannot take it. public_cursor_mismatch: a cursor from another query. public_expand_invalid, public_expand_too_many, public_expand_too_deep: see expand. |
| 404 | No site on this host (unknown_host), or no model with this slug, or a private model and no valid site API token. The last two give the same body with no reason, so a caller cannot tell a private model from a missing one. |
| 500 | site_locale_policy_invalid: the site has no usable locale policy stored. An administrator has to set one under Settings → Languages. An unexpected failure returns a plain-text Internal Server Error. |
curl 'https://example.com/v1/article?locale=th&limit=10&order=desc'{ "contracts": { "page": { "served": 5 } }, "data": [ { "id": "ci_…", "slug": "hello", "model": "article", "locale": "th", "version": 3, "published_at": "2026-09-01T08:00:00.000Z", "fields": { "title": "สวัสดี" }, "relations": {}, "media": {} } ], "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 the slug of its published version. Each language has its own slug: /v1/post/bonjour?locale=fr serves the French language of the entry whose English slug is hello, and a language answers only at its own slug. A slug is unique within a model and a language on a site, never across sites. Renaming a draft does not move a published address until the rename is published. The item carries alternates: [{ locale, slug }], every language the entry is live in with that language’s own slug, the served one included.
| Parameter | Required | Meaning |
|---|---|---|
locale |
no | Which language to serve, as a code matching ^[A-Za-z0-9_-]{1,64}$. Defaults to the site’s default language. What happens when an item has no content in that language is decided by the site’s locale policy, not by this parameter. See Locales below. |
preview |
no | A preview token (dw_prev_…) minted by the CMS when an editor opens a preview. It serves the current DRAFT of ONE root item, matched against the draft’s slug, for 30 minutes. A served preview is never cached. It does not make a private model readable. A token that does not verify answers 404, not 403. New shared links also read authorized related saved drafts and recheck their creator’s current rights; the root field and locale scope stays within the recorded grant. See draft previews. |
expand |
no | For ordinary reads and older preview links without the related-draft grant: Comma-separated relation fields of this model, at most 3, no repeats. Each item then carries expanded.<field>: { data, page }, where data holds the targets as items of the field’s target model, in the field’s order. Only published targets of this site that this request may read are served, in the requested locale under the site’s locale policy, the same as a read of the target model. A private target model is served only on a private model’s read whose token names it. At most 25 targets per field are inline; when there are more, page.next_cursor reads the rest from /v1/_related/:model. One level only: an expanded item carries relations, never expanded. A dotted path is refused with public_expand_too_deep, more fields with public_expand_too_many, and a field that is not a relation field of this model, or that accepts several models, with public_expand_invalid. New shared preview links automatically expand authorized saved drafts instead; see draft previews. |
| Status | Meaning |
|---|---|
| 200 | The item, projected into one locale. |
| 400 | public_filter_invalid: a parameter other than locale, preview and expand, a repeated or empty parameter, or a malformed locale. public_expand_invalid, public_expand_too_many, public_expand_too_deep: see expand. |
| 404 | unknown_host: no site on this host. locale_unavailable: the item is published but has no content the site’s locale policy will serve. ambiguous_slug: two published items claim this slug, so neither is served. No reason: no such model, no published item with this slug, or a preview token that did not verify. A private model without a valid token also returns this response. |
| 422 | Preview of a page only: the draft page’s media cannot be presented. reason is one of unreadable_page, page_media_unavailable, media_presentation_unreadable, media_presentation_revision_unavailable, media_presentation_mismatch, media_size_missing, and error carries a readable message. |
| 500 | site_locale_policy_invalid: the site has no usable locale policy stored. An administrator has to set one under Settings → Languages. An unexpected failure returns a plain-text Internal Server Error. |
curl 'https://example.com/v1/article/hello?locale=th'{ "contracts": { "page": { "served": 5 } }, "data": { "id": "ci_…", "slug": "hello", "model": "article", "locale": "th", "version": 3, "published_at": "2026-09-01T08:00:00.000Z", "fields": { "title": "สวัสดี" }, "relations": {}, "media": {} } }GET /v1/_related/:model
Section titled “GET /v1/_related/:model”Read the rest of an expanded relation field. The targets of one expanded relation field past the first page. :model is the model of the item that carried the field. The cursor names that item, its published version, the field and the locale. The same rules as expand decide what is served. The cursor works only on this site, for this model, and for a token with the same model scope as the request that minted it. A private model needs its token here too.
| Parameter | Required | Meaning |
|---|---|---|
cursor |
yes | The expanded.<field>.page.next_cursor of an item, or the page.next_cursor of a previous answer here. Send it back unchanged. One from another site, model or token scope is refused with public_cursor_mismatch. After the item is published again, it is refused with public_cursor_stale: read the item again. |
limit |
no | Page size, a whole number from 1 to 100. Default 25. |
| Status | Meaning |
|---|---|
| 200 | A page of related items in data, with page.limit, page.has_more and page.next_cursor. |
| 400 | public_filter_invalid: no cursor, a parameter other than cursor and limit, a repeated or empty one, a bad limit, or a cursor that does not decode. public_cursor_mismatch: a cursor from another site, model or token scope. public_cursor_stale: the item was published again, or the field is no longer an expandable relation field. |
| 404 | unknown_host: no site on this host. No reason: no such model, a private model without a valid token, or the item is no longer published in that locale. |
| 500 | site_locale_policy_invalid: the site has no usable locale policy stored. An administrator has to set one under Settings → Languages. An unexpected failure returns a plain-text Internal Server Error. |
curl 'https://example.com/v1/_related/article?cursor=…&limit=25'{ "contracts": { "page": { "served": 5 } }, "data": [ { "id": "ci_…", "slug": "ana", "model": "author", "locale": "en", … } ], "page": { "limit": 25, "has_more": false, "next_cursor": null } }GET /v1/_client
Section titled “GET /v1/_client”Download the typed TypeScript client. The dependency-free client for this host’s site, with the schema identity it was generated from. Needs a site API token, even when every model is public. The client covers every public model and the private models the token may read. schema_identity is a SHA-256 digest of contract: locales, locale policy, and each model’s visibility and field types. Labels and field order do not change it. source starts with a comment recording the same values. The Settings → API command reads this route. The response is never cached.
| Status | Meaning |
|---|---|
| 200 | data holds contract_version (1), schema_identity, site, url, models, contract and source. |
| 400 | public_filter_invalid: the request carried a query string. |
| 404 | No site on this host (unknown_host), or no valid site API token for this site. A missing, expired, revoked or other-site token gets the same body with no reason. |
curl -H "Authorization: Bearer $DEE_WAN_SITE_TOKEN" https://example.com/v1/_client{ "data": { "contract_version": 1, "schema_identity": "sha256:9f2c…", "site": "acme", "url": "https://example.com", "models": ["article"], "contract": { "site": "acme", "default_locale": "en", "locales": ["en", "th"], "locale_policy": "strict", "models": { "article": { "visibility": "public", "fields": { "title": { "type": "text", "required": true, "multiple": false, "options": null } } } } }, "source": "/* @dee-wan/public-client …" } }GET /v1/_redirects
Section titled “GET /v1/_redirects”Read the accepted redirect list. The redirect rules in the site’s accepted Site Chrome: manual ones and the ones a publication added when it moved a live address. A renderer reads this when a path matches no page. match: "exact" names one path; match: "pattern" binds {name} segments and a final {name*} that to may reuse. An exact rule wins over a pattern, and the pattern with the most literal segments wins over the others. status is 301, 302, 307 or 308 with a to (a path on this site or an https address), or 410 (gone) or 451 (unavailable for legal reasons) with to: null. An unaccepted Site Chrome draft never appears here. The answer is the same with or without a token, is cached, and is purged when Site Chrome is accepted.
| Status | Meaning |
|---|---|
| 200 | data.redirects: a list of { from, match, status, to }, possibly empty. |
| 400 | public_filter_invalid: the request carried a query string. |
| 404 | unknown_host: no site on this host. |
| 500 | site_chrome_unreadable: the accepted Site Chrome version no longer matches its recorded digest. An administrator has to accept a version again. |
curl https://example.com/v1/_redirects{ "data": { "redirects": [ { "from": "/tours-old", "match": "exact", "status": 301, "to": "/tours" }, { "from": "/news/{rest*}", "match": "pattern", "status": 308, "to": "/blog/{rest}" }, { "from": "/old-offer", "match": "exact", "status": 410, "to": null } ] } }GET /v1/_chrome
Section titled “GET /v1/_chrome”Read the menus and regions of one language. The menus, template regions and footer note of the site’s accepted Site Chrome, resolved for one language. A menu item that points at a page, post, Blog list or archive carries that item’s live address in this language today (the site’s locale rule decides fallback); an item whose page is not live, or whose model is private, is left out, and a heading left with no items under it is left out too. Each language owns its own menu items; a language without its own version follows the site’s strict or fallback rule. placements says which menu the header and the footer show in this language. regions holds each region the site’s template declares that shows something in this language, with its items in order: a shared section’s published sections, a menu, the search page, recent Blog posts, categories, tags or posts by month. A shared section carries the entries of each of its Latest entries sections, read as GET /v1/:model reads them: published only, in this language or its fallback, at most the section’s limit. Posts by month links the 30 newest posts at most, grouped into 12 months at most; a month has no address of its own. A managed renderer reads this on every page, so menu and region changes and moved pages need no new release. The answer is the same with or without a token, is cached, and is purged when Site Chrome is accepted or a referenced model or shared section publishes.
| Parameter | Required | Meaning |
|---|---|---|
locale |
no | A language of this site. Absent means the default language. |
| Status | Meaning |
|---|---|
| 200 | data: `{ locale, menus: { [key]: { lang, items: [{ id, label, href, children: [{ id, label, href }] }] } }, placements: { header, footer }, footer_note: { lang, text } |
| 400 | public_chrome_invalid: locale is not a language of this site. public_filter_invalid: another query parameter, or locale twice. |
| 404 | unknown_host: no site on this host. |
| 500 | site_chrome_unreadable: the accepted Site Chrome version no longer matches its recorded digest. An administrator has to accept a version again. |
curl https://example.com/v1/_chrome?locale=th{ "data": { "locale": "th", "menus": { "main": { "lang": "th", "items": [ { "id": "mi_about", "label": "เกี่ยวกับ", "href": "/th/about", "children": [] }, { "id": "mi_help", "label": "Help", "href": null, "children": [ { "id": "mi_call", "label": "Call", "href": "tel:+6621234567" } ] } ] } }, "placements": { "header": "main", "footer": null }, "footer_note": null, "regions": { "page_end": { "lang": "th", "items": [ { "kind": "search", "href": "/th/search" } ] } } } }GET /v1/_embeds
Section titled “GET /v1/_embeds”Read the embed origins. The https origins an administrator added under Settings → Integrations, besides the built-in YouTube and Vimeo players. A renderer frames a rich-text embed from one of these origins only while it is listed here, and otherwise draws a plain link to the same address. Each id is the origin’s host, and an embed block names it as data-provider. Dee Wan’s renderers read this only for a document that holds such an embed. The answer is the same with or without a token, is cached, and is purged when the list is saved.
| Status | Meaning |
|---|---|
| 200 | data.providers: a list of { id, origin }, possibly empty. |
| 400 | public_filter_invalid: the request carried a query string. |
| 404 | unknown_host: no site on this host. |
curl https://example.com/v1/_embeds{ "data": { "providers": [ { "id": "maps.example.com", "origin": "https://maps.example.com" } ] } }GET /v1/_rich-blocks
Section titled “GET /v1/_rich-blocks”Read the plugin rich block definitions. The rich-text block types the site’s active plugins declare: fields, labels and a render tree built from a fixed element list, never markup. A stored block names its type and version in data-type and data-version on <figure data-block="plugin">. A renderer draws a block from its definition’s render tree, escaping every value, and draws nothing for a block whose type is not listed, whose definition has no render, or whose version is newer than the listed one. Dee Wan’s renderers read this only for a document that holds a plugin block. The answer is the same with or without a token, is cached, and is purged when a plugin is installed, upgraded, disabled, enabled or removed.
| Status | Meaning |
|---|---|
| 200 | data.blocks: a list of definitions, possibly empty. |
| 400 | public_filter_invalid: the request carried a query string. |
| 404 | unknown_host: no site on this host. |
curl https://example.com/v1/_rich-blocks{ "data": { "blocks": [ { "type": "com.example.notes/callout", "plugin": "com.example.notes", "name": "callout", "version": 1, "label": "Callout", "fields": [ { "key": "title", "control": "text", "label": "Title" } ], "render": { "nodes": [ { "element": "strong", "children": [ { "field": "title" } ] } ] } } ] } }GET /v1/_sitemap
Section titled “GET /v1/_sitemap”Read the robots policy and the sitemap chunks. What /robots.txt and /sitemap.xml say for this site. robots is the accepted Site Chrome policy. sitemap.chunks names every chunk of live addresses, per model, 5,000 addresses at most each; a renderer lists each at /sitemap-<model>-<number>.xml. Empty when the sitemap is off. Only public models and live versions count; a page marked noindex is never listed. Cached, and purged when Site Chrome is accepted or a listed model publishes.
| Status | Meaning |
|---|---|
| 200 | data.robots: { allow_indexing, disallow }; data.sitemap: { enabled, change_frequency, chunks: [{ model, number, lastmod }] }. |
| 400 | public_filter_invalid: the request carried a query string. |
| 404 | unknown_host: no site on this host. |
curl https://example.com/v1/_sitemap{ "data": { "robots": { "allow_indexing": true, "disallow": [] }, "sitemap": { "enabled": true, "change_frequency": "weekly", "chunks": [ { "model": "page", "number": 1, "lastmod": "2026-10-01T09:00:00.000Z" } ] } } }GET /v1/_sitemap/:model/:number
Section titled “GET /v1/_sitemap/:model/:number”Read one sitemap chunk. The live addresses of one chunk /v1/_sitemap named: each language member at its own address, with its last publication and up to 20 images (the social image, then the images its live version shows). model and number are a chunk /v1/_sitemap listed. A Blog’s list addresses lead the post chunks.
| Status | Meaning |
|---|---|
| 200 | data.entries: a list of { path, lastmod, images }. |
| 400 | public_filter_invalid: the request carried a query string. |
| 404 | sitemap_chunk_missing: no such chunk, or the sitemap is off. |
curl https://example.com/v1/_sitemap/page/1{ "data": { "model": "page", "number": 1, "entries": [ { "path": "/tours", "lastmod": "2026-10-01T09:00:00.000Z", "images": ["https://media.example.com/boat.jpg"] } ] } }GET /v1/_search
Section titled “GET /v1/_search”Search the live site. Site search over what is live now: published pages, Blog posts on a Blog site, and the other public models the site search setting names. A draft, an unpublished language, an item in Trash, a shared section and a private model never appear, and a publication, withdrawal or address move shows up at once. Each result is served in the requested language or, under the fallback policy, the first available language in its fallback chain; slug is that language’s live address. Every word of q must match, by the model’s matching from the search setting: word (the default) whole words, english_stem English word forms (tours finds tour) in a result served in English (en, en-* or en_*) and whole words in a result served in any other language, substring anywhere inside a word. Words are letters, marks and digits; anything else only separates them. Results with every word in the title come first, then the newest. A model the site tunes under Settings → Source & delivery is searched by its chosen fields only and ranks by the highest weight, 1 to 10, among those fields that hold every word; an untuned title match counts as 10. A relation field matches by the labels of the targets a public read serves the item in relations, read when searching. A target’s draft label never matches, nor does a target that is unpublished, in Trash or in a private model, and summary never holds a relation label. Without q the answer is empty and says only that search runs here. The same answer for every caller, cached at most five minutes and purged on every publication.
| Parameter | Required | Meaning |
|---|---|---|
q |
no | What to find: 1 to 200 characters and at most 6 words of at most 64 characters each, compared without case. |
locale |
no | Which language to serve, as a code matching ^[A-Za-z0-9_-]{1,64}$. Defaults to the site’s default language. What happens when an item has no content in that language is decided by the site’s locale policy, not by this parameter. See Locales below. |
limit |
no | Results per page, 1 to 20. Defaults to 10. |
cursor |
no | The page.next_cursor of the previous answer to the same q and locale, under the same search tuning, matching and language fallback. |
| Status | Meaning |
|---|---|
| 200 | query, data: a list of { kind, id, model, slug, locale, title, summary, published_at } (kind is page, post or entry), page: { limit, has_more, next_cursor }, locale: { requested, default }. |
| 400 | public_search_invalid: an unknown or repeated key, an empty value, a query out of bounds, a bad limit, or a cursor from another query or from before the search tuning, matching or language fallback changed. |
| 404 | search_off: site search is off. locale_unknown: the site has no such language. unknown_host: no site on this host. |
| 500 | site_locale_policy_invalid: the site has no usable locale policy stored. An administrator has to set one under Settings → Languages. An unexpected failure returns a plain-text Internal Server Error. |
curl "https://example.com/v1/_search?q=lighthouse&locale=en"{ "query": "lighthouse", "data": [ { "kind": "page", "id": "ci_1", "model": "page", "slug": "tours", "locale": "en", "title": "Lighthouse tours", "summary": "Walk the cliffs at dawn.", "published_at": "2026-10-01T09:00:00.000Z" } ], "page": { "limit": 10, "has_more": false, "next_cursor": null }, "locale": { "requested": "en", "default": "en" } }GET /v1/_search/suggest
Section titled “GET /v1/_search/suggest”Suggest while typing. Up to limit live items for a query still being typed, from the same rows and rules as /v1/_search: published pages, Blog posts on a Blog site and the chosen public models, in the requested language or the first available language in its fallback chain; never a draft, an unpublished language, Trash, a shared section or a private model. Every word of q matches by the model’s matching, as in /v1/_search, except the last word, which may be partial: it matches the start of a word (anywhere inside one under substring); a tuned model matches by its chosen fields only. Items whose title starts with q come first, then items whose title contains every word, then text-only matches; then the newest. slug is the served language’s live address, as in /v1/_search. Nothing about the query is recorded. The same answer for every caller, cached at most five minutes and purged on every publication. Managed and generated sites ask it through their own /search?suggest=<q>&locale=<code>, which answers { data: [{ title, summary, href, kind }] } with each address as that site links it.
| Parameter | Required | Meaning |
|---|---|---|
q |
yes | What is being typed: 2 to 100 characters and at most 6 words of at most 64 characters each, compared without case. |
locale |
yes | Which language to serve, as a code matching ^[A-Za-z0-9_-]{1,64}$. Fallback follows the site’s locale policy, as in /v1/_search. |
limit |
no | Suggestions to return, 1 to 10. Defaults to 5. |
| Status | Meaning |
|---|---|
| 200 | query, data: a list of { kind, id, model, slug, locale, title, summary, published_at } (kind is page, post or entry), limit, locale: { requested, default }. |
| 400 | public_search_invalid: a missing q or locale, an unknown or repeated key, an empty value, a query out of bounds or a bad limit. |
| 404 | search_off: site search is off. locale_unknown: the site has no such language. unknown_host: no site on this host. |
| 500 | site_locale_policy_invalid: the site has no usable locale policy stored. An administrator has to set one under Settings → Languages. An unexpected failure returns a plain-text Internal Server Error. |
curl "https://example.com/v1/_search/suggest?q=light&locale=en"{ "query": "light", "data": [ { "kind": "page", "id": "ci_1", "model": "page", "slug": "tours", "locale": "en", "title": "Lighthouse tours", "summary": "Walk the cliffs at dawn.", "published_at": "2026-10-01T09:00:00.000Z" } ], "limit": 5, "locale": { "requested": "en", "default": "en" } }GET /v1/_feed
Section titled “GET /v1/_feed”Read the Blog feed. The newest 20 published Blog posts in one language, exactly as the Blog list serves them, for a renderer to write as RSS at /rss.xml (the default language) and /<code>/rss.xml. summary is the post’s search description, or the start of its text. A post leaves the feed when it is unpublished or moved to Trash. Cached at most five minutes and purged on every publication.
| Parameter | Required | Meaning |
|---|---|---|
locale |
no | Which language to serve, as a code matching ^[A-Za-z0-9_-]{1,64}$. Defaults to the site’s default language. What happens when an item has no content in that language is decided by the site’s locale policy, not by this parameter. See Locales below. |
| Status | Meaning |
|---|---|
| 200 | data: { title, locale, default_locale, items }, each item { id, slug, locale, title, summary, authors, date, published_at }. |
| 400 | public_feed_invalid: a key other than locale, repeated or empty. |
| 404 | feed_unavailable: the site is not a Blog, or its posts are not public. locale_unknown: the site has no such language. |
| 500 | site_locale_policy_invalid: the site has no usable locale policy stored. An administrator has to set one under Settings → Languages. An unexpected failure returns a plain-text Internal Server Error. |
curl "https://example.com/v1/_feed?locale=en"{ "data": { "title": "Harbour notes", "locale": "en", "default_locale": "en", "items": [ { "id": "ci_9", "slug": "first-light", "locale": "en", "title": "First light", "summary": "Fog lifted at six.", "authors": ["Ana Lima"], "date": "2026-10-01", "published_at": "2026-10-01T09:00:00.000Z" } ] } }GET /v1/_comments
Section titled “GET /v1/_comments”Read an entry’s comments. Approved visitor comments on one live entry of a model that takes comments (a Blog post, or any model whose comments setting is on), in the language of the item you name: top-level comments in the order you ask, each with at most 20 approved replies, oldest first. A comment carries only its id, display name, website, text, time and reaction counts. Email, moderation state, who reacted and anything used against abuse are never served. count counts approved comments and replies and stops at 999 (count_capped). Comments hide while the entry is unpublished or in Trash. While its model takes no comments the answer is a closed page (open: false, no threads). New comments are posted to POST /v1/interactions/comment.submit and reactions to POST /v1/interactions/comment.react on the site host, served by the intake Worker. Cached at most five minutes and purged on every moderation change, publication and change of the model’s comments setting.
| Parameter | Required | Meaning |
|---|---|---|
entry |
yes | The id of a published entry item. |
order |
no | oldest (default), newest, or best: most helpful reactions first, equal counts oldest first, then by id. |
cursor |
no | The previous page’s next_cursor. A cursor works only for the entry and order it came from. |
limit |
no | Top-level comments per page, 1 to 50. Defaults to 20. |
| Status | Meaning |
|---|---|
| 200 | data: { entry, locale, order, open, count, count_capped, threads, next_cursor }, each thread { id, name, website, body, created_at, reactions, replies, more_replies } and each reply { id, name, website, body, created_at, reactions }. reactions is { helpful }. |
| 400 | public_comments_invalid: a missing or malformed entry, an unknown order, another key, a repeated or empty value, a bad limit or a cursor from another entry or order. |
| 404 | comments_unavailable: the entry is not live in its language, or its model is private. Never cached. |
curl "https://example.com/v1/_comments?entry=ci_9&order=best&limit=20"{ "data": { "entry": "ci_9", "locale": "en", "order": "best", "open": true, "count": 2, "count_capped": false, "threads": [ { "id": "cm_1", "name": "Ana", "website": null, "body": "Lovely light.", "reactions": { "helpful": 3 }, "created_at": "2026-10-02T08:00:00.000Z", "more_replies": false, "replies": [ { "id": "cm_2", "name": "Bo", "website": "https://bo.example/", "body": "Agreed.", "reactions": { "helpful": 0 }, "created_at": "2026-10-02T09:00:00.000Z" } ] } ], "next_cursor": null } }Both /v1 routes return items of this shape:
| Key | Type | Meaning |
|---|---|---|
id |
string | The item’s id. Stable across edits and renames. |
slug |
string or null | The served version’s slug; the draft’s during preview. null when the item has none. |
model |
string | The model’s slug. |
locale |
string | The locale the fields were served in. Under the fallback policy it can differ from the one you asked for. |
version |
number | The served saved version number; the draft’s during preview. |
published_at |
string or null | ISO 8601 publication time. |
fields |
object | Field values for the served locale, keyed by field key. A field the model does not translate has one value, served in every locale and used by sort and filter in every locale. |
relations |
object | For ordinary reads and older preview links without the related-draft grant: For each relation field, an ordered list of { instance_id, model, slug, display } with only the targets this request can read: published, not in Trash, and in a public model, or in a private model only on a private model’s read whose token names both. slug and display are the target’s published address and label, read now. display is null when the label field is private. model is the slug of the target’s model; a field that accepts several models serves targets of each in the list’s order. Any other target is left out, as if it were not linked. A translated target is served in the item’s language, then along the site’s fallback chain. Under strict, a translated target with no published version in that language is left out. An untranslated target is served in any language. New shared preview links use each authorized target’s latest saved draft, including never-published entries; addresses and labels come from those drafts. The creator’s current item, field and locale rights apply. Private fields and unreadable or trashed targets are omitted. See draft previews. |
expanded |
object | For ordinary reads and older preview links without the related-draft grant: Only with expand. For each named relation field, { data, page }: up to 25 targets as items, and page.next_cursor for the rest at /v1/_related/:model. New shared preview links populate this automatically for ordinary and component relationships, up to four levels and 128 targets across one root response. Cycles stop at stable identities. Draft expansion does not use public relation cursors. |
media |
object | For each media field, an ordered list of { media_id, name, mime_type, kind, size, url, thumbnail_url, alt, width, height }. kind is image, document, audio, video or file; show an image with <img>, audio and video with their elements and a download link, and a document as a link with its name, type and size. width and height are null for anything that is not an image. An entry also carries crop ({ x, y, width, height }) and focal ({ x, y }) when the editor set them, as fractions 0–1 of the stored image. They describe how to show an image and are never set on other files. The bytes and url are unchanged, and no resized or cropped copy is served. A file copied from a media source also carries a read-only credit: { author, source, license, url } (author and url may be null), as it was when the entry was saved. Show it under the image. A private file of a private model (it may be used only there) carries visibility: "private" with an empty url and a null thumbnail_url: its address is withheld, and the bytes come from your application’s own sign-in, never from this API. |
Rich-text fields hold one rich document, stored as its canonical HTML: paragraphs,
headings, lists, quotes, code blocks, rules, tables, image galleries, audio,
video and file blocks, and embeds. Every write is checked, and
markup outside that set is refused, so the API never serves scripts, styles,
event attributes or frames. Blocks are written as data, not as players: a media
block is <figure data-block="video|audio|file"> holding one link with
data-media-id, type and data-size, and an embed is
<figure data-block="embed" data-provider data-url data-title> with no <iframe>.
data-provider is youtube, vimeo or a configured origin’s host, and
data-url is its canonical https frame address. Pass the origins from
/v1/_embeds to frame a configured provider; without them it renders as a link.
Render it the way Dee Wan’s own renderers do, with renderRichDocument from the page
contract (Blog sites).
A preview response also carries preview_identity, may carry
preview_attestation, and for page models carries mediaPresentation.
Published responses never carry any of the three.
Each language of a translation group is published on its own and serves its own published version, so the list returns one item per group: the language the locale policy serves, from that language’s own published version. A language that is not published is never served from another language’s version.
Private fields
Section titled “Private fields”A field marked Private in the Model Builder is never served: it is absent from fields,
relations and media of every item, expanded item and related page, from the Blog feed and
from site search. Inside a component value, a private component field is absent too. A query
that names a private field in filter[...], sort, fields, relation, expand or a related
cursor gets the same refusal as a field that does not exist, so a query cannot learn its value or
that it exists. This applies to private models read with a token as well.
Only the fields a model declares now are served. A published version keeps a renamed or deleted
field’s old key in storage, but no public read serves a value under it: not in fields,
relations, media, inside a component value, the Blog feed or site search. So a field
that was private and is then renamed or deleted never reappears under its old key. A field
cannot take such a key while a kept version still holds a value under it: the model or component
save is refused with retained_key_reuse, so an old value never reappears under a new field.
Single-entry models
Section titled “Single-entry models”A single-entry model holds at most one entry. Its routes are the ordinary ones:
GET /v1/<model> answers a list of at most one item, with the usual envelope, and
GET /v1/<model>/<slug> answers it by address. Read the list when the address is not known.
Locales
Section titled “Locales”Each site has a locale policy, set under Settings → Languages:
- strict: an item is served only in the requested locale, and only while that
language is published. The list leaves out items without it. The item route
answers 404
locale_unavailable. Fallback languages are ignored. - fallback: the requested locale, then that locale’s fallback languages, then
the site’s default language, then the item’s own primary language, each only
while that language is published. The item’s
localekey names the one actually served.
Fallback languages are set per language under Settings → Languages → Missing translations, in
order, while a missing translation shows the default-language page. A
fallback language’s own fallbacks follow it: pt-BR → pt and pt → es serve
pt-BR, pt, es, then the default. Cycles and unknown languages are
refused when saved. A site with none set falls back to its default alone.
A caller cannot override the policy or the fallback languages.
Errors
Section titled “Errors”Every JSON error body has the shape { "error": string, "reason"?: string }.
public_filter_too_many, public_filter_too_many_values,
public_expand_too_many and public_expand_too_deep add max. Branch on
reason and on the status, never on the text of error.
| Status | reason |
When |
|---|---|---|
| 400 | public_filter_invalid |
Any rejected query: unknown or repeated parameter, empty value, bad limit, order, locale, filter or cursor. |
| 400 | public_filter_too_many |
More than 16 relation/tag pairs, or filter on more than 4 fields. max is in the body. |
| 400 | public_filter_too_many_values |
More than 10 values for one filter[field]. max is in the body. |
| 400 | public_filter_type_unsupported |
filter on a field whose type it cannot compare, a range on a type that has none, or relation or tag on a relation field that accepts several models. |
| 400 | public_cursor_mismatch |
A cursor sent with a query, site, model or token scope other than its own. |
| 400 | public_cursor_stale |
A /v1/_related/:model cursor whose item was published again. |
| 400 | public_expand_invalid |
expand names a field that is not a relation field of the model, a relation field that accepts several models, or repeats one. |
| 400 | public_expand_too_many |
expand names more than 3 fields. max is in the body. |
| 400 | public_expand_too_deep |
expand names a dotted path. One level only; max is 1. |
| 404 | unknown_host |
No active site on this host. |
| 404 | locale_unavailable |
The item has no content the locale policy will serve. |
| 404 | ambiguous_slug |
Two published items claim the slug. |
| 404 | none | No such model, no such published item, a failed preview token, or a private model without a valid token. |
| 422 | a media presentation reason | Page preview only. See the item route above. |
| 500 | site_locale_policy_invalid |
The site’s locale policy is missing or invalid, or its stored fallback languages are malformed. |
Validation rules for both /v1 routes:
- An unknown parameter is refused, never ignored.
- An empty value (
?locale=) is refused. - A parameter may appear once, except
relation,tagandfilter[field](equality only) on the list route. - Locale codes and filter field keys must match
^[A-Za-z0-9_-]{1,64}$. - A filter value (the part after the first
:) is at most 255 characters, and neither side of the:may be empty. - The item route accepts only
locale,previewandexpand.
Private models
Section titled “Private models”A model is public or private. Set this under Settings → API. A private model is readable only with a site API token:
Authorization: Bearer dw_site_…Tokens are created under Settings → API. Each one names the models it may read,
is shown once, can carry an optional expiry date, and can be revoked there at any
time. Revocation and expiry take effect on the next request. Tokens start with
dw_site_. After the operator rotates the signing key to its second era, new
tokens start with dw_site2_. The public Worker needs a valid signing-key configuration
(SITE_TOKEN_HMAC_KEY). Without it no private model is readable.
A request with no token and a request with a wrong one get the same answer: 404
with no reason, the same as a model that does not exist. That is deliberate:
distinguishing them would tell a caller who guessed the model name that the model exists.
So when a private model answers 404, check the token before the URL.
A public model ignores the Authorization header.
Caching
Section titled “Caching”Whether a response may be cached depends on the model, not on the request:
- Public model, published read: cacheable at the edge when caching is on.
This holds whether or not the request carries an
Authorizationheader. - Private model, or a served preview: always
Cache-Control: private, no-store, never stored. A served preview is one whose token verified: its item, and its 422 media presentation refusal. A preview token that does not verify on a public model answers the ordinary public 404. - 400 errors and 500
site_locale_policy_invalid:no-store.
Caching is off by default: PUBLIC_CACHE_MAX_AGE is 0, and public responses
say no-store. Set PUBLIC_CACHE_MAX_AGE to a number of seconds, at most
31536000 (one year), on both the public and the admin Worker to turn it on.
Publishing or unpublishing then purges the affected responses by cache tag. That
purge needs CF_PURGE_ZONE_ID and CF_PURGE_API_TOKEN (a token with
Zone → Cache Purge) on the admin Worker. Without them, the change reports that
the purge was skipped, and cached responses stay until they expire.
Response headers:
| Header | Public model | Private model or served preview |
|---|---|---|
Cache-Control |
no-store when caching is off. public, max-age=<min(60, TTL)>, s-maxage=<TTL> when on |
private, no-store |
X-Cache |
MISS from the Worker, HIT from the edge cache |
BYPASS |
Cache-Tag |
site:<id>, model:<slug>, on the item route instance:<id>, and model:<slug> of models linked from component fields or expanded with expand |
not sent |
Vary |
Accept-Encoding |
Authorization |
The cache key is the host plus the normalized path and query, so the same query
written in a different parameter order shares one entry. For a model with private fields, the
key also names those fields, so a field turned private is never served from an older cached response.
Changing which fields are private also purges model:<slug> and site search.
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 reason values, and the top-level shape of a
response: contracts, 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. A page item also carries page_contract. With expand, an item
also carries expanded. An item whose published
version sets search and sharing overrides for the served language also carries
seo: { format: 1, title, description, image, canonical, robots, schema_type },
where image is { media_id, url, alt, width, height } or null, canonical
is an https address or a path on this site or null, robots is index or
noindex, and schema_type is WebPage, AboutPage, ContactPage,
Article or null (a renderer’s structured data names the page by it). Only the published version’s values are served; a draft change is
not. Versioned plugin contributions may also add seo.renderer_metadata: declared metadata,
links and JSON-LD, keyed by plugin and contribution. Authored/core SEO takes precedence.
Published hierarchy may add ancestors[field] and seo.breadcrumbs[field]: ordered
{ id, locale, label, url } entries using current published ancestors and accepted routes,
under the site’s locale policy. Draft labels and unavailable ancestors are omitted. Chains hold
at most 32 ancestors, for the first eight public hierarchy fields in field-key order.
Absent seo means no SEO overrides or derived additions are present.
An item referencing public library assets may carry mediaMetadata, keyed by asset id.
Each value contains only explicitly public custom asset fields. These values follow the asset’s
current revision, independently of the entry’s published version; changing a field’s privacy
changes the public cache identity. Generated artifacts snapshot this public projection.
PAGE CONTRACT VERSIONS: contracts.page.served is the page vocabulary that
produced the response, currently 5. A page item’s
page_contract.required is the page-contract version a renderer must read to
render that page. Today every readable page answers the served version, so
required equals contracts.page.served. It is not the stored page
format, which is a different number. A renderer built for version N reads
every page whose required is at most N and refuses the rest whole.
It never renders part of a page. With fields=, page_contract is kept
only when slides is selected.
NOT stable, and do not parse: the contents of next_cursor, the text of
error, and the order of keys. A new optional parameter, a new reason, or a
new key inside an item may appear within v1. Nothing
documented here will change meaning without a new version in the path.
Consumer examples
Section titled “Consumer examples”Fetch every item, following the cursor
Section titled “Fetch every item, following the cursor”Run this on a server or at build time. Browser reads need an allowed origin; keep private site tokens on your server.
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; 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'Keep the token on the server. Anything shipped to a browser is public.
Use the generated TypeScript client
Section titled “Use the generated TypeScript client”Download dee-wan-client.mjs from Settings → API and commit it. It needs Node
18 or later and nothing else. Pull the client with a site API token:
DEE_WAN_SITE_TOKEN=dw_site_… node dee-wan-client.mjs pull \ --url https://example.com --out src/dee-wan-public.tsBefore you deploy, check that the committed client still matches the site:
DEE_WAN_SITE_TOKEN=dw_site_… node dee-wan-client.mjs check --file src/dee-wan-public.tscheck exits 1 on a breaking change: a removed model, field or locale, a changed
field type, a model made private, a field made optional, a new select value, or a
changed default locale or locale policy. It exits 0 when nothing changed or every
change is compatible, such as an added model, field or locale, and lists them. It
exits 2 when it cannot run. After a change, run pull and commit the result.
The client contains the response envelope, the locale union, exact field types, model-aware query types and one method per model. Settings → API can also copy or download it by hand. Dee Wan does not change your repository.
Types without the generated client
Section titled “Types without the generated client”type RelationTarget = { instance_id: string; model: string; slug: string | null; display: string | null };type Media = { media_id: string; name: string; mime_type: string | null; kind: 'image' | 'document' | 'audio' | 'video' | 'file'; size: number | null; url: string; thumbnail_url: string | null; alt: string | null; width: number | null; height: number | null; visibility?: 'private';};type Item<F> = { id: string; slug: string | null; model: string; locale: string; version: number; published_at: string | null; fields: F; relations: Record<string, RelationTarget[]>; media: Record<string, Media[]>;};type Page<F> = { contracts: { page: { served: number } }; data: Item<F>[]; page: { limit: number; has_more: boolean; next_cursor: string | null };};type ApiError = { error: string; reason?: string; max?: number };
type Article = { title: string; body: string };
const response = await fetch(url);if (!response.ok) throw new Error(`${response.status} from ${url}`);const page: Page<Article> = await response.json();Spatial queries
Section titled “Spatial queries”A point value is { "latitude": 13.75, "longitude": 100.5 }. Coordinates are finite degrees. Radius values use metres.
GET /v1/places?filter[location][within_radius]=13.75,100.5,2000GET /v1/places?filter[location][within_bbox]=13.5,100.2,14,100.8GET /v1/places?sort=location&origin=13.75,100.5Bounding-box order: south, west, north, east. Edges are inclusive. West greater than east crosses the antimeridian. Radius and nearest order use exact Haversine distance. Use sort=-location for farthest first. Missing values sort last; ID breaks ties. A cursor binds the origin and bounds. Private coordinates follow the normal field and model restrictions.
Incremental synchronization
Section titled “Incremental synchronization”GET /v1/_sync?limit=50 starts a bounded published entry/media snapshot. Optional model and locale restrict entries. Follow next_cursor until checkpoint is returned. Poll with cursor=<checkpoint>. Existing site tokens and CORS apply; a scoped token can include its authorized private models. Private media are excluded, matching ordinary public media delivery.
Apply snapshot data, then apply replay changes in ascending sequence. Each change has operation: upsert|delete, entity: entry|media, id, and an entry locale. Upserts carry item. Changes can repeat identities; apply idempotently. data and deleted are convenience arrays; do not apply those arrays in separate batches because a later republish can follow a delete.
Cursors are opaque signed values. Preserve them unchanged. 409 reset_required requires a fresh snapshot after scope, publication policy, replaced database state, retention or a referenced shared source changes. A normal process restart preserves the checkpoint. Same-site ordinary, component, hierarchy and active rich-block references watch their authorized declared target models. A later publication or withdrawal in one of those models returns 409 reset_required with reset_reason: related_content_changed, including streams restricted by model. This can include another entry in the same target model. Hidden relation fields and unreadable target models do not add dependencies. Current public projection and component privacy/definitions also bind the cursor; a change requires a fresh snapshot. Any published-content change in a referenced source site can also invalidate the checkpoint, including changes to related public display values. An unavailable shared-source database returns retryable 503 sync_dependency_unavailable; retry the same cursor. There is no automatic journal purge. Journal rows retain identity and operation, never draft or private field bodies.
Shared relation output uses shared[field] slots { site_id, id, item }; an unavailable or revoked target has item: null. Each delivery rechecks the source grant and published public fields. A response resolves at most 128 distinct grant/language identities. Repeated slots, including synchronization data and changes, reuse one current projection. On 422 shared_reference_limit, lower the list or sync limit and retry.
Unreleased / pre-MVP; no stable version
Source revision: fe23db481c422b40e317b92fc98116b4c0c26a87 (modified checkout)
Build date (UTC): 2026-10-18T09:22:34.630Z
Upgrade target: unavailable: modified checkout. Release notes