Skip to main content

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

FileWhat it describes
docs/api/openapi.jsonThe JSON:API surface (/api/json), sign-in, fired artifacts and preview links, as OpenAPI 3.0.
docs/api/schema.graphqlThe 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

ResourceURLWho gets it
OpenAPI 3 documentGET /api/json/open_apiAnyone where API_DOCS_ENABLED is on; otherwise a request with an API key.
Swagger UIGET /api/json/swaggeruiAnyone where API_DOCS_ENABLED is on; otherwise no one.
GraphQL SDLGET /api/graphql/schema.graphqlAnyone where GraphQL introspection is on; otherwise a request with an API key.
GraphQL introspectionPOST /gql (__schema)Anyone where GRAPHQL_INTROSPECTION_ENABLED is on; otherwise no one.
GraphQL playgroundGET /gql/playgroundDev-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.graphql

graphql-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:

SurfaceEndpointUse caseReference
Sign-inPOST /api/auth/sign_inExchange credentials for a bearer token (JWT). Server-to-server clients use an API key instead.§ Authentication
JSON:API/api/jsonFilterable 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
GraphQLPOST /gql, /ws/gqlDelivery reads, search, menus and point-in-time (contentAsOf); the same writes as mutations; subscriptions over the WebSocket.headless-graphql-api.md
Fired artifactsGET /api/content/:type/:slugPre-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/syncMirror 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 historyGET /api/content/:type/:id/revisionsA document's revisions, one revision's snapshot, and restore. Editor-tier credential required.§ Version history
Hybrid searchGET /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 resolutionGET /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)
MenusGET /api/menus, GET /api/menus/:keyResolved navigation trees with live URLs.navigation-menus.md
Related contentGET /api/content/:type/:slug/relatedPublished documents semantically closest to this one (empty when semantic search is off).rag.md
Ask your contentGET /api/ask?q=Cited published passages, plus a generated answer when a generator is configured.rag.md
ProvenanceGET /api/provenance/verify/:type/:slug, GET /api/provenance/:type/:slug, …/verify, GET /api/provenance/public-keyWhether 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
LocalesGET /api/localesConfigured content locales, the default, and each locale's fallback chain.§ Locale discovery, § Locale fallback
SchemaGET /api/schemaJSON Schema for the fired json payloads — generate types, validate responses.§ Schema discovery
Media uploadPOST /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/mcpModel Context Protocol server for LLM authoring clients; API key required.mcp.md
FormsGET /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
SitemapGET /sitemap.xmlEnumerate published content for crawling/SSG.—
News sitemapGET /news-sitemap.xmlGoogle News sitemap: NewsArticle types published in the last 48 hours.seo.md § Google News sitemap
FeedsGET /feed.xml, GET /feed.jsonAtom 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 previewGET /preview/:tokenOne unpublished document via a short-lived token.§ Preview tokens
Image transformsGET /media/:id/t/:opsResize, 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 :editor or :admin role — 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 auth bucket (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.Token store (require_token_presence_for_authentication? is on), so revocation (sign-out / log-out-everywhere) takes effect immediately.
  • The browser /sign-in LiveView 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 429 leaves 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. A 429 here is not reset by signing in again; it carries Retry-After in whole seconds.
  • Errors from both steps carry a stable code alongside detail: 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 — send code as 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__.token

API 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 :viewer account.
  • Keys carry an access scope, 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. /mcp additionally 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_ids replaces, 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, use add_tag_ids/remove_tag_ids (JSON:API, GraphQL) — MCP's update_page/update_post/update_entry take 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 the token connection 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:

GoalSurfaceReturns drafts with bearer?
List / filter draftsJSON:API GET /api/json/<type>?filter[state]=draft✅ yes
Search draftsJSON:API /search · GraphQL searchPosts/searchPages✅ yes
Fetch one by idJSON:API GET /api/json/<type>/:id✅ yes
Fetch one by slugGraphQL postBySlug/pageBySlug, categoryBySlug, tagBySlug, tagGroupBySlug❌ no — published only
Share a specific draftGET /preview/:token (signed link)n/a (no account needed)
Render a shared draft in a front end's own templatesJSON:API by id and GET /api/content/:type/:slug with x-kiln-preview-tokenn/a — the token, see Headless draft mode

GraphQL *BySlug never returns drafts. Those queries run the :public_by_slug action, which hard-filters state == :published inside the action — independent of the actor. A bearer token makes no difference; a draft slug always resolves to null. 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:

ResourceCollectionSingle recordExtra reads
PageGET /api/json/pagesGET /api/json/pages/:id/pages/search, /pages/semantic-search, /pages/autocomplete
PostGET /api/json/postsGET /api/json/posts/:id/posts/published, /posts/search, /posts/semantic-search, /posts/autocomplete
MediaItemGET /api/json/media-itemsGET /api/json/media-items/:id/media-items/search, /media-items/library — uploads: § Uploading media
CategoryGET /api/json/categoriesGET /api/json/categories/:id/categories/by-slug/:slug
TagGET /api/json/tagsGET /api/json/tags/:id/tags/by-slug/:slug
TagGroupGET /api/json/tag-groupsGET /api/json/tag-groups/:id/tag-groups/by-slug/:slug
TypeDefinitionGET /api/json/type-definitionsGET /api/json/type-definitions/:id/type-definitions/by-name/:name (editor+ key; read-only)
ContentReleaseGET /api/json/releasesGET /api/json/releases/:id?include=items (editor+ key; read-only)
ReleaseItemGET /api/json/release-itemsGET /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; position is 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 public cache headers. Its ETag also covers the expanded targets, so republishing a target invalidates it.
  • link is the data stored on the connection (a relationship's link fields), passed through as stored; {} when the field has none.
  • target is null for 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. expand needs surface=json and cannot be combined with as_of; both are a 400 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 }
  ]
}
KeyMeaning
id, url, altThe 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, heightThe original's size in pixels — lay out before the image loads. null until the item has been processed (below), and for a document.
mime_typeThe original's content type.
focalThe 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.
variantsEvery 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[].croppedtrue 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": "…"}}}
  • id and type are always kept, and so is expanded when ?expand= is also given. custom_fields on its own keeps every custom field.
  • Unknown names are ignored, not refused.
  • The ETag is a digest of the filtered body. It moves only when a kept value does: after a republish that changed only blocks, a ?fields=title revalidation is still a 304. Order and repeats don't change the body, so every spelling of one fieldset shares the ETag. The canonical spelling (sorted, de-duplicated, without id/type) comes back as x-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. fields needs surface=json; otherwise it is a 400 invalid_fields. It works with ?as_of= too.
  • /api/sync always embeds the whole artifact. For sparse lists, use JSON:API's fields[<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.

RouteBodyUse it for
POST /api/mediamultipart: file + metadata fieldsMost uploads, up to the per-kind caps below
POST /api/media/import-urlJSON: url + metadataA file already on the public web
POST /api/media/uploads → PUT → POST /api/media/uploads/completeJSONLarge files, sent straight to object storage (below)
PATCH /api/json/media-items/:idJSON:APIEditing 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:

FieldTypeNotes
altstringAlt text.
captionstring
decorativebooleanA decorative image correctly has no alt text.
focal_x, focal_ynumber, 0.0–1.0Focal point the smart crops centre on (default 0.5).
tag_idsarray of tag idstag_ids[]=… repeated in a multipart form. Every id must be a tag on this site.
filenamestringimport-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 PUT into 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/uploads answers 501 direct_uploads_unavailable; use POST /api/media.

Upload errors

Refusals use the error envelope; branch on code:

StatuscodeMeaning
401unauthorizedNo credential.
403forbiddenRead-only key, or an account that can't create media.
413too_largeOver the cap for the file's kind (or the import download cap). A body over the route's limit is 413 too.
415unsupported_media_typeNot a kind the library accepts.
422missing_fileNo multipart file field.
422invalid_parameterA metadata field failed validation; detail names it.
422create_failedThe item couldn't be saved — usually a tag_ids entry that isn't a tag on this site. Nothing is stored.
422encryptedA password-protected PDF — its metadata can't be removed.
422strip_unavailable / strip_failedMetadata couldn't be removed, so the file was refused rather than stored with it.
422unsafe_url / fetch_failedimport-url: the URL isn't a public address, or didn't answer 2xx.
422invalid_upload_token / not_uploaded / size_mismatchDirect uploads: a bad or someone else's token; nothing staged (or already completed); the staged size isn't the declared one.
429too_many_requestsThe media_upload bucket (§ Rate limits).
501direct_uploads_unavailableThis deployment can't presign (no S3 private bucket).
502storage_failedThe object store refused the write; retry.
503insufficient_storageOut 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.

RouteAnswers
GET /api/content/:type/:id/revisionsThe document's revisions, newest first — ids, actions, timestamps and the names of the fields each write changed
GET /api/content/:type/:id/revisions/:version_idOne revision: its own changes, plus the full snapshot of the document at that revision
POST /api/content/:type/:id/revisions/:version_id/restoreRevert 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_text column is left out); GET …/revisions/:version_id adds the version's own raw changes and the snapshot — 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_id is null when the write had no actor.
  • Pagination is newest first: ?limit= (1–100, default 20) and an opaque ?cursor= — pass back the previous page's meta.next_cursor, which is null on 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_version action 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) answers 422 restore_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:

CallerList / readRestore
No credential401401
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 out404404

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_in comes 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-store and carry no ETag. 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 (401 invalid_passphrase from 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 /published collection routes, the historical collection (GET /api/content/:type?as_of= and GraphQL contentAsOf), 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 :published read carries that as a filter, not a policy clause, so it holds for an authorized caller too — a locked document is absent from /published and 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.

ParameterMeaning
initial=trueStart a new sync. Exactly one of initial and cursor.
cursorResume. Carries its own scope, so type/surface are ignored with it.
typeOne content type (singular, compiled or admin-defined). Default: every type.
surfaceArtifact surface embedded in upserts: json (default), json_ld, web, llm.
limitItems 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 → fr does 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_required for the first locked one, and POST …/unlock with the same ?locale= verifies against it.
  • Navigation is stricter. /api/menus/:key and GraphQL menu follow 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

ParameterMeaning
locale=fr-CAWhere the walk starts. Defaults to the site's default locale.
fallback=falseServe locale or nothing (true/false/1/0; anything else is 400 invalid_fallback).
fallback_locale=frTry 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-locale and Content-Language to the served locale. /api/resolve and /api/menus also carry it as locale in the body; the artifact json surface carries it in the document, and the JSON:API resource in attributes.locale.
  • GraphQL cannot set a header per field: select locale on 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"]
ParameterEffect
?type=post,pageOnly these content types. An unknown name is a 400, not a silent full export.
?blocks=onlyThe 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 _type const, so a oneOf match is unambiguous;
  • _id is 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 columns block's children $ref back to the union, so nesting is typed all the way down (the storage union has to stay flat — see KilnCMS.Blocks.Columns);
  • custom_fields is open, and nothing in it is required: 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: true block field is listed in required (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_required finds 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.ts

The .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):

URLFormatScope
GET /feed.xmlAtom 1.0Every syndicated content type
GET /feed.jsonJSON Feed 1.1Every syndicated content type
GET /blog/feed.xmlAtom 1.0Posts only
GET /pages/feed.xmlAtom 1.0Pages only
GET /blog/category/<slug>/feed.xmlAtom 1.0Posts in one category
GET /blog/tags/<slug>/feed.jsonJSON Feed 1.1Posts carrying one tag
GET /fr/feed.xmlAtom 1.0Everything, in French
GET /fr/blog/category/<slug>/feed.xmlAtom 1.0All 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 JWT
201 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

ReadWith 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/:slugThe 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/:slugThe 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 503 with Retry-After, not invalid_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) carries Vary: X-Kiln-Preview-Token; a CDN that ignores Vary must 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_api rate-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:

StatusWhen
201Minted. Cache-Control: private, no-store.
401No credential (or an invalid one).
403The 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.
404Unknown 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_ORIGINSEffect
(unset, production default)Same-origin only — no cross-origin reads.
*Echo any origin (dev default).
https://a.example,https://b.exampleAllowlist 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 embedEffect
(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_ORIGINSEffect
(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
KeyValues
w, h1–4000Width / height in CSS px
ara:b (1–99 each)Aspect ratio, instead of h
dpr1–3Pixel density multiplier
fitcover (default), containCrop to fill, or fit inside
cropfocal (default), center, top, bottom, left, rightAnchor of a cover crop
fmauto, jpg, png, webp, avifOutput format (default: the source's); auto negotiates from Accept
q1–100Quality (lossy formats)
v8 hexVersion pin: makes the response immutable for a year
s22 charsHMAC 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.

SurfaceAnonymous 200With a credential
GET /api/content/:type/:slug (fired artifacts)public, max-age=300, ETag, Last-Modifiedsame (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, ETagprivate, no-store
GET /gql?query=… (GraphQL queries over GET)public, max-age=60, stale-while-revalidate=60, ETagprivate, no-store
GET /api/searchpublic, max-age=60, stale-while-revalidate=60, ETagprivate, no-store
POST /gql, any JSON:API writenever cachednever 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 ETag that 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 as If-None-Match and a still-current response is a bodyless 304 Not Modified. It is a weak validator (W/"…") so the server can still gzip the response; conditional GETs compare weakly anyway. A credentialed request never gets a 304 from this: it has no ETag to match.
  • A fired artifact's ETag is weak too (W/"…"), for the same reason: Kiln gzips the artifact for a client that sends Accept-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-Match is compared weakly: a W/ prefix is ignored, a comma-separated list matches if any tag does, and * matches. A tag cached from an older Kiln (the same value without W/) still revalidates. A passphrase-unlocked document has no ETag and is never 304ed.
  • Vary: Accept, Authorization, Origin on every response of these surfaces, cached or not. Origin because the CORS Access-Control-Allow-Origin header is only sent to a request with an allowed Origin — 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 no Accept-Language or Cookie variation to key on.
  • Surrogate-Key: kiln kiln-org-<site id> and the same keys as Cache-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 Authorization header (and ideally x-kiln-unlock), unless your CDN honours Vary: Authorization. Fastly, Varnish and most standards-following caches do; some CDNs ignore Vary apart from Accept-Encoding and 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 an Authorization request that is not marked public, 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. Use GET for queries you want cached; the query string carries the document and variables.
  • Search analytics count only what reaches Kiln. /api/search records the query for the search report at /editor/analytics when 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.

BucketEndpointsLimit (per IP)
api/api/json/*, GET /api/content/:type/:slug (fired artifacts), GET /api/locales, GET /api/search120 requests / minute
gql/gql60 requests / minute
authsign-in / auth40 requests / minute
docs/api/json/swaggerui60 requests / minute
unlockPOST /api/content/:type/:slug/unlock (and the built-in site's lock form)10 requests / minute
media_transformGET /media/:id/t/:ops (every request)1,200 requests / minute
media_renderGET /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 api60 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 sendWhat you get
A key not seen beforeThe request runs; its response is stored
The same key and the same request, after the first finishedThe stored response, byte for byte, with idempotency-replayed: true. Nothing runs again
The same key with a different request422 idempotency_key_reused
The same key while the first request is still running409 idempotency_request_in_progress, with retry-after: 1
A key that is empty, longer than 255 characters, or not printable ASCII400 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's id, title and source.pointer.
  • Field-level validation errors from POST /api/forms/:slug are {"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"}. That status is 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 + display name); *BySlug returns 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.