Headless consumer guide
KilnCMS exposes content over four HTTP surfaces, and they deliberately return different JSON shapes. This guide is a decision tree for picking the right one and knowing what you'll get back. See also api.md (JSON:API + auth), headless-graphql-api.md (GraphQL), and json-api.md (filtering reference).
Every Kiln site also serves a human-readable summary of these surfaces at
/developers (linked from the site header) — endpoints, auth in brief, and
the Swagger UI / OpenAPI spec links (#319).
Building in Elixir? Use the official client,
kiln_client
— it wraps the JSON:API reads, search, and artifact surfaces with the safe
defaults below (published-only reads, Req.Test-stubbable) already encoded,
plus the JSON:API writes (create, update, workflow
transitions, soft-delete — a :read_write key required) and a small /gql
helper.
Building in JS/TS? Use the official client,
@kiln-cms/client
— the same surfaces and safe defaults as the Elixir client (published-only
reads, writes, GraphQL, injectable fetch), plus
preview tokens, ?as_of= point-in-time reads, the /api/sync delta loop, and a bundled kiln-types
generator that emits per-site TypeScript types from GET /api/schema (dynamic
content types and custom fields included). examples/astro-blog consumes it
end to end.
Building a native app? native-apps.md covers keeping a
phone's copy current: storing the sync cursor, backoff, iOS/Android background
refresh, ETags, ?fields= for smaller payloads, and webhooks as the push path.
Try it live
The project's own site, kilncms.dev, is a Kiln
instance, and its published content answers anonymous reads. The guides under
/docs are entries of a dynamic doc type, so you can try each surface below
with nothing installed:
# Filterable lists / metadata (JSON:API)
curl -g 'https://kilncms.dev/api/json/entries/published?filter[type_name]=doc&fields[entry]=slug,title&page[limit]=5' \
-H 'accept: application/vnd.api+json'
# The rendered body of one document (the fired artifact)
curl 'https://kilncms.dev/api/content/doc/getting-started?surface=json'
# Hybrid search
curl 'https://kilncms.dev/api/search?q=migrations'
# What lives at this URL?
curl 'https://kilncms.dev/api/resolve?path=/docs/getting-started'
# Mirroring: the first page of a sync
curl 'https://kilncms.dev/api/sync?initial=true&limit=5'
# A typed query (GraphQL)
curl https://kilncms.dev/gql -H 'content-type: application/json' \
-d '{"query":"{ publishedEntries(limit: 3) { count results { title slug } } }"}'It is a documentation site, so it has no categories, tags or menus, and few
posts: those reads answer 200 with an empty list. It is read-only for you,
and the rate limits are a stock install's. The examples
in the rest of this guide use localhost:4000 or cms.example.com, meaning a
site of your own.
Decision tree
| You want… | Use | Returns |
|---|---|---|
| The rendered body of a published page/post (blocks, HTML, JSON-LD) | Artifact: GET /api/content/:type/:slug?surface=json|json_ld|web | Fired artifact — the immutable, pre-compiled output (Kiln v2 _type block model) |
| To preview a specific draft by share link (draft mode) | Mint: POST /api/content/:type/:id/preview-token (editor key, server side) — or an editor's Copy preview link. Redeem: GET /preview/:token | The draft's raw, editable block tree (curated public fields) — the working copy for a live document — behind a signed, read-only 15-minute token for that one document |
| To mirror the public site — a build cache, search index or edge store — and learn what was taken down | Sync: GET /api/sync?initial=true, then ?cursor= | Upserts (with the fired artifact) and id-only deletes since your last cursor — see api.md → Sync |
| Filterable lists / metadata (slug, title, SEO, dates, relationships), incl. drafts with a bearer token | JSON:API: GET /api/json/... | Resource attributes + relationship linkage. No block body (blocks is public? false) |
| Taxonomy (categories, tags) | JSON:API /api/json/categories,/tags or GraphQL categories,tags | Name, slug, description |
| Search (keyword, semantic, autocomplete) | JSON:API /<type>/search,/semantic-search,/autocomplete or GraphQL search*/semanticSearch*/autocomplete* | Matching records (metadata; no block body). Published-only for anonymous callers — with a bearer token, drafts match too. Delivery sites: use the …/published twins (searchPublished* etc.), which pin state == :published server-side (see "Drafts") |
| A typed query over published content by slug/locale | GraphQL /gql (postBySlug, pageBySlug, …) | Selected fields; no block body, author is the opaque authorId only |
| An image at a size, crop or format the upload variants don't have | GET /media/:id/t/:ops via kiln.imageUrl / KilnClient.image_url | The image bytes, cached and immutable when version-pinned (see "Images") |
Locales: what you get when a translation is missing
Every slug lookup — the artifact API, /api/resolve, JSON:API
/<plural>/by-slug/:slug, GraphQL *BySlug, and the built-in site — answers a
missing translation the same way: it walks the site's fallback chain for
the requested locale (fr-CA → fr → en, set at /editor/locales; a locale
with no chain falls back to the default locale) and serves the first published
variant. So:
- Always read back which locale you got.
x-kiln-locale/Content-Languageon HTTP responses,localeon the JSON:API resource and the GraphQL result,localein/api/resolveand/api/menusbodies. A static build that generates/fr-ca/…pages should decide whether an English answer there is a page it wants (and whether it canonicalises to the English URL) — the API tells you, it does not decide for you. - Want exactly one locale? Pass
fallback=false(GraphQLfallback: false): you get that locale or a 404 /null.fallback_locale=replaces the chain with one locale. - A locale the site does not run is a
400 unsupported_locale, never a silent default — checkGET /api/locales, which also publishes every locale's chain. - Navigation follows a configured chain only, never the implicit hop to the default locale.
Details and the full parameter table: api.md → Locale fallback.
Admin-defined (dynamic) content types
Types created in the admin UI (/editor/types — decision D17) are served
through the same surfaces as compiled types, with one difference: instead
of a typed schema per type, they share one generic entries surface,
scoped by the type's name:
| Surface | How |
|---|---|
| Artifact | GET /api/content/<type name>/<slug> — identical to compiled types; the json surface's type field is the dynamic type's name |
| JSON:API | GET /api/json/entries?filter[type_name]=<name> (+ /entries/search, /semantic-search, /autocomplete with ?query=…, each with a published-only …/published twin) |
| GraphQL | entryBySlug(slug, locale, typeDefinitionId), searchEntries(query, filter: {typeName: {eq: "<name>"}}), entryTranslations, semanticSearchEntries, autocompleteEntries (+ searchPublishedEntries / semanticSearchPublishedEntries / autocompletePublishedEntries) |
| Webhooks | Events are named by the dynamic type — "<name>.published" / .updated / .unpublished / .archived / .deleted / .restored / .created — exactly like compiled types |
Admin-defined custom fields are delivered in each entry's custom_fields
map on every surface — including the fired artifact's json surface — and are
filterable/sortable on the JSON:API and GraphQL list surfaces via
custom_filter/custom_sort (see
json-api.md → "Custom fields"). Scalar fields are JSON-native values; media and
reference fields are write-time snapshots — {"id", "url", "alt"} for
media, {"id", "type", "slug", "title"} for references, and an ordered
array of those for a media gallery or a list of references (a field defined
with multiple: true; /api/schema types it as an array) — so no extra
resolution is needed to render them (fetch fresh content by id/type when
you need more than the label). A geolocation field is
{"lat", "lng"} (plus optional "zoom"/"label"), and also appears on the
json_ld surface as the document's contentLocation. A computed field is
derived server-side and read-only — writing to its key is silently ignored,
and the artifact always carries a freshly recomputed value. Per-type typed
GraphQL/JSON:API schemas are deliberately not generated at runtime — promote
the type to a compiled one when you need them.
After an upgrade that changes a surface's shape, a document last published before it keeps its old artifact until something reads it. The first read serves the old shape and enqueues a re-fire behind the request; reads in between are served from cache, so the new shape appears once that background job lands — usually seconds, longer if the firing queue is backed up (#615). Treat the transition as eventual, not next-request: read
format_versionif you need to know which shape you have rather than assuming the keys are present. An operator can migrate a whole corpus up front withmix kiln.refire_allinstead of waiting for reads to drive it.
Word count and reading time
Every document exposes word_count (wordCount in GraphQL) and
reading_time_minutes (readingTimeMinutes) — folded from the block tree and
served wherever the rest of the document is, so you do not have to divide by
your own constant and get a different number from the editor's.
Reading time is ceil(word_count / wpm) at 230 words per minute by default,
configurable per deployment with KILN_READING_TIME_WPM (or config :kiln_cms, :reading_time_wpm in a project overlay). The same rate drives the
reading_time() computed-field function, so a site using both gets one answer. Rounded
up, so any content at all is at least 1 and only genuinely empty content is
0.
Caveat. One words-per-minute figure is an English-prose assumption. Scripts without spaces — Chinese, Japanese, Thai — are counted as words rather than characters, so their estimate is wrong in a way the calculation cannot see. Locale-aware rates are a follow-up.
Why three different block shapes?
| Surface | Blocks field | Shape |
|---|---|---|
Artifact GET /api/content/:type/:slug | ✅ blocks | Fired typed model ({"_type": "...", ...} per block), sanitized, ready to render |
Preview GET /preview/:token | ✅ blocks | Raw editable blocks (what the editor holds), for a single draft |
| JSON:API / GraphQL | ❌ none | Block tree is not auto-exposed (public? false); these surfaces are for metadata, lists, search, and relationships |
Rule of thumb: render published bodies from the artifact surface (it has
CDN cache headers — Cache-Control/ETag/Last-Modified, see #188); use
JSON:API/GraphQL for discovery, lists, filtering, taxonomy, and search; use
preview tokens to share an unpublished draft. The artifact, JSON:API,
GraphQL GET and search surfaces are all CDN-cacheable when read anonymously —
see Caching.
Draft mode with a preview token
A front end's draft/preview mode needs one draft in a browser, and the browser must not hold your API key. Mint on the server, redeem in the page:
- Your server (holding an editor's
:readkey) callsPOST /api/content/:type/:id/preview-token— or the editor pastes a link from Copy preview link, whose last path segment is the token. - Pass the returned
tokento the page (a cookie, a query param). - The page reads
GET /preview/:token— no credential — until it expires 15 minutes later; mint again on the next render.
To render the draft in your own templates (headless draft mode, #1887),
present the same token on the reads your published pages already make, as
x-kiln-preview-token: <token> (or ?preview_token=):
GET /api/json/<type>/:id?include=content_links,incoming_links,tags— the draft's record (a live document's working copy), with its edges to published documents. The id is the minting response'sid(orGET /preview/:token's);GET /api/content/:type/:slug?surface=json|web|json_ld|llm— the body, rendered live from the working copy in the published artifact's shape.
The token adds that one record to what the read returns; everything else is
answered as for an anonymous reader. Search, by-slug and the /published
twins stay published-only, GraphQL and /mcp ignore the token, writes never
read it, and a token that lapsed is a 404 invalid_preview rather than the
published page.
See api.md → Preview tokens for the response shape and who may mint.
Images: sizes, crops and formats on request
A media_item (JSON:API /api/json/media-items, GraphQL featuredImage)
carries url, width, height, focal_x, focal_y and a variants map of
the fixed renditions made at upload. For anything else — a 16:9 crop at 1080px,
a square thumbnail, AVIF — ask the transform endpoint:
import { createClient } from "@kiln-cms/client";
const kiln = createClient({ baseUrl: "https://cms.example.com" });
// <img src srcset sizes> for a 16:9 card, cropped around the editor's focal point
const src = kiln.imageUrl(media, { width: 1080, aspectRatio: "16:9", format: "auto" });
const srcset = kiln.imageSrcset(media, { aspectRatio: "16:9", format: "auto" });What to know:
- The builders need the media object, not just its id.
v(the version pin that makes a response cacheable for a year) is computed fromurland the focal point, so pass the wholemedia_item. Withouturlthe URL still works, with a five-minute lifetime. - Unsigned URLs snap. From a browser the builders round
width/heightup to the server's size ladder, because the server only renders allowlisted sizes for unsigned URLs. UseaspectRatiorather thanheightfor a fixed crop — snapping two sides independently changes the shape. - Signed URLs don't. A server-side frontend holding the operator's
KILN_IMAGE_TRANSFORM_KEYcan sign (kiln.signedImageUrl,KilnClient.image_url(media, sign_key: key)) and ask for exact sizes. The key is a secret — never ship it to a browser bundle. format: "auto"returns the best format the browser accepts and sendsVary: Accept; behind a CDN that ignoresVary, pick explicit formats.- Visibility matches downloads. A gated item's transforms are 404 for anyone who couldn't download it.
Full reference: media-pipeline.md § On-the-fly transforms.
Draft mode with a preview token
A front end's draft/preview mode needs one draft in a browser, and the browser must not hold your API key. Mint on the server, redeem in the page:
- Your server (holding an editor's
:readkey) callsPOST /api/content/:type/:id/preview-token— or the editor pastes a link from Copy preview link, whose last path segment is the token. - Pass the returned
tokento the page (a cookie, a query param). - The page reads
GET /preview/:token— no credential — until it expires 15 minutes later; mint again on the next render.
See api.md → Preview tokens for the response shape and who may mint.
Author / PII
No surface exposes author email or role. Content carries only the opaque
authorId (JSON:API/GraphQL) and the display name is used server-side for the
JSON-LD/byline. See headless-graphql-api.md → "Author
PII".
Drafts
*BySlug GraphQL queries always return published content only (the action
hard-filters state == :published). To read a known draft, use a bearer token
with JSON:API filter[state]=draft or a /preview/:token link. See
api.md → "Reading drafts".
Delivery sites: an API key widens what you see
The read policy authorizes any editor/admin identity for every workflow
state — and that includes a service API key attached as a bearer token. A
public frontend that sets its key "for rate limits" or "as a service identity"
is not an anonymous caller: its plain-index reads (GET /api/json/<plural>)
and all search routes silently include drafts. With an admin key it is
worse than drafts — the admin policy bypass skips the audience and passphrase
checks too, so the base routes also return audience-gated and passphrase-locked
bodies (#1013).
Two independent defenses; use both:
- Mint delivery keys on a
:vieweraccount (see api.md → "API keys"). A viewer identity only ever matches published content, so the key cannot widen visibility no matter which route it hits. - Read the published-only surfaces anyway. For lists/detail, use
GET /api/json/<plural>/published(the:publishedaction filtersstate == :publishedserver-side; see json-api.md → route table) rather than the plain index. Search has the same twins (#297):/search/published,/semantic-search/publishedand/autocomplete/published(GraphQL:searchPublished*,semanticSearchPublished*,autocompletePublished*) pin, server-side, exactly what an anonymous visitor could read — published,audience: :public, and no passphrase (#1013) — with the same query surface minus thestatefacet, so the filter cannot be widened by any credential. Prefer these over remembering to passstate=publishedon every call to the base routes.
Treat "what can this credential see" as part of its blast radius: a leaked editor-keyed delivery config exposes drafts, and an admin-keyed one exposes every paying member's content as well — not just rate-limit headroom.
Mirroring: sync instead of re-reading everything
A static build or search index that polls JSON:API with
filter[updated_at][gt]=<last run> picks up new and edited documents, but
never a removal: an unpublished, archived, deleted, locked or members-only
document just stops matching. It stays in your mirror indefinitely — and for a
document someone locked or moved behind a paywall, that is a leak.
GET /api/sync is built for this (api.md → Sync).
Run ?initial=true once, follow cursor while has_more, store the final
cursor, and poll with it; every delete is a document to drop. In JS:
const { items, cursor } = await kiln.sync({ cursor: await load() });
for (const item of items) {
if (item.op === "upsert") await mirror.put(item.id, item.artifact);
else await mirror.remove(item.id);
}
await save(cursor);What sync reports is the anonymous view whatever key you configure, so it is safe to point a delivery key at it — and a delete never says why a document left, or names one you were never sent.
For an app on a phone, see native-apps.md for where to keep the cursor and when (not) to start over.
Editorial tooling: version history and releases
Two editor-tier reads exist for tools about the content rather than sites that render it — a migration script, an audit export, a launch dashboard. Both need an editor's (or admin's) credential and answer nothing to an anonymous caller or a viewer key, so they never belong in a delivery site's config.
- Version history —
GET /api/content/:type/:id/revisionslists a document's revisions (who, when, which fields),…/revisions/:version_idreturns one revision's changes plus the full document as it stood then, andPOST …/revisions/:version_id/restorereverts the content to it (a:read_writekey; a read-only key is refused). Keyed by document id, paged with a cursor. See api.md → "Version history (revisions)". - Content releases —
GET /api/json/releases(?include=items) shows what is scheduled to go live together, when, and what each release will publish or take down. Read-only: releases are shipped from the console. See json-api.md → "Content releases (read-only)".
To show a visitor "what this page said on a date", use the public
point-in-time read (GET /api/content/:type/:slug?as_of=), not revisions: it
serves only what was published at that instant, to anyone.
Caching
Anonymous reads of the main delivery surfaces are shared-cacheable: fired
artifacts (public, max-age=300), and JSON:API, GraphQL queries over GET and
/api/search (public, max-age=60, stale-while-revalidate=60). Each carries an
ETag; send it back as If-None-Match and an unchanged response is a bodyless
304. The full table, the headers and CDN setup are in
api.md → Caching and CDNs.
What that means for a front end:
- Read delivery anonymously if you want it cached. Any credential — an
Authorizationheader (including a delivery API key), an unlock grant, even a cookie — makes the responseprivate, no-store. That is deliberate: an editor's token sees drafts on the same URLs, so its answer must not land in a shared cache. If a build or server needs a key, keep it off the requests your CDN serves to readers (Delivery sites has the other reason to). - Query GraphQL with
GETfor cacheable reads.POST /gqlis never cached. Put the document (andvariables, JSON-encoded) in the query string — a document sent in aGETbody is never cached — and a query that returnserrorsis not cached either. - A publish shows up within the
max-age, or at once if the operator setKILN_CDN_PURGE_URL, which purges the site'sSurrogate-Key/Cache-Tagon every publish, unpublish and live edit (not on taxonomy or schema edits, which age out). Static builds and ISR should still rebuild from webhooks; the purge only covers the CDN in front of Kiln. - Don't build your own ETag from fields. Kiln's is a digest of the response
body, so it already changes whenever anything in the body does — keying on
updatedAtor an id misses changes that do not touch them.
Analytics: your fetches are what get counted
A successful GET /api/content/:type/:slug records a view against that
document, so a headless site's traffic appears at /editor/analytics instead of
reading as zero. There is nothing to install: no beacon, no cookie, no client
JS. Counting happens server-side on the request you already make, and stores
only the same aggregate counters the rendered site uses — no visitor data. (For
the same reason there is no ingest endpoint to POST your own numbers to; see
advanced-analytics-plan.md → "Privacy constraints".)
What that number means depends on how you cache, so read it as a floor rather than a census:
- Your cache absorbs readers. With ISR, a CDN in front of your frontend, or a static build, you fetch once and serve that document to everyone until it expires. Those readers are invisible to Kiln.
- A build-time fetch counts a deploy, not a reader. A CI pipeline that prerenders every page inflates counts by one per page per build.
- Each surface counts separately. Rendering a page from
?surface=jsonplus?surface=json_ldis two views of one document. - Revalidation counts. A conditional request that gets a
304still counts — the client is actively serving that document. Excluding it would make a CDN-fronted site report near-zero. - Point-in-time reads do not count.
?as_of=is a history query, not a delivery. Neither doesGET /api/sync: a mirror's fetch is replication, and counting it would credit every document on every snapshot.
The stored counters have no surface dimension, so headless and rendered views
sum together in the dashboard. If you export metrics, the
kiln_cms.analytics.view.count telemetry counter is tagged with surface
("html" for the rendered site, otherwise the fired surface name), which is
where the two can be told apart.
If you need true reader counts, that has to come from your own frontend's analytics — Kiln deliberately cannot see the browser.