Skip to main content

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…UseReturns
The rendered body of a published page/post (blocks, HTML, JSON-LD)Artifact: GET /api/content/:type/:slug?surface=json|json_ld|webFired 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/:tokenThe 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 downSync: 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 tokenJSON: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,tagsName, 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/localeGraphQL /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 haveGET /media/:id/t/:ops via kiln.imageUrl / KilnClient.image_urlThe 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-Language on HTTP responses, locale on the JSON:API resource and the GraphQL result, locale in /api/resolve and /api/menus bodies. 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 (GraphQL fallback: 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 — check GET /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:

SurfaceHow
ArtifactGET /api/content/<type name>/<slug> — identical to compiled types; the json surface's type field is the dynamic type's name
JSON:APIGET /api/json/entries?filter[type_name]=<name> (+ /entries/search, /semantic-search, /autocomplete with ?query=…, each with a published-only …/published twin)
GraphQLentryBySlug(slug, locale, typeDefinitionId), searchEntries(query, filter: {typeName: {eq: "<name>"}}), entryTranslations, semanticSearchEntries, autocompleteEntries (+ searchPublishedEntries / semanticSearchPublishedEntries / autocompletePublishedEntries)
WebhooksEvents 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_version if you need to know which shape you have rather than assuming the keys are present. An operator can migrate a whole corpus up front with mix kiln.refire_all instead 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?

SurfaceBlocks fieldShape
Artifact GET /api/content/:type/:slug✅ blocksFired typed model ({"_type": "...", ...} per block), sanitized, ready to render
Preview GET /preview/:token✅ blocksRaw editable blocks (what the editor holds), for a single draft
JSON:API / GraphQL❌ noneBlock 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:

  1. Your server (holding an editor's :read key) calls POST /api/content/:type/:id/preview-token — or the editor pastes a link from Copy preview link, whose last path segment is the token.
  2. Pass the returned token to the page (a cookie, a query param).
  3. 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's id (or GET /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 from url and the focal point, so pass the whole media_item. Without url the URL still works, with a five-minute lifetime.
  • Unsigned URLs snap. From a browser the builders round width/height up to the server's size ladder, because the server only renders allowlisted sizes for unsigned URLs. Use aspectRatio rather than height for 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_KEY can 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 sends Vary: Accept; behind a CDN that ignores Vary, 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:

  1. Your server (holding an editor's :read key) calls POST /api/content/:type/:id/preview-token — or the editor pastes a link from Copy preview link, whose last path segment is the token.
  2. Pass the returned token to the page (a cookie, a query param).
  3. 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 :viewer account (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 :published action filters state == :published server-side; see json-api.md → route table) rather than the plain index. Search has the same twins (#297): /search/published, /semantic-search/published and /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 the state facet, so the filter cannot be widened by any credential. Prefer these over remembering to pass state=published on 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/revisions lists a document's revisions (who, when, which fields), …/revisions/:version_id returns one revision's changes plus the full document as it stood then, and POST …/revisions/:version_id/restore reverts the content to it (a :read_write key; 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 Authorization header (including a delivery API key), an unlock grant, even a cookie — makes the response private, 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 GET for cacheable reads. POST /gql is never cached. Put the document (and variables, JSON-encoded) in the query string — a document sent in a GET body is never cached — and a query that returns errors is not cached either.
  • A publish shows up within the max-age, or at once if the operator set KILN_CDN_PURGE_URL, which purges the site's Surrogate-Key/Cache-Tag on 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 updatedAt or 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=json plus ?surface=json_ld is two views of one document.
  • Revalidation counts. A conditional request that gets a 304 still 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 does GET /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.