KilnCMS API documentation
KilnCMS describes its two schema-bearing APIs in machine-readable form: an OpenAPI 3 document for the JSON:API surface (with an interactive Swagger UI explorer over it), and the GraphQL schema as SDL. Both come in two forms — committed to the repository for the stock build, and served by a running site for its own build.
Machine-readable specs
Committed: the stock build
| File | What it describes |
|---|---|
docs/api/openapi.json | The JSON:API surface (/api/json), sign-in, fired artifacts and preview links, as OpenAPI 3.0. |
docs/api/schema.graphql | The GraphQL schema behind /gql and /ws/gql, as SDL. |
Point codegen at these without running anything:
npx openapi-typescript docs/api/openapi.json -o kiln-api.d.ts
npx graphql-codegen --config codegen.ts # schema: "docs/api/schema.graphql"They are regenerated with mix kiln.api.specs, and CI fails when they fall
behind the code (mix kiln.api.specs --check), so the copy on main matches
the code on main — and the copy at a release tag matches that release. The
OpenAPI document's server is a placeholder ({origin}, default
http://localhost:4000); set your site's origin in your tool.
They describe the stock build. A project that adds its own content domains
(config :kiln_cms, :content_domains) grows both schemas; generate against
that project's running site instead.
Served: a running site
| Resource | URL | Who gets it |
|---|---|---|
| OpenAPI 3 document | GET /api/json/open_api | Anyone where API_DOCS_ENABLED is on; otherwise a request with an API key. |
| Swagger UI | GET /api/json/swaggerui | Anyone where API_DOCS_ENABLED is on; otherwise no one. |
| GraphQL SDL | GET /api/graphql/schema.graphql | Anyone where GraphQL introspection is on; otherwise a request with an API key. |
| GraphQL introspection | POST /gql (__schema) | Anyone where GRAPHQL_INTROSPECTION_ENABLED is on; otherwise no one. |
| GraphQL playground | GET /gql/playground | Dev-only; compile-gated to dev_routes, never built into a release. |
The docs and introspection are on in development and test and off in a production build (#567). Closed, each path answers 404 — not 403, which would confirm the route exists and is merely closed.
The reason is disclosure, not access: since #330 the described surface
includes the write routes, so the documents are a complete map of the
mutation API. They grant nothing — every route is still enforced by the Ash
policies and the API key's access scope — but an anonymous stranger has no need
of the map. An API key is different: only an admin can mint one, so its holder
is an integration the site chose, and generating a client against the site's
own schema is exactly what it needs. Any key works, read or read_write. A
user JWT does not — open registration hands those to anyone.
# A production site's own schemas, with any API key
curl -H "authorization: Bearer $KILN_API_KEY" https://cms.example.com/api/json/open_api
curl -H "authorization: Bearer $KILN_API_KEY" https://cms.example.com/api/graphql/schema.graphqlgraphql-codegen reads a schema URL ending in .graphql as SDL and sends the
headers you configure, so the second URL works as its schema directly.
Locally: http://localhost:4000/api/json/swaggerui.
The OpenAPI document is generated by AshJsonApi
from the content domains' resources and enriched by KilnCMSWeb.OpenApi (title,
version, auth/usage description, servers, the API-key scheme, and the routes
that live outside the JSON:API router). It covers Page, Post, admin-defined
types through Entry, MediaItem, the taxonomy (Tag, TagGroup,
Category), Redirect and TypeDefinition — every collection,
single-record, search and autocomplete route, the write and workflow routes,
their filter/sort/page parameters, and both auth schemes. The hand-written
routes AshJsonApi cannot derive are described there too: headless sign-in, the
fired-artifact and preview reads, and the media upload API
(below).
Headless surfaces at a glance
The JSON:API is one of several headless surfaces. Pick the one that fits:
| Surface | Endpoint | Use case | Reference |
|---|---|---|---|
| Sign-in | POST /api/auth/sign_in | Exchange credentials for a bearer token (JWT). Server-to-server clients use an API key instead. | § Authentication |
| JSON:API | /api/json | Filterable reads of Page, Post and admin-defined types (Entry), media, taxonomy and redirects; per-type search and autocomplete; writes — create, update, workflow transitions, soft-delete — with a read_write API key. | json-api.md |
| GraphQL | POST /gql, /ws/gql | Delivery reads, search, menus and point-in-time (contentAsOf); the same writes as mutations; subscriptions over the WebSocket. | headless-graphql-api.md |
| Fired artifacts | GET /api/content/:type/:slug | Pre-rendered output per surface: json (default), json_ld, web, and llm (raw text/markdown). ?as_of= reads a document as it stood on a date; GET /api/content/:type?as_of= lists what was published then; ?fields= trims the json body (§ Sparse fieldsets). | examples/README.md, point-in-time.md |
| Sync (delta) | GET /api/sync | Mirror public content: a snapshot, then upserts and deletions since an opaque cursor — the only surface that reports a document leaving. | § Sync, native-apps.md |
| Version history | GET /api/content/:type/:id/revisions | A document's revisions, one revision's snapshot, and restore. Editor-tier credential required. | § Version history |
| Hybrid search | GET /api/search?q= | Keyword + semantic + title search across every type, fused and ranked; answers as an anonymous visitor whatever the credential. 503 with Retry-After (temporarily_unavailable) when the database pool cannot take the search. | search-roadmap.md |
| Path resolution | GET /api/resolve?path= | "What lives at this URL?" — content, a redirect to follow, or nothing — for a front end's catch-all route. | json-api.md (URLs, pathauto & redirects) |
| Menus | GET /api/menus, GET /api/menus/:key | Resolved navigation trees with live URLs. | navigation-menus.md |
| Related content | GET /api/content/:type/:slug/related | Published documents semantically closest to this one (empty when semantic search is off). | rag.md |
| Ask your content | GET /api/ask?q= | Cited published passages, plus a generated answer when a generator is configured. | rag.md |
| Provenance | GET /api/provenance/verify/:type/:slug, GET /api/provenance/:type/:slug, …/verify, GET /api/provenance/public-key | Whether an artifact is verified, tampered or unsigned, and the signed manifests behind the answer. On by default since 1.7; 404 when an operator switches provenance off. | provenance.md |
| Locales | GET /api/locales | Configured content locales, the default, and each locale's fallback chain. | § Locale discovery, § Locale fallback |
| Schema | GET /api/schema | JSON Schema for the fired json payloads — generate types, validate responses. | § Schema discovery |
| Media upload | POST /api/media, POST /api/media/import-url, /api/media/uploads[/complete] | Upload a file, import one from a public URL, or send a large one straight to object storage; metadata edits ride the JSON:API PATCH. read_write key on an editor account. | § Uploading media |
| MCP | /mcp | Model Context Protocol server for LLM authoring clients; API key required. | mcp.md |
| Forms | GET /api/forms/:slug, POST /api/forms/:slug, <script src="…/embed.js"> | A form's schema and JSON submission, or an auto-resizing iframe on any site. | § Embeddable forms |
| Visual editing | <script src="…/bridge.js"> | In-context edit overlay for an external front end (annotated preview + deep-link + live push). | visual-editing-bridge.md |
| Sitemap | GET /sitemap.xml | Enumerate published content for crawling/SSG. | — |
| News sitemap | GET /news-sitemap.xml | Google News sitemap: NewsArticle types published in the last 48 hours. | seo.md § Google News sitemap |
| Feeds | GET /feed.xml, GET /feed.json | Atom 1.0 / JSON Feed 1.1 of newly published content. | § Feeds |
| Outbound webhooks | (you host the receiver) | Timestamped HMAC-signed push on the content lifecycle. | webhooks.md |
| Signed preview | GET /preview/:token | One unpublished document via a short-lived token. | § Preview tokens |
| Image transforms | GET /media/:id/t/:ops | Resize, crop to the focal point, convert (AVIF/WebP) and re-encode an image on request; unsigned URLs are held to a size allowlist, signed ones are not. | § Image transforms |
Authentication
Requests are anonymous by default and resolved through each resource's read policy, so an unauthenticated caller only ever sees published content. No credentials are required for the public delivery use case — the common headless setup needs no auth at all.
To read drafts / in-review / archived content, authenticate as an editor or admin with a JWT bearer token:
Authorization: Bearer <token>The same token authenticates GraphQL (POST /gql and its WebSocket) — see the
bearerAuth security scheme in the OpenAPI spec.
Obtaining a token
Tokens are AshAuthentication user JWTs. Headless clients get one by posting
credentials to POST /api/auth/sign_in:
curl -s -X POST http://localhost:4000/api/auth/sign_in \
-H 'content-type: application/json' \
-d '{"email": "[email protected]", "password": "…"}'// 201 Created
{
"token": "<JWT>",
"user": { "id": "…", "email": "[email protected]", "role": "editor" }
}user.role is the caller's effective tier on the organization this request
resolved to — the same per-org authorization boundary the server enforces
(#419), not a single global role. It therefore changes with the host you dial
(acme.example.com vs beta.example.com), and is "none" on an org where the
account has no tier. Shape UI on it only for the org you signed in against.
The account must already exist with an
:editoror:adminrole — signup always creates a:viewer. See Creating an admin user in the README for seeding the first admin and promoting other users.
Then send the token on JSON:API / GraphQL requests:
curl -s 'http://localhost:4000/api/json/posts?filter[state]=draft' \
-H 'accept: application/vnd.api+json' \
-H "authorization: Bearer $TOKEN"Notes:
- Invalid credentials return a generic 401 (
{"errors": [...]}) — the response never reveals whether the email exists. Missing fields return 422. - The endpoint is rate-limited per IP under the tight
authbucket (40/min — a two-factor sign-in spends two of it, one for the password and one for the code). - Tokens are verified against the
KilnCMS.Accounts.Tokenstore (require_token_presence_for_authentication?is on), so revocation (sign-out / log-out-everywhere) takes effect immediately. - The browser
/sign-inLiveView remains the path for the interactive admin/editor UI (it establishes a session, not a bearer token).
Accounts with two-factor authentication
If the account has TOTP enabled, the password alone is not enough: the
response is a 200 (not a 201) and carries no bearer token, only a
short-lived handle for finishing the sign-in.
// 200 OK
{
"two_factor_required": true,
"pending_token": "<opaque>",
"expires_in": 300
}Redeem it with a code from the authenticator app — or one of the account's one-time recovery codes:
curl -s -X POST http://localhost:4000/api/auth/sign_in/verify \
-H 'content-type: application/json' \
-d '{"pending_token": "<opaque>", "code": "123456"}'That returns the same 201 Created body the single-step flow gives.
Branch on the status code, not on the presence of token — 201 means
signed in, 200 means one step to go. A client that only looks for token
will silently treat "second factor required" as a failure.
Notes:
- Treat the pending token as a credential. It is opaque and encrypted, but what it encrypts is the sign-in this exchange will complete — it is safe because of the encryption, not because of what is inside. Do not log it, do not put it in a URL, and do not persist it past the exchange. (It is not sufficient on its own: redeeming it also takes a valid code.)
- It is redeemed at most once. A verify request that succeeded cannot be
replayed. A wrong code or a
429leaves it usable, so a client can retry the code without restarting. - Codes are budgeted per account — 5 per 15 minutes, across every node — and the
budget is shared with the browser prompt at
/sign-in/verify. A429here is not reset by signing in again; it carriesRetry-Afterin whole seconds. - Errors from both steps carry a stable
codealongsidedetail:invalid_credentials,missing_parameters,pending_expired,invalid_code,too_many_attempts. A parameter you supplied with the wrong JSON type is not reported as missing — sendcodeas a string, since a leading zero makes the integer form fail only some of the time. - For unattended server-to-server use prefer an API key over a user password. Keys carry no second factor by design, so they are unaffected by any of the above.
In-process server code can skip the HTTP round-trip and mint a token directly:
strategy = AshAuthentication.Info.strategy!(KilnCMS.Accounts.User, :password)
{:ok, user} = AshAuthentication.Strategy.action(strategy, :sign_in, %{
"email" => "[email protected]", "password" => password
})
token = user.__metadata__.tokenAPI keys (third-party access)
For unattended integrators (a build pipeline, another app, an LLM client),
issue a long-lived API key instead of sharing user credentials. An admin
mints keys at /editor/api-keys; the plaintext is shown once at
creation (only a hash is stored) and looks like kiln_<base62>_<crc>.
Send it on the same Authorization: Bearer header — the kiln_ prefix tells it
apart from a JWT:
curl -s 'http://localhost:4000/api/json/posts' \
-H 'accept: application/vnd.api+json' \
-H "authorization: Bearer $KILN_API_KEY"- A key authenticates as its owning user, inheriting that user's read scope
(role + audiences). For public-content-only access, mint the key on a
:vieweraccount. - Keys carry an
accessscope, chosen at mint and immutable after:- Read-only (the default): any write attempted with a read key is forbidden at the policy layer regardless of the owner's role.
- Read + write: may additionally author content over all three
key-authenticated surfaces — JSON:API (
POST/PATCH/DELETE), GraphQL (mutations), and MCP (/mcp) — as its owning user, within that user's role. An editor creates/updates/submits drafts; return-to-draft, publish, unpublish and (soft-)delete require an admin — an editor submits for review, and deciding the outcome is the admin's half; the hard delete (:purge) is never exposed to any key./mcpadditionally exposes no publish/delete tools at all (LLM authoring is draft-only). See json-api.md → "Writing", headless-graphql-api.md → "Mutations", and mcp.md.
tag_idsreplaces, it doesn't merge (#521): sending it on a content write overwrites the entire tag set, so a partial list detaches every tag not named. To attach/detach a subset instead, useadd_tag_ids/remove_tag_ids(JSON:API, GraphQL) — MCP'supdate_page/update_post/update_entrytake the same three arguments. See "Writing tags — replace vs merge" in json-api.md or headless-graphql-api.md, and mcp.md → "Tools".- Keys always expire and can be revoked immediately from the admin UI; an expired/revoked key returns 401.
- Works on JSON:API (
/api/json), GraphQL (/gql, incl. the subscription WebSocket via thetokenconnection param), and MCP (/mcp).
Reading drafts
A bearer token widens the :read and :search policies for editors/admins,
but not every query returns drafts. Pick the right surface:
| Goal | Surface | Returns drafts with bearer? |
|---|---|---|
| List / filter drafts | JSON:API GET /api/json/<type>?filter[state]=draft | ✅ yes |
| Search drafts | JSON:API /search · GraphQL searchPosts/searchPages | ✅ yes |
| Fetch one by id | JSON:API GET /api/json/<type>/:id | ✅ yes |
| Fetch one by slug | GraphQL postBySlug/pageBySlug, categoryBySlug, tagBySlug, tagGroupBySlug | ❌ no — published only |
| Share a specific draft | GET /preview/:token (signed link) | n/a (no account needed) |
| Render a shared draft in a front end's own templates | JSON:API by id and GET /api/content/:type/:slug with x-kiln-preview-token | n/a — the token, see Headless draft mode |
GraphQL
*BySlugnever returns drafts. Those queries run the:public_by_slugaction, which hard-filtersstate == :publishedinside the action — independent of the actor. A bearer token makes no difference; a draft slug always resolves tonull. To fetch a known draft slug, use the JSON:API filter (filter[slug]=<slug>&filter[state]=draft) or a preview token.
The table above cuts both ways: a delivery site that attaches an
editor-minted key gets drafts on plain index and search reads it never asked
for. Mint delivery keys on a :viewer account and prefer the /published
surfaces — see headless-consumer-guide.md →
"Drafts".
Core content endpoints
Full filtering / sorting / pagination reference: json-api.md. A quick map:
| Resource | Collection | Single record | Extra reads |
|---|---|---|---|
| Page | GET /api/json/pages | GET /api/json/pages/:id | /pages/search, /pages/semantic-search, /pages/autocomplete |
| Post | GET /api/json/posts | GET /api/json/posts/:id | /posts/published, /posts/search, /posts/semantic-search, /posts/autocomplete |
| MediaItem | GET /api/json/media-items | GET /api/json/media-items/:id | /media-items/search, /media-items/library — uploads: § Uploading media |
| Category | GET /api/json/categories | GET /api/json/categories/:id | /categories/by-slug/:slug |
| Tag | GET /api/json/tags | GET /api/json/tags/:id | /tags/by-slug/:slug |
| TagGroup | GET /api/json/tag-groups | GET /api/json/tag-groups/:id | /tag-groups/by-slug/:slug |
| TypeDefinition | GET /api/json/type-definitions | GET /api/json/type-definitions/:id | /type-definitions/by-name/:name (editor+ key; read-only) |
| ContentRelease | GET /api/json/releases | GET /api/json/releases/:id | ?include=items (editor+ key; read-only) |
| ReleaseItem | GET /api/json/release-items | GET /api/json/release-items/:id | ?filter[release_id]= (editor+ key; read-only) |
Taxonomy (Category/Tag/TagGroup) is world-readable and now mirrors the GraphQL
taxonomy surface over JSON:API (#185) — list, fetch by id, or fetch by slug. A
tag group is the bucket a tag is filed under — see json-api.md
→ "Routes" and headless-graphql-api.md → "Taxonomy"
for the tag_group_id/tagGroup relationship and content_types scoping.
*/search (keyword) and */semantic-search (vector) take their inputs as
top-level query params — ?query=<text>&locale=<code> (plus optional
category_id, author_id, state, tag_ids facets on keyword search).
Semantic search requires the embedding model (KilnCMS.Search.semantic?); when
it's unavailable the endpoint returns an empty result set rather than an error.
All requests use the JSON:API media type:
# Published posts, newest first, page of 10 with a total count
curl -s 'http://localhost:4000/api/json/posts?sort=-published_at&page[limit]=10&page[count]=true' \
-H 'accept: application/vnd.api+json'
# Drafts (requires an editor/admin bearer token)
curl -s 'http://localhost:4000/api/json/posts?filter[state]=draft' \
-H 'accept: application/vnd.api+json' \
-H 'authorization: Bearer <token>'Expanding references
GET /api/content/:type/:slug?expand=<field>[,<field>…] adds the targets of
those :reference custom fields to the json artifact, each with its own
fired json artifact, so a front end renders a recipe and its ingredients
from one request:
curl -s 'http://localhost:4000/api/content/recipe/pancakes?expand=ingredients'{
"type": "recipe",
"title": "Pancakes",
"custom_fields": {"ingredients": [{"id": "…", "type": "ingredient", "slug": "flour", "title": "Flour"}]},
"expanded": {
"ingredients": [
{"position": 0, "link": {}, "id": "…", "type": "ingredient", "slug": "flour",
"target": {"type": "ingredient", "title": "Flour", "blocks": ["…"], "custom_fields": {}}}
]
}
}- Every requested field is a key of
expanded; a field with no targets, or one the type does not have, is[]. Items keep the field's order;positionis the item's place in the stored list. - Only targets an anonymous reader could fetch are expanded: published,
public, not passphrase-locked. A draft target is left out as if it were not
linked, even when the request unlocks the source. The response is the same
for every caller, so it keeps
publiccache headers. ItsETagalso covers the expanded targets, so republishing a target invalidates it. linkis the data stored on the connection (a relationship's link fields), passed through as stored;{}when the field has none.targetisnullfor a target that is published but not fired yet; a firing job is queued for it, as a direct read of that target would.- At most 10 fields and 100 targets per field.
expandneedssurface=jsonand cannot be combined withas_of; both are a400 invalid_expand.
An expanded response reads the source's edges and its targets from the
database, so it does not have the plain artifact read's cache-only path
through a database outage (it answers 503 then). JSON:API's
?include=content_links still carries the edges by id; GraphQL's typed
references(field:) is the other way to read targets in one request
(headless-graphql-api.md).
Media in delivered content
A library item reaches a client in one shape, wherever it appears: as the
value of a :media custom field (one object, or a list of them for a gallery
field with multiple), and — in the fired :json artifact
(GET /api/content/:type/:slug, /api/sync) — under a media key on every
image block and on each entry of a gallery block's images. Write one
decoder for it.
{
"id": "6f1c…", "url": "https://cdn.example.com/kiln/orig.jpg", "alt": "The kiln at dusk",
"width": 1600, "height": 900, "mime_type": "image/jpeg",
"focal": { "x": 0.5, "y": 0.3 },
"variants": [
{ "label": "thumb", "width": 400, "height": 225, "format": "image/jpeg",
"url": "https://…/thumb.jpg", "bytes": 18204, "cropped": false },
{ "label": "thumb.webp", "width": 400, "height": 225, "format": "image/webp",
"url": "https://…/thumb.webp", "bytes": 12950, "cropped": false },
{ "label": "medium", "width": 1024, "height": 576, "format": "image/jpeg",
"url": "https://…/medium.jpg", "bytes": 96133, "cropped": false },
{ "label": "card", "width": 800, "height": 450, "format": "image/jpeg",
"url": "https://…/card.jpg", "bytes": 51870, "cropped": true }
]
}| Key | Meaning |
|---|---|
id, url, alt | The item, its original file, and its library alt text. Always present. An image block's own alt (on the block) is the one to show; media.alt is the library default behind it. |
width, height | The original's size in pixels — lay out before the image loads. null until the item has been processed (below), and for a document. |
mime_type | The original's content type. |
focal | The focal point, as fractions of width and height from the top left. Crop around it (object-position, contentMode + offset). null for anything that is not an image. |
variants | Every derived rendition, smallest first. format is its MIME type: each size exists in the original's format and in each configured alternate (WebP by default, AVIF opt-in). bytes is the encoded size, null for a rendition made before sizes were recorded or by an external image backend. |
variants[].cropped | true for a rendition that is not the same picture at another size — the focal-aware card crop (800×450) and a video's poster frame. Choose by width only among cropped: false entries, and add the original (url, width) as the largest; pick a cropped one by label when you want that framing. |
Before processing. Variants and dimensions are derived in the background
after an upload, so a document saved in the first seconds after one stores
"variants": [] and null dimensions. Show url in the meantime. When the
item's processing finishes — or its focal point or alt text changes — every
published document showing it is re-fired, so the fired artifact catches up
on its own. The value stored on the record (what JSON:API and GraphQL return
in custom_fields) is refreshed when the record is next saved; an operator
can refresh every record at once with mix kiln.media.resnapshot --apply
(a dry run without --apply).
Values stored before Kiln 1.6 carry only id, url and alt until then.
Treat every other key as optional; the delivery schema
(§ Schema discovery) marks only id and
url required.
Sparse fieldsets on artifacts
GET /api/content/:type/:slug?fields=<name>[,<name>…] returns only the named
keys of the json artifact: top-level keys (title, slug, blocks,
custom_fields, …) and custom_fields.<name> for a single custom field. It is
for clients that want a list row or a widget, not the whole document, and do
not run GraphQL:
curl -s 'http://localhost:4000/api/content/recipe/pancakes?fields=title,custom_fields.hero_image'
# {"id": "…", "type": "recipe", "title": "Pancakes",
# "custom_fields": {"hero_image": {"id": "…", "url": "…", "alt": "…"}}}idandtypeare always kept, and so isexpandedwhen?expand=is also given.custom_fieldson its own keeps every custom field.- Unknown names are ignored, not refused.
- The
ETagis a digest of the filtered body. It moves only when a kept value does: after a republish that changed onlyblocks, a?fields=titlerevalidation is still a304. Order and repeats don't change the body, so every spelling of one fieldset shares theETag. The canonical spelling (sorted, de-duplicated, withoutid/type) comes back asx-kiln-fields. Send that one if you want every client to share a CDN entry, because a CDN keys on the full URL. - At most 50 names.
fieldsneedssurface=json; otherwise it is a400 invalid_fields. It works with?as_of=too. /api/syncalways embeds the whole artifact. For sparse lists, use JSON:API'sfields[<type>]=or GraphQL.
A fieldset response does not satisfy the full document schema from
GET /api/schema, which marks blocks,
custom_fields and the other keys required. Decode it into a narrower type.
native-apps.md covers using it from a mobile app.
Uploading media
Files enter the media library over REST. Every route below runs the same
pipeline as a file dropped on /editor/media (KilnCMS.Media.Ingest): the
file is byte-sniffed (the name and Content-Type you send are ignored),
size-capped per kind, metadata-stripped (EXIF/GPS from images, author data
from PDFs, container metadata from video/audio — see
media-pipeline.md), stored, recorded as a MediaItem
attributed to the credential's user, and its variants/poster are derived in the
background.
| Route | Body | Use it for |
|---|---|---|
POST /api/media | multipart: file + metadata fields | Most uploads, up to the per-kind caps below |
POST /api/media/import-url | JSON: url + metadata | A file already on the public web |
POST /api/media/uploads → PUT → POST /api/media/uploads/complete | JSON | Large files, sent straight to object storage (below) |
PATCH /api/json/media-items/:id | JSON:API | Editing metadata afterwards (json-api.md); GraphQL updateMediaItem is the same write |
Who may upload: a read + write API key (or a JWT) on an editor or
admin account — the same gate as content writes. A read-only key is 403
whatever its owner's role; so is a :viewer; no credential is 401. On
POST /api/media that answer comes before the body is read: the endpoint
leaves this one route's body unparsed until the caller is authenticated and
allowed to create media, so an unauthorized client cannot make the server
spool a large upload to disk first. Deleting media is not routed for any key.
Metadata — optional on every upload route, as form fields or JSON keys:
| Field | Type | Notes |
|---|---|---|
alt | string | Alt text. |
caption | string | |
decorative | boolean | A decorative image correctly has no alt text. |
focal_x, focal_y | number, 0.0–1.0 | Focal point the smart crops centre on (default 0.5). |
tag_ids | array of tag ids | tag_ids[]=… repeated in a multipart form. Every id must be a tag on this site. |
filename | string | import-url only: the name to record instead of the URL's. |
Anything else in the request — url, storage_key, content_type,
dimensions — is ignored: those come from the bytes. There are no folders; tags
are the library's organising axis. Metadata is validated before the file is
processed, so a bad focal point is a 422 with nothing stored.
curl -s http://localhost:4000/api/media \
-H "authorization: Bearer $KILN_API_KEY" \
-F [email protected] \
-F alt='The kiln at dusk' -F focal_x=0.3 \
-F 'tag_ids[]=<tag uuid>'curl -s http://localhost:4000/api/media/import-url \
-H "authorization: Bearer $KILN_API_KEY" \
-H 'content-type: application/json' \
-d '{"url": "https://example.com/cat.png", "alt": "A cat"}'A successful upload answers 201 with a Location header and the item as
a JSON:API resource object — the same type, attributes and tags
relationship GET /api/json/media-items/:id returns, plus:
{
"data": {
"type": "media_item",
"id": "…",
"attributes": { "filename": "kiln-at-dusk.jpg", "content_type": "image/jpeg",
"kind": "image", "url": "https://…", "alt": "The kiln at dusk",
"focal_x": 0.3, "uploaded_by_id": "…", /* … */ },
"relationships": { "tags": { "data": [{ "type": "tag", "id": "…" }] } },
"links": { "self": "/api/json/media-items/…" },
"meta": { "processing": false }
}
}meta.processing: true means an audio/video file's metadata strip is still
running in the background (KILN_AV_STRIP_MODE=deferred): the item exists and
is editor-visible, but its url serves nothing until the strip finishes.
variants fill in shortly after any image upload.
Size limits. The per-kind caps are the library's: images 10 MB, documents
25 MB, captions 2 MB, audio 100 MB, video 500 MB — applied to the bytes
actually received, after sniffing. POST /api/media accepts a body up to the
largest cap (the rest of the API keeps the 8 MB request cap). import-url
downloads at most 25 MB (the body is buffered in memory) and follows up to
three redirects, each re-validated.
URL imports are SSRF-guarded. The server fetches through
KilnCMS.SafeFetch: only public http(s) addresses, resolved once and
connected to by address, so a hostname cannot be re-pointed at an internal
service between the check and the connection. A refused URL is
422 unsafe_url, and the response never says what a name resolved to.
Direct uploads for large files
A reverse proxy or CDN in front of Kiln often caps request bodies below the video limit (Cloudflare's is 100 MB on most plans). Direct uploads send the bytes straight to object storage and only the small JSON requests to Kiln:
# 1. Ask for an upload URL (byte_size is the exact file size).
curl -s http://localhost:4000/api/media/uploads \
-H "authorization: Bearer $KILN_API_KEY" -H 'content-type: application/json' \
-d '{"filename": "firing.mp4", "byte_size": 412345678}'
# → 201 {"data": {"token": "…", "upload_url": "https://…", "method": "PUT",
# "headers": {"content-length": "412345678"}, "expires_at": "…", "max_bytes": 500000000}}
# 2. PUT the bytes to upload_url with exactly those headers (no Kiln credentials).
curl -s -X PUT "$UPLOAD_URL" -H 'content-length: 412345678' --data-binary @firing.mp4
# 3. Complete — metadata goes here.
curl -s http://localhost:4000/api/media/uploads/complete \
-H "authorization: Bearer $KILN_API_KEY" -H 'content-type: application/json' \
-d '{"token": "…", "alt": "Loading the kiln"}'
# → 201, the same media item body as POST /api/media- The upload URL is a presigned
PUTinto the private bucket, valid 15 minutes, with the declared size signed in — the store refuses any other length. The token may be completed within an hour, only by the same user on the same site. - Completion copies the staged object down and runs it through the same
pipeline as
POST /api/media— sniffed, stripped, stored under its own key — then deletes the staged copy whatever the outcome. A token completes once; a staged upload never completed is deleted when its token expires. - Needs the S3 storage adapter with a private bucket (
S3_PRIVATE_BUCKET), and a CORS rule on that bucket if browsers upload to it — see media-pipeline.md. Without one,POST /api/media/uploadsanswers501 direct_uploads_unavailable; usePOST /api/media.
Upload errors
Refusals use the error envelope; branch on code:
| Status | code | Meaning |
|---|---|---|
| 401 | unauthorized | No credential. |
| 403 | forbidden | Read-only key, or an account that can't create media. |
| 413 | too_large | Over the cap for the file's kind (or the import download cap). A body over the route's limit is 413 too. |
| 415 | unsupported_media_type | Not a kind the library accepts. |
| 422 | missing_file | No multipart file field. |
| 422 | invalid_parameter | A metadata field failed validation; detail names it. |
| 422 | create_failed | The item couldn't be saved — usually a tag_ids entry that isn't a tag on this site. Nothing is stored. |
| 422 | encrypted | A password-protected PDF — its metadata can't be removed. |
| 422 | strip_unavailable / strip_failed | Metadata couldn't be removed, so the file was refused rather than stored with it. |
| 422 | unsafe_url / fetch_failed | import-url: the URL isn't a public address, or didn't answer 2xx. |
| 422 | invalid_upload_token / not_uploaded / size_mismatch | Direct uploads: a bad or someone else's token; nothing staged (or already completed); the staged size isn't the declared one. |
| 429 | too_many_requests | The media_upload bucket (§ Rate limits). |
| 501 | direct_uploads_unavailable | This deployment can't presign (no S3 private bucket). |
| 502 | storage_failed | The object store refused the write; retry. |
| 503 | insufficient_storage | Out of temp disk to strip a video; retry after retry-after. |
Not over MCP. /mcp has no upload tool on purpose: media has no draft
state — an upload is live at its public URL the moment it lands — and /mcp's
promise is that an LLM's work stays a draft until a human approves it. See
mcp.md.
Version history (revisions)
A document's version history — the same history the editor's version panel shows — is readable, and restorable, over three hand-written routes. It is an authenticated, editor-tier surface: history carries every draft a document ever held, so there is no anonymous access at all.
| Route | Answers |
|---|---|
GET /api/content/:type/:id/revisions | The document's revisions, newest first — ids, actions, timestamps and the names of the fields each write changed |
GET /api/content/:type/:id/revisions/:version_id | One revision: its own changes, plus the full snapshot of the document at that revision |
POST /api/content/:type/:id/revisions/:version_id/restore | Revert the document's content to that revision, as the caller |
:type is any content type's name — page, post, a project type, or an
admin-defined (dynamic) type's machine name. :id is the document's id, not
its slug: a slug is per-locale and can change; a document's history cannot.
curl -s "http://localhost:4000/api/content/post/$POST_ID/revisions?limit=10" \
-H "authorization: Bearer $KILN_API_KEY"{
"data": [
{
"id": "8c1f…", // the version id
"action": "update", // the action that wrote it (update, autosave, publish, restore_version, …)
"action_type": "update", // create | update | destroy
"inserted_at": "2026-09-19T09:14:03.118220Z",
"user_id": "5b2e…", // the acting user's id — null for a system write
"changed_fields": ["seo_title", "title"]
}
],
"meta": { "limit": 10, "next_cursor": "MjAyNi0wOS0xOVQwOTox…" }
}- Values only on the single-revision read. The list names the editorial
fields each write touched (bookkeeping such as the derived
search_textcolumn is left out);GET …/revisions/:version_idadds the version's own rawchangesand thesnapshot— every tracked field at that revision. History is stored as changes only, so the snapshot is folded from every version up to that one: the same fold a restore writes back and the editor's compare view diffs. Values are in their stored JSON shape (the block tree as typed blocks). - The acting user is an id. User records are not exposed over the API (PII,
#183);
user_idisnullwhen the write had no actor. - Pagination is newest first:
?limit=(1–100, default 20) and an opaque?cursor=— pass back the previous page'smeta.next_cursor, which isnullon the last page. The cursor is a keyset, so autosave coalescing pruning rows between two reads never shifts or repeats a page. - Restore runs the type's own
:restore_versionaction as the caller. It moves content fields only, never workflow state — restoring a published document's old text does not unpublish it — and is itself recorded as a new revision, which the response carries:{"data": {"id", "type", "state", "restored_version_id", "revision": {…}}}. A restore that cannot land (a category or media item deleted since that version) answers422restore_failed, naming the field.
Who gets what. Authorization is the version resources' own policies, with your credential as the actor and the request host's org as the tenant:
| Caller | List / read | Restore |
|---|---|---|
| No credential | 401 | 401 |
| Admin; editor whose read scope covers the type | ✅ | ✅ with a JWT or a :read_write key |
| Same, with a read-only key | ✅ | 403 — refused by the content policies |
Viewer; editor whose readable_types leave the type out | 404 | 404 |
Anything the caller may not read history for is a 404, never a 403, whether
or not the document exists — a document in another org, a document of a
different type than :type names (dynamic types included), a version of a
different document, a trashed document. A 403 is reserved for a caller who
can read the history but may not write the document. Malformed ids and
cursors are 400. Every response, refusals included, is Cache-Control: private, no-store.
Password-protected content
An editor can put a shared passphrase on a published document (#496) — the WordPress "password protected post" analogue, for a client proposal or an early-access page. It is deliberately weak access control: one secret, no per-reader identity, no revocation but rotation, no audit trail. Use audiences (memberships.md) when you need real access control; this exists for convenience.
A locked document answers 401 instead of 200:
{"errors": [{"status": "401", "code": "password_required",
"detail": "This content is protected. POST the passphrase to …"}]}Exchange the passphrase for a grant token, then present the token on reads:
TOKEN=$(curl -s -X POST http://localhost:4000/api/content/page/proposal/unlock \
-H 'content-type: application/json' \
-d '{"passphrase": "shared secret"}' | jq -r .token)
curl -s http://localhost:4000/api/content/page/proposal \
-H "x-kiln-unlock: $TOKEN"?unlock=<token> works too, for callers that cannot set headers (a static build
step, for instance).
Notes that matter in practice:
The token expires in 12 hours and dies on rotation. It names a fingerprint of the passphrase, not the document — so the moment an editor changes the passphrase, every outstanding token stops working.
expires_incomes back with the token.A token is document-scoped. Two documents sharing a passphrase still have different fingerprints, so a token for one will not read the other.
Unlocked responses are
private, no-storeand carry noETag. The body is a function of your grant rather than of the URL, so it must not be shared-cached. That includes a point-in-time snapshot (?as_of=) read with a grant. Budget for that: a locked document is not a CDN-friendly one.A wrong passphrase and an unlocked document answer identically (
401invalid_passphrasefrom the unlock endpoint), so it cannot be used to enumerate which documents are locked.Locked documents are absent from every discovery surface — the sitemap, feeds,
llms.txt, the blog index, the/publishedcollection routes, the historical collection (GET /api/content/:type?as_of=and GraphQLcontentAsOf), keyword and semantic search, related content, and any configured Meilisearch index. If a document is locked, the only way to reach it is to know its URL and its passphrase.The
:publishedread carries that as a filter, not a policy clause, so it holds for an authorized caller too — a locked document is absent from/publishedand its GraphQL twin whatever credential is presented. Those are delivery feeds; an editor reaches their own locked drafts through the editor reads, which are unaffected.Note the asymmetry with an audience gate, which the index does show, with a "Members" badge: gated metadata is a marketing surface — you want a reader to know members-only content exists and what they would get. A passphrase is a shared secret handed to specific people, so disclosing the document's title and URL is the one thing it must not do (#1032).
The unlock endpoint has its own tight rate-limit bucket (see Rate limits) — it is the guessing surface for a shared secret, and there is no account to lock out instead.
Sync (delta) API
GET /api/sync mirrors a site's public content: the whole corpus once, then
only what changed since — upserts and deletions. It is the Contentful Sync
API shape (initial=true → cursor → deltas with deleted entries), and it exists
because filter[updated_at][gt] on JSON:API cannot see a document leave: one
that is unpublished, archived, deleted, locked or moved to a members-only
audience simply stops matching, which a poller cannot tell from "unchanged".
# 1. First run: the full snapshot (optionally one type, one surface)
curl -s 'https://cms.example.com/api/sync?initial=true&type=post&limit=100'
# 2. While "has_more" is true, follow the cursor straight away
curl -s 'https://cms.example.com/api/sync?cursor=SFMyNTY…'
# 3. Once "has_more" is false, store the cursor; poll with it later
curl -s 'https://cms.example.com/api/sync?cursor=<stored>'Every response is {"items": […], "cursor": "…", "has_more": bool}, and each
item is one of
{"op": "upsert", "type": "post", "id": "…", "slug": "hello", "locale": "en",
"published_at": "…", "updated_at": "…", "artifact": { …the fired surface… }}
{"op": "delete", "type": "post", "id": "…"}artifact is exactly what GET /api/content/:type/:slug?surface=… serves.
Apply items in order, keyed by id: an upsert replaces your copy, a delete
removes it.
| Parameter | Meaning |
|---|---|
initial=true | Start a new sync. Exactly one of initial and cursor. |
cursor | Resume. Carries its own scope, so type/surface are ignored with it. |
type | One content type (singular, compiled or admin-defined). Default: every type. |
surface | Artifact surface embedded in upserts: json (default), json_ld, web, llm. |
limit | Items per page, 1–500 (default 100). |
Visibility is always anonymous — whoever calls. An upsert is a document an
anonymous reader could fetch right now: published, audience :public, not
passphrase-locked, not archived, of a live content type. A bearer token does
not widen it (use JSON:API for drafts). A document that stops qualifying
arrives as a delete carrying its id and type only — never its body, slug,
or the reason. And a delete only ever names a document this endpoint has
already served: a draft, a members-only post or a locked page that was never
public never appears in a delta at all, not even as an id.
Semantics a client must allow for.
- At-least-once. A delta window trails the clock by a few seconds (so a write whose transaction is still committing is not skipped), and windows overlap slightly after the snapshot. The same item can arrive twice; applying it twice is harmless.
- A delete for something you don't hold can happen (a document that became public and private again between your polls). Ignore it.
- Edits re-send. Any editorial write to a public document re-sends it, even one that did not change the rendered body.
- Not seen: a change that writes no version to the document itself —
editing a fragment it embeds, or its type's custom-field definitions. Its
artifact re-fires, but the document is not reported. A periodic full resync
(
initial=true) covers that.
Errors. 400 invalid_cursor — the cursor was tampered with, belongs to
another site, or was signed before a SECRET_KEY_BASE rotation: start again
with initial=true. 404 — unknown type. 503 artifact_compiling (with
Retry-After) — a document on this page has no fired artifact yet (published
a moment ago, or imported and never fired); retry the same request, with
the same cursor. Responses are Cache-Control: no-store, and sync fetches are
not counted as views.
artifact_compiling is all-or-nothing per page. One request queues a fire
for every document on the page that lacks an artifact, then answers 503;
the cursor you hold does not move, so no document is skipped, and a retry once
those fires have run serves the whole page. On a site with a large backlog of
never-fired content, an initial sync can still meet one 503 per page — so
keep the last good cursor and retry that page with backoff, rather than
restarting from initial=true or giving up after a few seconds. An operator
can clear such a backlog before the first sync with mix kiln.refire_all
(on a release: bin/kiln_cms rpc "KilnCMS.Firing.Sweep.run()"), which fires
every published document.
Both official clients wrap the loop: kiln.sync({ cursor }) in
@kiln-cms/client, KilnClient.sync(cursor: …) in kiln_client.
Syncing from a phone (where to keep the cursor, backoff, background-refresh limits) is covered in native-apps.md.
Locale discovery
GET /api/locales returns the site's configured content locales and the
default, so a headless frontend can build a locale switcher or hreflang set
without hard-coding the language configuration. No auth required; the response
is cacheable (Cache-Control: public, max-age=300) since locales change only on
redeploy.
curl -s http://localhost:4000/api/locales
# {"default":"en","locales":["en","fr","fr-CA"],
# "fallbacks":{"en":[],"fr":["en"],"fr-CA":["fr","en"]}}Pass the returned codes as the locale argument/param to the other surfaces
(GET /api/content/:type/:slug?locale=fr, postBySlug(slug:, locale:), etc.).
fallbacks is this site's chain for each locale — see the next section.
Locale fallback
Content is one document per locale, so a translation that has not been published is a missing document. Every delivery surface answers that the same way: it walks the site's fallback chain for the requested locale and serves the first published variant it finds.
fr-CA → fr → en- The chain is a site setting, edited at
/editor/locales(admins), over an operator default —config :kiln_cms, :i18n, fallbacks: %{"fr-CA" => ["fr", "en"]}. - A locale with no chain falls back to the default locale — what the
built-in site has always done. A chain of
[]means never fall back (a missing translation is a 404). A chain is taken as written:fr-CA → frdoes not quietly continue to the default. - Only readable variants take part. A variant the caller may not read —
gated to an audience, passphrase-locked, unpublished — is skipped like a
missing one. On the artifact API, if nothing on the chain is readable but a
locked variant exists, the answer is
401 password_requiredfor the first locked one, andPOST …/unlockwith the same?locale=verifies against it. - Navigation is stricter.
/api/menus/:keyand GraphQLmenufollow a configured chain but never take the implicit hop to the default locale on their own: English navigation on a French page is worse than none unless the site said otherwise.
Per request
| Parameter | Meaning |
|---|---|
locale=fr-CA | Where the walk starts. Defaults to the site's default locale. |
fallback=false | Serve locale or nothing (true/false/1/0; anything else is 400 invalid_fallback). |
fallback_locale=fr | Try locale, then this one — instead of the site's chain. |
They are accepted by GET /api/content/:type/:slug (and POST …/unlock),
GET /api/resolve, GET /api/menus/:key, the JSON:API
GET /api/json/<type>/by-slug/:slug routes, and GraphQL *BySlug and menu
(as the locale, fallback and fallbackLocale arguments).
Which locale was served
- HTTP surfaces set
x-kiln-localeandContent-Languageto the served locale./api/resolveand/api/menusalso carry it aslocalein the body; the artifactjsonsurface carries it in the document, and the JSON:API resource inattributes.locale. - GraphQL cannot set a header per field: select
localeon the result. - ETags name the served locale, and every cache key in front of Kiln is the URL — which carries the requested locale and the fallback parameters — so a shared cache never mixes two answers. A settings change drops the site's cached delivery lookups at once rather than waiting out a TTL.
An unsupported locale is a 400
?locale=de on a site that does not run German answers
400 unsupported_locale (naming GET /api/locales) on every surface above,
instead of silently serving the default locale: a typo like fr_CA answered in
English is indistinguishable from a missing translation. The same goes for
fallback_locale. GraphQL returns an error on the field.
Schema discovery (typed clients)
GET /api/schema returns a JSON Schema (draft 2020-12) describing exactly what
GET /api/content/:type/:slug?surface=json serves: the _type-discriminated
block union and one document schema per content type. Generate types from it,
or validate responses against it (#430).
curl -s http://localhost:4000/api/schema | jq '.["$defs"] | keys'
# ["block","block_accordion",…,"content_page","content_post","portable_text_block"]| Parameter | Effect |
|---|---|
?type=post,page | Only these content types. An unknown name is a 400, not a silent full export. |
?blocks=only | The block union alone — no content types, no database read. |
It is per site: dynamic content types and custom fields are
organization-scoped, so the document is built for the org the request host
resolves to, and an admin adding a custom field changes it without a redeploy.
No auth required; cacheable (Cache-Control: public, max-age=300).
Notable properties:
- every block object is closed (
additionalProperties: false) and pinned by a_typeconst, so aoneOfmatch is unambiguous; _idis the block's stable id — the same anchor the visual-editing bridge uses. It is absent on a block that has never been persisted;- a
columnsblock's children$refback to the union, so nesting is typed all the way down (the storage union has to stay flat — seeKilnCMS.Blocks.Columns); custom_fieldsis open, and nothing in it isrequired: a definition added after a document was last saved has no stored value and publishing does not invent one, while a point-in-time read (?as_of=) serves the values stored at that instant — which may include fields since removed;- a
required: trueblock field is listed inrequired(the key is always present) and is non-nullable — a container child now runs through the same Ash cast a top-level block does (#935), so a nested block cannot carry a nil there any more than its top-level twin can, and both share one definition. A row written before that fix could still hold a legacy nil in such a field;mix kiln.blocks.audit_requiredfinds them.
It publishes shape, not editorial content: field help_text and a
:select's option list are deliberately withheld, as is the organization id.
Type and field names are published, so a site where an unannounced content
type is itself sensitive should keep this endpoint off the public internet.
This describes the read surface. The write API's authoring shape (which
differs — a video's stored media_id/url pair is delivered as one resolved
src) is described by the OpenAPI document at /api/json/open_api.
Generating types at build time
mix kiln.export.schema --out priv/static/schema.json --pretty
mix kiln.export.schema --format ts --out assets/js/kiln.d.tsThe .d.ts emitter is built in, so no Node toolchain is needed:
export interface HeadingBlock {
_id?: string;
_type: "heading";
level?: number;
text: string;
}
export type KilnBlock = AccordionBlock | AudioBlock | … | VideoBlock;
export type KilnDocument = PageDocument | PostDocument;--blocks-only skips content types entirely (and the database with them), which
is what a CI job that only wants block types should use. --all-orgs --out <dir>
writes one document per site; mix help kiln.export.schema has the rest.
Feeds
Atom 1.0 and JSON Feed 1.1, for readers and for the RSS-driven automation every email-campaign tool is built on (#486):
| URL | Format | Scope |
|---|---|---|
GET /feed.xml | Atom 1.0 | Every syndicated content type |
GET /feed.json | JSON Feed 1.1 | Every syndicated content type |
GET /blog/feed.xml | Atom 1.0 | Posts only |
GET /pages/feed.xml | Atom 1.0 | Pages only |
GET /blog/category/<slug>/feed.xml | Atom 1.0 | Posts in one category |
GET /blog/tags/<slug>/feed.json | JSON Feed 1.1 | Posts carrying one tag |
GET /fr/feed.xml | Atom 1.0 | Everything, in French |
GET /fr/blog/category/<slug>/feed.xml | Atom 1.0 | All three at once |
A type's own feed lives under its public path segment where it has one
(/blog/… for posts) and under its plural otherwise (/pages/…) — a type served
at <base>/<slug> has no prefix of its own, and deriving one would put its feed
at /feed.xml, which is already taken. Both spellings resolve, so
/posts/feed.xml works too. A dynamic content type (D17) gets a feed at its own
segment automatically.
Delivery pages advertise these in <head> via <link rel="alternate">, so a
reader or a campaign tool finds them without being handed the URL — in the
locale of the page being read, so a French article points at the French feed.
Feeds are published and public. An audience-gated record is published and
paywalled; it never appears in a feed, which anonymous readers and third-party
aggregators fetch. The same rule holds for the optional Meilisearch index (#1006)
— it has no audience facet and its queries are anonymous, so a gated document is
kept out of it and removed from it when gating is applied. See
What is in the index — and what is not (#1006) in meilisearch.md.
A type syndicates if it already has a public index. For a dynamic content
type that is the per-type "Has a public index of published entries" checkbox in
/editor/types, which defaults to off — a type nobody chose to publish an
index for is not one whose records should be enumerable. Compiled types (Page,
Post) have public indexes by definition.
The rest of the policy is per-site (#719). /editor/feeds lists every
content type the site has with two switches each — whether it appears in the
site's feeds at all, and whether its entries carry the rendered body rather than
a summary. Both are org admin-only, and both are scoped to the site you are on.
"In feeds" is the same switch ActivityPub reads, so a type taken out of a site's feeds also stops being announced to the fediverse and leaves that site's outbox — see federation.md. The page says so.
Full content is opt-in because it hands the whole article to everything
subscribed: with it on, every anonymous reader, aggregator and scraper fetching
the feed receives the complete rendered body of every published entry of that
type. That is a publishing decision a site makes for itself, which is why it
cannot live in a config file only a deployment operator can edit — a compiled
type like post is shared by every organization on a deployment, so one
tenant's newsletter would otherwise have opted in all of them.
Underneath sits the operator default, used by any site that has not saved its own settings:
config :kiln_cms, :feeds,
exclude: ["page"], # types that should not syndicate at all
full_content: ["post"], # types whose entries carry the rendered body
entry_limit: 50 # newest N records per feed (capped at 200)A site that has never saved follows this config; saving replaces both lists for that site, and Use the operator defaults on the page drops them and goes back to following it. An empty saved list means none, which is not the same as never having said anything — that distinction is what lets a site turn full content off while the deployment default has it on.
entry_limit stays deployment-wide, and deliberately: it bounds the work a feed
request costs the server rather than expressing a publishing choice.
If the settings row cannot be read at all — a rolling deploy before the table exists, a pool timeout — feeds fall back to summaries only, not to the config. Falling back to the config there would discard the opt-out this exists for, and hand out complete articles for the duration of the fault. Exclusions keep following the config in that state, because failing closed on that axis would mean no feeds at all, and nothing in a feed is private.
Segments and locales (#720)
Every entry carries its taxonomy — <category term="slug" label="Name"/> in
Atom, a flat tags array of labels in JSON Feed — capped at 20 per entry so one
heavily tagged record cannot bloat a document served to every subscriber.
A category element is only useful if there is a feed narrow enough to act on, so a type's feed also comes scoped to one category or one tag. An unknown slug is a 404 rather than an empty document: a reader who subscribes to a typo would otherwise get something that never has anything in it, and no way to tell.
An unscoped feed carries the default locale only. A record translated into
three languages is three rows, and a feed carrying all three re-notifies every
subscriber — and every "new post → campaign" automation — three times per
publish. That left readers of other languages with no feed at all, so any
locale prefix gives that language its own: /fr/feed.xml, /fr/blog/feed.xml,
/fr/blog/category/news/feed.xml. Each language has exactly one feed, and
nobody is notified twice.
There is no /<locale>/feed.xml route — the locale prefix is stripped before
the router for every URL on the delivery site, so feeds get this the same way
pages do. /en/feed.xml therefore resolves, and advertises /feed.xml as its
own id, so the two URLs are one thing to subscribe to rather than two.
Taxonomy feeds are cached like any other, but they are not dropped on publish: computing which of them a record belongs to needs its tags and category, and the cache bust runs inside the publish transaction, where a relationship load can abort it and lose the publish (#660). They are five minutes stale at worst.
Locale feeds are dropped on publish, along with the site-wide and per-type ones. A record's locale is a plain attribute rather than a relationship, so it costs the bust nothing — and a locale feed that only ever refreshed on a TTL would leave the one reader these exist for as the only one without timely invalidation.
Feeds are cached for five minutes and dropped on any publish/unpublish, so a new post is in the feed on the next fetch rather than after the TTL.
Webhooks
Admins register receivers in the editor UI (/editor/webhooks); each endpoint
has its own signing secret, stored encrypted. On publish/unpublish/update KilnCMS
dispatches <type>.published, <type>.unpublished and <type>.updated events;
<type>.archived, <type>.deleted (trashed) and <type>.restored tell a mirror
when a document leaves or comes back, and opt-in <type>.created announces new
drafts. Deliveries go out through an Oban worker with retry/backoff. Every request
carries x-kilncms-webhook-signature: t=<unix>,v1=<hex>, an HMAC-SHA256 of
"<t>.<raw body>" keyed by the endpoint secret, plus an x-kilncms-delivery-id
that stays the same across retries. Verify the signature and reject a t more than
five minutes from your clock before trusting the payload. The JS and Elixir
clients ship a verifyWebhook / KilnClient.Webhook.verify/4 helper. Selectable
events are derived at runtime from every registered content type × verb.
Reliability. Every delivery is recorded on a ledger shown at
/editor/webhooks: per-attempt status, last HTTP code, and last error. A
delivery that exhausts its retries (5 attempts, exponential backoff) is marked
failed; after 10 exhausted deliveries in a row the endpoint is
auto-disabled (any success — or an admin edit — resets the count). Admins
can redeliver any recorded delivery (a fresh ledger row, same payload) and
ping an endpoint with a test "ping" event, which delivers even while the
endpoint is disabled. Delivery history is pruned after 30 days; both knobs are
configurable (config :kiln_cms, KilnCMS.Webhooks, auto_disable_after: … and
config :kiln_cms, :webhooks, delivery_retention_days: …).
See Webhooks for event names and payload shapes, signature verification, and the SSRF/egress protections applied to endpoint URLs.
Preview tokens
GET /preview/:token returns a single referenced draft of any content type
(pages, posts, project types and admin-defined types alike) as JSON (curated
public fields only). For a live document with unpublished edits it is the
working copy — what the editor sees — not the published row. A browser
opening the same URL is redirected to /preview/:token/live, a shared view
anyone holding the link can watch without signing in.
The token is a stateless Phoenix.Token with a 15-minute expiry — share a
draft without granting a standing credential. It is read-only and
per-document: it binds one record and its site. The read it authorizes is
scoped to the org the token was minted under, and a token presented on another
site's host is refused, so a draft is only ever served by the site that owns it.
Minting one
Editors mint from the content editor's Copy preview link button. A headless front end (a framework's draft mode, a preview deployment) mints over the API:
POST /api/content/:type/:id/preview-token
Authorization: Bearer kiln_… # an editor's API key, or a bearer JWT201 Created
{
"token": "SFMyNTY…",
"url": "https://acme.example.com/preview/SFMyNTY…",
"type": "post",
"id": "0b6c…",
"expires_at": "2026-09-19T14:15:00Z",
"expires_in": 900
}url is on the host of the site the document belongs to (the only host that
will honour it). Keep your API key on the server and hand the browser the
token — redeem it with GET /preview/:token — so a leaked value exposes one
draft for a few minutes rather than every draft indefinitely. Mint a fresh one
per preview render rather than caching it.
The same token also authenticates the visual-editing bridge for its document:
the annotated read (x-kiln-preview-token on GET /api/visual-editing/:type/:slug)
and the live socket (/ws/bridge?preview_token=). See
visual-editing-bridge.md
for re-minting through a long edit session.
Headless draft mode
A front end that renders its pages from the plain reads can render a shared
draft with the same templates: present the token as x-kiln-preview-token
(or ?preview_token=, for a caller that cannot set headers) on
| Read | With a token |
|---|---|
JSON:API GET /api/json/<type>/:id (and include=) | The draft's record — for a live document with unpublished edits, its working copy (title, slug, custom fields and every other held attribute). Its edges to published documents are included; edges to other drafts are not. A :reference to the draft from another document is shown, not masked. |
JSON:API GET /api/json/<type> | The draft alongside the published records the filter matches. |
GET /api/content/:type/:slug | The body, rendered live from the working copy, on any surface, in the published artifact's shape. ?fields= applies; ?expand= follows the working copy's own :reference values. Not counted as a view, no experiment arm. |
GET /api/visual-editing/:type/:slug | The annotated working copy, as before (visual-editing-bridge.md). |
The token is a read grant for its one record, in its own site, on the
primary read. It changes nothing else: by-slug, the /published twins,
search, semantic search and autocomplete stay published-only, and GraphQL and
/mcp ignore it — a token sent there changes neither the answer nor its
caching. A token is read on GET/HEAD only: a write ignores it and is
authorized as before. On the artifact and visual-editing routes the URL must
name the token's record: its type, its slug (the live one or the one its
working copy holds) and, when ?locale= is given, its locale. ?locale= is
validated as on the published read (400 unsupported_locale); the fallback
options have no chain to walk, since the token names one variant.
JSON:API includes of held to-many links (tags, curated related content) show the live set; the artifact body shows the working copy's.
- A presented token that is malformed, expired or minted on another site is
answered
404 invalid_preview, never with the anonymous read: a front end whose token lapsed must re-mint rather than show the published page as the draft. - A database outage is a retryable
503withRetry-After, notinvalid_preview. - Every response under a token is
Cache-Control: private, no-store. Every response of the routes that honour one (JSON:API, the artifact and visual-editing reads) carriesVary: X-Kiln-Preview-Token; a CDN that ignoresVarymust bypass its cache for requests carrying the header (or use the query-string form, which is part of the URL). - A read with a token is also charged to the
:preview_apirate-limit bucket (120 a minute per address), on top of:api. - The token stays out of the request log (
filter_parameters) and out of error reports: Sentry events drop the header and mask the parameter.
Who may mint: anyone who sees this document's drafts as an editor — an
admin, or an editor whose read scope covers the type (readable_types, see
granular-rbac.md). A :read-scoped API key is enough: the
token grants a read, never a write. The responses:
| Status | When |
|---|---|
201 | Minted. Cache-Control: private, no-store. |
401 | No credential (or an invalid one). |
403 | The caller can read the document, but not as an editor — e.g. a viewer on a published page, whose pending edits they may not see. |
404 | Unknown type, no such record, or one the caller cannot read at all. |
Cross-origin (CORS)
The headless surfaces (/api/* and /gql) support CORS so a browser client on
another origin — a decoupled SPA, browser-side fetch, a third-party
integrator — can read them. Authentication is header-based (bearer token or API
key), never cookies, so no ambient credentials are exposed.
Allowed origins come from the CORS_ORIGINS env var, resolved at runtime:
CORS_ORIGINS | Effect |
|---|---|
| (unset, production default) | Same-origin only — no cross-origin reads. |
* | Echo any origin (dev default). |
https://a.example,https://b.example | Allowlist of exact origins (comma-separated). |
Entries are matched by exact equality against the browser's Origin header,
which is always scheme://host[:port] — no trailing slash, no path, downcased,
punycode for an IDN. An entry in any other shape can never match, so the CMS
warns on stderr at boot naming it (#651). It applies the value unchanged:
the check is a heuristic about what browsers send, and dropping an entry on a
heuristic would be the outage the warning is meant to prevent, arrived at
quietly.
The common mistakes are a trailing slash (https://acme.com/), a bare host
(acme.com), https://*.acme.com — there is no wildcard matching here, so it
grants nothing — an uppercase host, and a * mixed into a list, which unlike in
EMBED_ORIGINS cannot widen anything for the same reason.
Any scheme is legitimate. An Origin is a serialized origin, so
chrome-extension://…, moz-extension://…, capacitor://localhost,
tauri://localhost and app://… are all real allowlist entries and none of
them warns. null does warn: browsers send it for sandboxed iframes, file://
and some redirects, so allowlisting it grants all of those at once — it is kept
if you mean it.
Cross-origin callers may send If-Match (and read ETag and retry-after
back), so a browser front end can make the conditional writes described in
json-api.md (Concurrency).
Preflight OPTIONS requests are answered ahead of routing and are not
counted against the caller's rate-limit budget. Browser/HTML routes are
unaffected — CORS applies to the API paths only.
Embeddable forms
Any active form can be embedded on a third-party site with a one-line snippet
(copy it from the form's panel in /editor/forms):
<script src="https://cms.example.com/embed.js" data-kiln-form="contact"></script>/embed.js injects an iframe pointing at GET /forms/:slug/embed — a
standalone document rendering the same form markup as an on-site form block —
and keeps its height in sync with the content, so there's no inner scrollbar.
Optional attributes: data-kiln-origin (CMS base URL, defaults to the script's
own origin), data-kiln-title, data-kiln-height (initial px).
Submissions post to the normal POST /forms/:slug endpoint and render the
form's success message inside the iframe. No CORS is involved: the iframe is
served from the CMS origin, so its form post is same-origin.
Who may embed is set per form, in the builder's Embed tab, and becomes that
page's CSP frame-ancestors. A form that leaves it alone inherits the
deployment-wide EMBED_ORIGINS:
| Form's Who may embed | Effect |
|---|---|
| (unset — the default) | Whatever EMBED_ORIGINS says, below. |
| This site only | 'self' — same-origin only for this form, whatever the deployment allows. |
| Only these sites | 'self' plus this form's own parents, instead of the deployment's list. |
EMBED_ORIGINS | Effect |
|---|---|
| (unset or blank — the default) | 'self' — same-origin only; cross-site embedding is off. |
https://a.example,https://b.example | 'self' plus these parents may frame it. |
* | Any site may embed the form. |
Per form because forms are org-scoped (#648): a deployment-wide allowlist has to be the union of every org's embedders, and that union is what every org's forms would become framable by.
On a multi-org deployment EMBED_ORIGINS is also a ceiling (#1133, #1618):
unless the operator sets EMBED_ORIGINS_LOCKED=false, a form's own list (and
its org's default) may only narrow it, and a site it does not cover is dropped
from the served frame-ancestors. With EMBED_ORIGINS unset there, no other
site may frame any form. See forms.md.
The default is closed (#562) — paste the snippet on an external site before
allowing that site and you get a blank iframe plus a CSP violation in that
site's console. The embed page carries no ambient credentials (an anonymous
public form; a cross-site iframe never receives the SameSite=Lax session
cookie), but framing is itself the attack: with *, any site can overlay the
form invisibly and harvest into your submissions table under your branding, and
submission is deliberately CSRF-free. See forms.md.
Inactive or unknown slugs render a framable "Form not found" page (HTTP 404) rather than a blank iframe.
Image transforms
Any processed image can be resized, cropped and re-encoded on request:
GET /media/<media_item_id>/t/w_1080,ar_16:9,fm_auto,v_3f2a9c01| Key | Values | |
|---|---|---|
w, h | 1–4000 | Width / height in CSS px |
ar | a:b (1–99 each) | Aspect ratio, instead of h |
dpr | 1–3 | Pixel density multiplier |
fit | cover (default), contain | Crop to fill, or fit inside |
crop | focal (default), center, top, bottom, left, right | Anchor of a cover crop |
fm | auto, jpg, png, webp, avif | Output format (default: the source's); auto negotiates from Accept |
q | 1–100 | Quality (lossy formats) |
v | 8 hex | Version pin: makes the response immutable for a year |
s | 22 chars | HMAC signature: lifts the size allowlist |
Unsigned URLs may only use the allowlisted sizes, ratios and qualities
(off-list is a 400 naming them); signed ones any in-range value (a bad
signature is a 403). Output is never upscaled. A gated item is a 404
exactly where /media/:id/download would be; a non-image is a 422; a
saturated render queue is a 503 with Retry-After.
Build the URLs with the SDKs rather than by hand — they snap to the allowlist,
compute v from the item's url/focal_x/focal_y, and sign when given the
server's KILN_IMAGE_TRANSFORM_KEY (server-side only):
kiln.imageUrl(media, { width: 800, aspectRatio: "16:9", format: "auto" });
kiln.imageSrcset(media, { aspectRatio: "16:9" });KilnClient.image_url(media, width: 800, aspect_ratio: "16:9", format: :auto)Grammar, limits, caching and configuration in full: media-pipeline.md § On-the-fly transforms.
Caching and CDNs
Every headless read surface tells a shared cache what it may keep, so a CDN in front of Kiln can answer delivery traffic instead of the app.
| Surface | Anonymous 200 | With a credential |
|---|---|---|
GET /api/content/:type/:slug (fired artifacts) | public, max-age=300, ETag, Last-Modified | same (the artifact is published-only); a passphrase-unlocked document is private, no-store |
GET /api/json/* (JSON:API) | public, max-age=60, stale-while-revalidate=60, ETag | private, no-store |
GET /gql?query=… (GraphQL queries over GET) | public, max-age=60, stale-while-revalidate=60, ETag | private, no-store |
GET /api/search | public, max-age=60, stale-while-revalidate=60, ETag | private, no-store |
POST /gql, any JSON:API write | never cached | never cached |
Anonymous means no credential at all: no Authorization header (a JWT or
kiln_… API key — even an invalid one), no x-api-key, no unlock grant
(x-kiln-unlock or ?unlock=) and no Cookie. Anything else gets
private, no-store, because a bearer token with editor rights sees drafts on
the same URLs — its response must never be stored where the next anonymous
caller could be handed it.
What the cached responses carry:
- An
ETagthat is a digest of the body (and its content type). It changes whenever anything that shapes the response changes — there is no list of inputs to keep in sync with the body. Send it back asIf-None-Matchand a still-current response is a bodyless304 Not Modified. It is a weak validator (W/"…") so the server can still gzip the response; conditionalGETs compare weakly anyway. A credentialed request never gets a304from this: it has noETagto match. - A fired artifact's
ETagis weak too (W/"…"), for the same reason: Kiln gzips the artifact for a client that sendsAccept-Encoding: gzip. It names the document, locale, surface and publish time (a?fields=response is tagged by a digest of its filtered body instead).If-None-Matchis compared weakly: aW/prefix is ignored, a comma-separated list matches if any tag does, and*matches. A tag cached from an older Kiln (the same value withoutW/) still revalidates. A passphrase-unlocked document has noETagand is never304ed. Vary: Accept, Authorization, Originon every response of these surfaces, cached or not.Originbecause the CORSAccess-Control-Allow-Originheader is only sent to a request with an allowedOrigin— a copy cached from a server-side fetch must not be handed to a browser. The locale is always part of the URL (?locale=, a GraphQL argument, a JSON:API filter or a/fr/prefix), so there is noAccept-LanguageorCookievariation to key on.Surrogate-Key: kiln kiln-org-<site id>and the same keys asCache-Tag(comma-separated), also on public fired-artifact responses — see Purging on publish.
Only a 200 is cached, and only for a GET with no request body. A GraphQL
GET is cached only when the document is in the URL (?query=), and not when
it answers 200 with an errors member (a failed resolver, a validation
error), so a transient failure is not pinned for a minute. Mutations are never
cached: Absinthe refuses a mutation over GET (405), and a POST is never
public.
Other /api routes — menus, /resolve, /locales, /schema, related
content, /ask — keep their own headers, set per controller.
Configuring your CDN
- Bypass the cache when the request has an
Authorizationheader (and ideallyx-kiln-unlock), unless your CDN honoursVary: Authorization. Fastly, Varnish and most standards-following caches do; some CDNs ignoreVaryapart fromAccept-Encodingand would otherwise hand the anonymous response to an editor's token or, worse, store nothing but key on the URL. RFC 9111 forbids a shared cache from storing a response to anAuthorizationrequest that is not markedpublic, which Kiln never does for those. - Key on the full URL including the query string. Every input to these bodies that is not a credential is in it.
- Don't cache
POST /gql. UseGETfor queries you want cached; the query string carries the document and variables. - Search analytics count only what reaches Kiln.
/api/searchrecords the query for the search report at/editor/analyticswhen it runs, so a CDN hit is not counted.
KILN_API_CACHE=false keeps anonymous responses at the private default;
KILN_API_CACHE_MAX_AGE and KILN_API_CACHE_SWR tune the two lifetimes. See
environment-variables.md.
Purging on publish
Without a purge, a publish reaches readers when the cached copy expires —
within KILN_API_CACHE_MAX_AGE (60 s by default) for JSON:API, GraphQL and
search, 300 s for fired artifacts. Set KILN_CDN_PURGE_URL and every
<type>.published, <type>.unpublished, <type>.updated and
release.published in a site sends one purge of that site's key. Those are the
content webhook events; a change that emits none — renaming a category or tag,
editing a type definition or custom field — is not purged and ages out on the
max-age:
POST <KILN_CDN_PURGE_URL>
content-type: application/json
surrogate-key: kiln-org-<site id>
authorization: Bearer <KILN_CDN_PURGE_TOKEN>
{"tags": ["kiln-org-<site id>"]}The body is Cloudflare's purge-by-tag request and the header is Fastly's purge-by-key request, so either can be the URL directly:
- Cloudflare:
KILN_CDN_PURGE_URL=https://api.cloudflare.com/client/v4/zones/<zone>/purge_cache,KILN_CDN_PURGE_TOKEN=<API token with Cache Purge>. - Fastly:
KILN_CDN_PURGE_URL=https://api.fastly.com/service/<service>/purge,KILN_CDN_PURGE_TOKEN=<API token>,KILN_CDN_PURGE_TOKEN_HEADER=fastly-key. - Anything else: point it at a small relay that translates the request.
The purge covers the whole site rather than the one document: a list or query
response can contain any number of documents and nothing records which, so the
site key is the one invalidation that is right for every cached response.
Purges are coalesced (a release publishing fifty documents sends one, after it
commits), retried
with backoff on failure, and sent through KilnCMS.SafeFetch, so in production
the URL must be https:// and resolve to a public address. A purge that never
lands leaves the cached copies to expire on their own. The kiln key on every
response exists for a deployment-wide purge by hand.
Rate limits
API and auth endpoints are rate-limited per client IP (Hammer fixed window).
Over the limit returns 429 with a retry-after header.
| Bucket | Endpoints | Limit (per IP) |
|---|---|---|
api | /api/json/*, GET /api/content/:type/:slug (fired artifacts), GET /api/locales, GET /api/search | 120 requests / minute |
gql | /gql | 60 requests / minute |
auth | sign-in / auth | 40 requests / minute |
docs | /api/json/swaggerui | 60 requests / minute |
unlock | POST /api/content/:type/:slug/unlock (and the built-in site's lock form) | 10 requests / minute |
media_transform | GET /media/:id/t/:ops (every request) | 1,200 requests / minute |
media_render | GET /media/:id/t/:ops that misses the derivative cache (each is a render) | 120 renders / minute |
media_upload | /api/media/* (uploads, URL imports, direct-upload begin/complete) — charged on top of api | 60 requests / minute |
Idempotent writes
A write that times out leaves the client unsure whether it landed. Retrying a
POST blindly can create a second document, and retrying a /publish gets a
409 because the first attempt already published. To make a retry safe, send an
Idempotency-Key header with a value you generate once per logical
operation (a UUID is ideal) and reuse on every retry of it:
curl -s -X POST http://localhost:4000/api/json/posts \
-H 'accept: application/vnd.api+json' -H 'content-type: application/vnd.api+json' \
-H "authorization: Bearer $KEY" \
-H 'idempotency-key: 6f1c1e8e-5d0b-4e61-9f0e-2a4b1c7d9e10' \
-d '{ "data": { "type": "post", "attributes": { "title": "Launch" } } }'It works on every authenticated POST and PATCH on /api/json/*, the
workflow routes included, and on POST /gql.
| What you send | What you get |
|---|---|
| A key not seen before | The request runs; its response is stored |
| The same key and the same request, after the first finished | The stored response, byte for byte, with idempotency-replayed: true. Nothing runs again |
| The same key with a different request | 422 idempotency_key_reused |
| The same key while the first request is still running | 409 idempotency_request_in_progress, with retry-after: 1 |
| A key that is empty, longer than 255 characters, or not printable ASCII | 400 idempotency_key_invalid |
- "The same request" means the same method, path, query string and body. The body is compared after parsing, so a retry that re-serializes the JSON with different key order or whitespace still matches.
- Keys are scoped to the authenticated user. Two callers can't replay, or collide with, each other's keys. Anonymous requests ignore the header.
- Kept for 24 hours, then pruned. A key reused after that runs as new.
- What is stored: every 2xx, and the 4xx that describe the request itself (validation errors, 404, 412). A 401 or 403 is not stored, so fixing a credential and retrying with the same key works; neither are 409, 429, any 5xx, or a response body over 1 MB. For those, retrying with the same key runs the request again.
- A request that crashes before it answers leaves its key in progress; after 60 seconds a retry takes it over and runs.
Error responses
The headless surfaces return errors as a JSON object with an errors
array, each entry carrying a string status (HTTP code), a machine-readable
code, and a human detail:
{ "errors": [ { "status": "404", "code": "not_found", "detail": "Content not found." } ] }This is the JSON:API shape. status is always the numeric HTTP code as a
string ("422", never "unprocessable_entity") — safe to parseInt — and
code is a stable token you can branch on.
You get it from the headless sign-in, fired-artifact (not_found /
artifact_compiling), related-content, provenance, form-schema and
form-submission, visual-editing, media-upload (§ Upload errors),
version-history (unauthenticated, invalid_id, invalid_cursor,
forbidden, restore_failed) and preview-token (invalid_preview)
endpoints — and from the 429 when you exceed a rate-limit bucket
(too_many_requests, alongside retry-after). All of them render through one
implementation, KilnCMSWeb.ApiError.send/4, and a test fails the build if a
new endpoint writes its own (#190, #744).
An unrouted /api path and an unhandled exception on any JSON-negotiated
request answer this same envelope (KilnCMSWeb.ErrorJSON), so a client that
only special-cases the routes above still gets a body it can parse.
Three responses on these paths are deliberately not this envelope:
/api/json/*(JSON:API proper) carries the same three fields plus the spec'sid,titleandsource.pointer.- Field-level validation errors from
POST /api/forms/:slugare{"ok": false, "errors": {"<field>": "…"}}— a per-field map. The envelope says the request failed; this says which input was wrong. GET /api/resolve's 200/301-equivalent and 404 responses are a verdict, not an error:{"status": "ok" | "moved" | "not_found"}. Thatstatusis the verdict and is not an HTTP code. A missing/malformed?path=is an error, though, and answers the envelope like everything else —code: "missing_path".
GraphQL follows the GraphQL spec's top-level errors array instead.
Versioning & stability
The HTTP paths are currently unversioned (/api/json, /api/content,
/gql, /preview). The OpenAPI document's info.version tracks the app
release, not the HTTP contract — don't pin behaviour to it.
What's stable (changes only via the breaking-change process below):
- The path layout of the published read surfaces above.
- The published-content read contract: resource types, their
public?attribute names/types, the JSON:API filter/sort/page semantics, and the fired artifact surfaces (json/json_ld/web). - Author PII is never exposed (only
authorId+ displayname);*BySlugreturns published content only. See the consumer guide. - A JSON:API GET of an unpublished record returns 404 (not 403) for anyone without read access — the published filter removes it, so the response never reveals that a draft exists. Branch on 404 for "not available".
What may change without notice:
- Anything behind a bearer token that mirrors editor/admin internals beyond the documented read fields.
- Result ordering where not explicitly specified (use
sort=). - Non-
public?internals (never serialized) and the raw editable block tree (intentionally not exposed — render from fired artifacts).
Breaking-change process. A change that removes/renames a public? field or
alters documented response semantics is a breaking change. When one is
unavoidable it will be introduced under a /api/v1/… prefix (the current
unprefixed paths becoming the implicit v1), with the previous version kept for
a deprecation window announced in the changelog and reflected in the OpenAPI
deprecated markers. The window is at least two minor releases and at least
six months, whichever is longer. The prefix is not introduced before it is
needed: 1.0 ships the unprefixed paths (decided 2026-09-18, #1539). Adding new endpoints, optional query params, or new
public? fields is not breaking and can land at any time.
To insulate yourself: consume the published OpenAPI spec (/api/json/open_api),
request only the fields you use (GraphQL selection / JSON:API sparse fieldsets),
and treat unknown fields as additive.