Skip to main content

Headless JSON:API

KilnCMS exposes a JSON:API-compliant surface for headless consumers at /api/json (powered by AshJsonApi). Reads are anonymous-friendly; writes (create / update / workflow / soft-delete) require an API key — see Writing. This document covers the read query params (filtering, sorting, pagination) for the public content types — Page, Post and MediaItem (tuned in Phase 5, issue #33) — and the write routes.

The machine-readable OpenAPI spec (/api/json/open_api) and its interactive Swagger UI (/api/json/swaggerui) are published in dev and test, and in production only with API_DOCS_ENABLED (#567) (dev and prod). See api.md for the full API documentation index (authentication, GraphQL, webhooks, preview tokens, rate limits).

Try it now. kilncms.dev runs Kiln, and its guides are published entries of a dynamic doc type, so these reads answer anonymously. -g stops curl from reading the brackets as a URL range:

A='accept: application/vnd.api+json'

# The delivery feed, with a sparse fieldset and a page size
curl -g -H "$A" 'https://kilncms.dev/api/json/entries/published?filter[type_name]=doc&fields[entry]=slug,title&page[limit]=5'

# Filter by exact slug
curl -g -H "$A" 'https://kilncms.dev/api/json/entries?filter[type_name]=doc&filter[slug]=getting-started&fields[entry]=slug,title,published_at'

# One page by slug (locale fallback applies)
curl -g -H "$A" 'https://kilncms.dev/api/json/pages/by-slug/documentation?locale=en&fields[page]=slug,title'

# Published-only full-text search and typeahead
curl -g -H "$A" 'https://kilncms.dev/api/json/entries/search/published?query=migrations&fields[entry]=slug,title'
curl -g -H "$A" 'https://kilncms.dev/api/json/entries/autocomplete/published?prefix=dep&fields[entry]=slug,title'

The site has no categories or tags, so those routes answer an empty list, and it does not publish its OpenAPI document. The rest of this page uses localhost:4000, meaning a site of your own.

Content negotiation

Every request must use the JSON:API media type:

Accept: application/vnd.api+json

Requests are anonymous by default and go through each resource's read policy, so only published content is returned. To read drafts / in-review / archived content, authenticate with a bearer token belonging to an editor or admin:

Authorization: Bearer <token>

Routes

ResourceCollectionSingle recordExtra reads
PageGET /api/json/pagesGET /api/json/pages/:id/pages/by-slug/:slug, /pages/search, /pages/semantic-search, /pages/autocomplete, /pages/published
PostGET /api/json/postsGET /api/json/posts/:id/posts/by-slug/:slug, /posts/search, /posts/semantic-search, /posts/autocomplete, /posts/published
MediaItemGET /api/json/media-itemsGET /api/json/media-items/:id/media-items/search, /media-items/library; PATCH /media-items/:id edits metadata (below)
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
Entry (dynamic types)GET /api/json/entriesGET /api/json/entries/:idsame set as Post, filtered by filter[type_name]=; /entries/by-slug/:slug takes ?type_definition_id=
TypeDefinitionGET /api/json/type-definitionsGET /api/json/type-definitions/:id/type-definitions/by-name/:name — editor-or-above credential; see Discovering dynamic types
ContentReleaseGET /api/json/releasesGET /api/json/releases/:id?include=items — editor-or-above credential, read-only; see Content releases
ReleaseItemGET /api/json/release-itemsGET /api/json/release-items/:id?filter[release_id]= — same
ContentLink—GET /api/json/content-links/:idPOST, PATCH /:id, DELETE /:id write payload links — see Writing relationships

GET /api/json/<plural>/by-slug/:slug?locale=fr-CA is the single published document for a slug, resolved through the site's locale fallback chain — ?locale= is where the walk starts and defaults to the site's default locale when left out (it was required, a 400, before 1.7), ?fallback=false and ?fallback_locale= narrow it, the served locale is attributes.locale and the x-kiln-locale / Content-Language headers, and a locale the site does not run is a 400. See api.md → Locale fallback. It authorizes like every route here, so a caller sees exactly the variants the read policies let it read; a variant it may not read is skipped along the chain like a missing one. (Filtering the collection with filter[slug]=…&filter[locale]=… still works and never falls back.)

GET /api/json/<plural>/published returns published records only, ordered newest first (-published_at) — the delivery feed. It exists on every content type (incl. /entries/published for dynamic types); on posts it doubles as the headless blog feed.

Taxonomy is world-readable and read-only over the API (D7). A tag group is the bucket a tag is filed under: tag-groups carries name, slug, description, position, a tag_count aggregate, and content_types — an array of content-type name strings (["post"]) that scopes where the group is offered, where an empty array means every content type. A tag exposes its group as tag_group_id plus an includable tag_group relationship (/api/json/tags?include=tag_group); /api/json/tag-groups?include=tags goes the other way. Frontends rebuilding Kiln's grouped tag UI filter content_types client-side — it is a short list, so there is no server-side for_content_type read.

Every content-type search read also has a published-only twin at …/search/published, …/semantic-search/published and …/autocomplete/published — same query surface, restricted server-side to what an anonymous visitor could read: published, audience: :public, and not passphrase-locked. Delivery sites calling with a bearer key should use these; see "Search & autocomplete" below.

That is a stronger filter than the read policy, and deliberately so. A bearer key authorizes as the account that minted it, and an admin account bypasses the audience and passphrase checks entirely — so on the base routes an admin-minted delivery key enumerates member-only and locked documents (title, slug, excerpt, SEO fields; blocks is not public on any read action). The twins cannot be widened by any credential (#297, #1013).

The hybrid GET /api/search holds the same line — it is actorless, so it always answers as an anonymous visitor.

GET /api/json/<plural>/published deliberately does not: an index is a discovery surface, and the rendered blog index publishes an audience-gated post's title and excerpt to anonymous visitors with a "Members" badge, so that metadata is already public. It pins state only.

Content releases (read-only)

A content release (#500) is a named bundle of publishes and unpublishes that goes live as one unit. Releases are composed and shipped in /editor/releases; over the API they are read-only, for a headless editorial tool or a status board that needs to know what is planned to go live and when.

RouteAnswers
GET /api/json/releasesEvery release in the request's org; filter[state]=scheduled, sort=scheduled_at, page[...] as usual
GET /api/json/releases/:idOne release; add ?include=items for its contents
GET /api/json/release-itemsRelease items; filter[release_id]=<id> for one release's
GET /api/json/release-items/:idOne item

A release (type: "release") carries name, description, state (open, scheduled, publishing, published, failed, rolling_back, rolled_back, archived), scheduled_at, published_at, rolled_back_at, failure_reason and failed_item_id. An item (type: "release_item") carries release_id, content_type + content_id (the document it acts on — fetch it through that type's own route, under that route's own read policy), action (publish / unpublish), status (pending, applied, skipped, cancelled, rolled_back), prior_state, prior_version_id and applied_at. Who created, triggered or added to a release is not exposed — those are user ids.

Same read and policy as the console: the credential must belong to an editor or admin of the request's org (a read-only key is enough). A viewer's key or an anonymous caller gets an empty list and a 404 on a single record; the host bounds the org, as everywhere else.

There are no write routes. Shipping a release publishes every item it holds as the admin who triggered it — an admin approval step that stays in the console — so neither composing nor scheduling a release is reachable from a key.

# What goes live next, with its contents
curl -s 'http://localhost:4000/api/json/releases?filter[state]=scheduled&sort=scheduled_at&include=items' \
  -H 'accept: application/vnd.api+json' \
  -H "authorization: Bearer $KILN_API_KEY"

A document's own version history is not a JSON:API resource — see api.md → Version history.

Filtering

Filter on any public attribute with filter[<field>]=<value> (equality) or filter[<field>][<operator>]=<value> for other operators (gt, lt, gte, lte, in, not_eq, ilike, …). Multiple filter[...] params are ANDed.

Page / Post filterable fields: title, slug, locale, state, published_at, scheduled_at, seo_title, seo_description, canonical_url, category_id, featured_image_id, author_id (Post also: excerpt). Relationship filters such as filter[category][slug]=news are also supported.

MediaItem filterable fields: filename, content_type, byte_size, width, height, alt, caption, url.

Examples:

# Published English posts in one category
GET /api/json/posts?filter[locale]=en&filter[category_id]=<uuid>

# Posts by exact slug
GET /api/json/posts?filter[slug]=my-first-post

# Drafts (requires an editor/admin bearer token)
GET /api/json/posts?filter[state]=draft

# Media of a given content type
GET /api/json/media-items?filter[content_type]=image/png

Sorting

Use sort=<field> (ascending) or sort=-<field> (descending). Comma-separate for multi-key sorts: sort=-published_at,title.

GET /api/json/posts?sort=-published_at        # newest first
GET /api/json/posts?sort=title                # A → Z

Any sortable public field may be used. The collection routes have no implicit ordering unless you pass sort (except /posts/published, which defaults to -published_at).

Two recency fields, two meanings: inserted_at/updated_at are the record's row timestamps (public read-only), while published_at is set by the publish transition — for published feeds, -published_at is what "newest" means editorially. Note a non-public or unknown field in sort fails the request with invalid_sort (it is not silently ignored), so a naive client renders an empty page — check the error body. The same is true of a public field that exists but isn't sortable — see Calculated fields.

Sorting search results

/…/search orders by relevance by default (title hits above body hits, newest breaking ties). An explicit sort= overrides it — your keys rank first and relevance degrades to the tiebreaker:

GET /api/json/posts/search?query=tea             # best match first
GET /api/json/posts/search?query=tea&sort=title  # A → Z, relevance breaks ties

/…/semantic-search behaves the same way with cosine distance as the default order (overriding it usually defeats the point — but it is not an error). custom_sort is not accepted on the search routes.

Custom fields (custom_filter / custom_sort)

A private custom field (private: true, see extending-content.md) is not in custom_fields for a caller below editor, and naming it in custom_filter or custom_sort is a 400 for them, as for a field that does not exist.

Admin-defined custom fields (see extending-content.md) live in one custom_fields JSONB map, so the derived filter[...]/sort= machinery above can't reach them. Two dedicated params close the gap:

# Equality (bare value) and operators
GET /api/json/posts?custom_filter[color]=red
GET /api/json/posts?custom_filter[price][gt]=10

# Combined, and mixed with regular filters
GET /api/json/posts?filter[locale]=en&custom_filter[price][lte]=20&custom_sort=-price

Filtering — custom_filter[<name>]=<value> (equality) or custom_filter[<name>][<op>]=<value> with eq, not_eq, gt, gte, lt, lte, in, ilike, null. Conditions are ANDed (with each other and with filter[...]). Semantics:

  • Field names are validated against the FieldDefinition registry — an unknown name is a 400, not an empty result.
  • Values are cast to the field's declared type and compared as jsonb, so an integer/float field compares numerically (9 < 10), boolean as a boolean, and date/datetime (ISO-8601 strings) chronologically.
  • in matches any of a list: custom_filter[color][in][]=red&custom_filter[color][in][]=blue.
  • ilike (text-like fields only) takes the usual % wildcards: custom_filter[subtitle][ilike]=%leather%.
  • null takes true/false and tests whether the record has the field at all.
  • media/reference fields match on their snapshot's stable id (custom_filter[hero_image]=<media uuid>) and support eq/not_eq/in/null (references also take the relationship operators below). On a media gallery or a list of references (multiple: true) they test membership: eq matches a list holding that id, not_eq one that doesn't, in one holding any of the listed ids.
  • Records without the field are excluded by every comparison (they're SQL NULL), and a record whose stored value has another JSON type simply doesn't match — it can't error the query.

Relationship operators — a reference field, single or a list, also takes has (holds any of the ids), has_all (holds every one), has_none (holds none of them) and count (how many targets it holds, compared with eq, gt, gte, lt, lte; a bare number means eq):

GET /api/json/posts?custom_filter[ingredients][has][]=<id>&custom_filter[ingredients][has][]=<id>
GET /api/json/posts?custom_filter[ingredients][has_all][]=<id>&custom_filter[ingredients][has_all][]=<id>
GET /api/json/posts?custom_filter[ingredients][has_none]=<id>
GET /api/json/posts?custom_filter[ingredients][count][gte]=3

The same three set operators work on the built-in relationships tags and related_<type>s (related_posts on posts, related_pages on pages, …):

GET /api/json/posts?custom_filter[tags][has_all][]=<tag id>&custom_filter[tags][has_all][]=<tag id>
GET /api/json/posts?custom_filter[related_posts][has_none]=<post id>

An included related_<type>s list comes back in the order the editor set (each link's position), as does the related_links projection (fields[post]=related_links, {id, title, slug} per record).

  • They read the relationship's edges (content_links, or the taggings), through their indexes, rather than the stored snapshot. eq/in keep the snapshot meaning above, and every operator can be combined on one field.
  • has, has_all and has_none take one id or a list of at most 100; an empty list, or something that is not an id, is a 400. count takes a whole number ≥ 0.
  • A target you may not read is not there. A draft (or audience-gated, or locked) target never matches has/has_all, is not counted by count, and has_none of it is true — exactly what you would see if it did not exist, so a filter cannot be used to find out that a draft exists. An editor's token sees drafts here as everywhere else.
  • tags and related_<type>s take has, has_all and has_none only. A custom field that happens to be named tags keeps the name: on such a type custom_filter[tags] means the field.
  • The derived filter[tags][id][in][]=<id> (any of) still works as before.
  • Cost. has, has_all and has_none are index probes per candidate record, about a millisecond in the database on 600k edges. count aggregates each candidate's edges, so it costs in proportion to the records scanned to fill the page, not the page size: about 100 ms when every one of 20,000 records has to be looked at, a few milliseconds when the first matches fill the page. Pair it with a narrowing filter on a big type. The numbers are in relationships-plan.md, workstream R6.

Link fields — a reference field's link fields (data on each connection, R3; see extending-content.md §1) filter under <field>.link.<name>, with the scalar operators (eq, not_eq, gt, gte, lt, lte, in, ilike, null), cast to the link field's type:

GET /api/json/posts?custom_filter[ingredients.link.role]=main
GET /api/json/posts?custom_filter[ingredients.link.quantity][gte]=100
  • A record matches when some connection of that field carries a matching value. Two operators on the same link field are separate conditions and may be met by different connections.

  • They read the edges' metadata (the copy of each snapshot's "link" map), under the same read rule as the relationship operators: a connection to a target you may not read is not there, so a link value cannot be used to learn that a draft target exists.

  • target ties the condition to particular records: beside the operators, it narrows them to the connections pointing at those ids. "Is this person the photographer?" rather than "is anyone?":

    GET /api/json/posts?custom_filter[contributors.link.role][eq]=photographer&custom_filter[contributors.link.role][target]=<author id>

    It takes one id or a list ([target][]=…, at most 100), and needs an operator beside it (a bare value can't carry one, so write [eq]). Several targets match a record where any one of them has the value.

  • An unknown link field, or a field that is not a reference, is a 400. customFilter in GraphQL takes the same key: {"ingredients.link.role": "main"}, and {"contributors.link.role": {"eq": "photographer", "target": "<id>"}}.

In the response a reference's snapshot carries its link values as an added "link" map — {"id", "type", "slug", "title", "link": {"role": "main"}} — and ?include=content_links serves the same map as each edge's metadata. A connection with no link value has no "link" key.

Sorting — custom_sort=<name> (ascending) or custom_sort=-<name>, comma-separated for multi-key. Records lacking the field always sort last. custom_sort composes with sort=: explicit sort keys take precedence, but custom_sort outranks an action's default order (e.g. /posts/published's -published_at). media/reference fields are not sortable.

Entries (dynamic types) — the same params work on /api/json/entries, where the field's type is resolved through the query's filter[type_name]=<name> (or filter[type_definition_id]=<uuid>) scope. Unscoped queries still work when every dynamic type declaring the field agrees on its type; if declarations diverge, the API asks you to scope rather than guessing a cast.

Search facets — /…/search and /…/semantic-search accept custom_filter too (not custom_sort; relevance/distance is the default order, and only an explicit sort= overrides it — see "Sorting search results" above).

Performance. These predicates run on unindexed JSONB extractions. They're built for the long tail of editor-owned fields; a field you filter or sort by on every request belongs as a real attribute (promote the type / add the column, D4). GraphQL exposes the same capability as customFilter / customSort arguments — see headless-graphql-api.md.

Pagination

All collection reads support offset and keyset pagination via the page[...] family of params:

ParamMeaning
page[limit]Page size. Defaults to 25, capped server-side at 100.
page[offset]Offset-based paging (row offset into the result set).
page[after]Keyset cursor — records after the given cursor.
page[before]Keyset cursor — records before the given cursor.
page[count]true to include the total record count in meta.page.total.

A requested page[limit] above the max is accepted but only the first 100 rows are returned (the cap is applied silently, not as an error).

When you paginate, the response carries pagination metadata and links:

{
  "data": [ /* ... */ ],
  "meta": { "page": { "total": 42, "limit": 2, "offset": 0 } },
  "links": {
    "self": "http://host/api/json/posts?page[limit]=2",
    "next": "http://host/api/json/posts?page[limit]=2&page[offset]=2",
    "prev": null
  }
}

Example — page through a category newest-first, with a total count:

GET /api/json/posts?filter[category_id]=<uuid>&sort=-published_at&page[limit]=10&page[count]=true
GET /api/json/posts?filter[category_id]=<uuid>&sort=-published_at&page[limit]=10&page[offset]=10

Internal callers (CMS.list_posts!/1, etc.) still receive a plain list: pagination is required?: false and not applied by default, so only requests that supply page[...] (i.e. the JSON:API layer) get a paginator.

Search & autocomplete

The /<type>/search and /<type>/autocomplete routes wrap the resources' full-text search actions. They take the action arguments as plain query params:

GET /api/json/posts/search?query=elixir&locale=en
GET /api/json/posts/autocomplete?prefix=eli
GET /api/json/media-items/search?query=logo

search also accepts the optional facets category_id, author_id, state and tag_ids[].

Media library browse (/media-items/library)

The media library's faceted browse. Every argument is optional; absent means "don't filter on that axis", and they compose:

GET /api/json/media-items/library?kind=image
GET /api/json/media-items/library?tag_ids[]=<uuid>&unused=true
GET /api/json/media-items/library?uploaded_by_id=<uuid>&uploaded_after=2026-01-01&uploaded_before=2026-06-30
  • kind — one of image, video, audio, captions, document (the MediaKind bucket, derived from content_type; also exposed as the filterable calculated field kind on the base route).
  • tag_ids[] — items carrying any of the listed tags. Media shares the content taxonomy, so these are the same tags /api/json/tags lists; the tags relationship is includable (?include=tags) on every media read.
  • uploaded_by_id — the uploading user's id. Ids only: like content's author, User is deliberately not a JSON:API resource (PII redaction), so there is no ?include=uploaded_by.
  • uploaded_after / uploaded_before — ISO dates bounding inserted_at, both inclusive of the named day.
  • unused=true — only items with no recorded reference from a published document (unused=false inverts). "Used" is the reference-edge graph the fire path maintains, with that graph's lifecycle: references are recorded when a document publishes and replaced on its next publish — so an item referenced only by never-published drafts counts as unused, while a reference from a document that was later unpublished keeps counting as a use until that document publishes again without it — and a reference from a document that was deleted keeps counting indefinitely (nothing removes a deleted document's recorded references).

Published-only search (…/published)

The base search routes go through the read policy: anonymous callers match published content only, but a bearer-keyed caller matches whatever its minting account can see. With an editor/admin key that includes drafts — and, because an admin bypasses the audience and passphrase policies outright, also audience-gated and passphrase-locked bodies. The optional state facet is merely a request the caller must remember to make, and it does not narrow the other two axes at all.

Each search read therefore has a published-only twin whose filter is applied server-side and matches exactly what an anonymous visitor could read — state == :published, audience == :public, and no passphrase (#297, #1013):

GET /api/json/posts/search/published?query=elixir&locale=en
GET /api/json/posts/semantic-search/published?query=elixir
GET /api/json/posts/autocomplete/published?prefix=eli

They take the same params minus state (the twins have no such argument — the filter cannot be widened) and keep the same relevance/distance ordering and pagination. A delivery key minted by an admin is the case that matters: on the base routes it reads gated and locked content; on the twins it reads neither. Delivery sites should use these — the search counterpart of reading /…/published instead of the plain index. See headless-consumer-guide.md → "Delivery sites: an API key widens what you see".

Sparse fieldsets & includes

Standard JSON:API fields[<type>] and include params work too, e.g. ?include=category&fields[post]=title,slug. The includable relationships on every content type are tags, category, featured_image, content_links, incoming_links and related_<type>s; anything else — notably author, which stays excluded for PII redaction (#183) — is a 400.

Link edges arrive as content_link compound members carrying their payload (kind, position, label, metadata, source_id, target_id), so a consumer can join outgoing/incoming relations (and e.g. per-link quantity metadata) without extra requests.

Since 1.1 every :reference custom field value also has an edge (#1594): kind: "reference", with field naming the custom field (a list of references, multiple: true, has one edge per item, position its 0-based place in the list) and source_type / target_type naming both ends' content types (the same vocabulary as the snapshot's "type"; on a field that links to several types, each edge carries its own item's type). So ?include=incoming_links on a record answers what links here — curated related content and references alike — and ?include=content_links gives a referrer's targets by id, which stay right when a target is renamed, where the snapshot's slug and title may lag until the referrer is next saved. The snapshot in custom_fields is unchanged. field, source_type and target_type are null on curated and payload links, and on links written before 1.1.

The targets themselves, in one request. ?include=content_links.target is not available: a reference can point at several content types, and a JSON:API include follows a relationship to exactly one (relationships plan, §7 Q1). Ask the delivery API instead — GET /api/content/:type/:slug?expand=<field> returns each target's fired json artifact under "expanded" (api.md → Expanding references) — or GraphQL's typed references(field:) (headless-graphql-api.md → Relationships).

Writing a reference. A :reference value is read back as the snapshot {"id", "type", "slug", "title"} (a list of them when the field is multiple), and a write accepts the same shape. What a write must carry depends on how many types the field allows (target_type or target_types on its field_definition; the schema export lists them as x-kiln-target-types):

  • One type: a bare id is enough ("custom_fields": {"author": "<uuid>"}), and so is a snapshot; its "type" is not consulted.
  • Several types: every item names its type, {"id": "<uuid>", "type": "post"}. A bare id, or a type the field does not allow, is refused, naming the item. The id is looked up among that type's records only.

slug and title on input are ignored; the stored snapshot is always re-read from the target.

An edge is returned only when the reader may read both of its ends: an anonymous reader of a published page never sees the edge from a draft that links to it, nor an edge to a target that has been unpublished. Editors and admins see every edge of their site. The embedded block tree is not exposed over JSON:API for reads — rendered content is served as fired artifacts at GET /api/content/:type/:slug. For writes, send the body via the block_tree attribute (see Writing).

Calculated fields

Every content type also exposes these derived, read-only fields — public calculations rather than stored columns, so ?fields[post]=title,path (or any other explicit selection) is required to load one; they are not returned by default. GET /api/json/open_api describes each of them on the resource's attributes schema.

FieldTypeFilterable / sortable
pathstringNo — full public URL path (/blog/my-post); see URLs, pathauto & redirects below
publishedbooleanYes — convenience flag for state == :published (a real SQL expression, unlike the rest of this table)
word_countintegerNo — total word count across the block tree
reading_time_minutesintegerNo — word_count ÷ the configured words-per-minute rate
effective_seo_titlestringNo — the author's seo_title, else the type's pattern expanded (#1102)
effective_seo_descriptionstringNo — same as above, for seo_description
related_linksarrayNo — curated related links, projected to [{id, title, slug}], in the order the editor set
inherited_fieldsobjectNo — what this locale variant inherits along the site's fallback chain for its :fallback fields, with the source locale: {"excerpt": {"value", "locale"}, "custom_fields": {…}} (#1327). Served only when asked for with fields[<type>]=…
custom_fields_plainobjectNo — the plain-text rendition of the :text custom fields marked "Also deliver as plain text" (deliver_plain): {"<name>": "text without tags" | null}, null when the type marks none — one line, or with "\n\n" between paragraphs and "\n" per line break for a field marked "Keep paragraph breaks" (plain_breaks). custom_fields keeps the stored HTML. Served only when asked for with fields[<type>]=…; the fired artifact (/api/content, /api/sync) carries it whenever the type marks a field
custom_fields_portableobjectNo — the Portable Text rendition of the rich :text custom fields marked "Also deliver as Portable Text" (deliver_portable): {"<name>": [<Portable Text block>, …] | null}, the shape a rich_text block's body holds; null when the type marks none. custom_fields keeps the stored HTML. Served only when asked for with fields[<type>]=…; the fired artifact (/api/content, /api/sync) carries it whenever the type marks a field

"No" means exactly that — not merely undocumented as a filter or sort: ?filter[path]=… is a clean 400 invalid_filter, and ?sort=path is likewise a clean 400 invalid_sort, not a match on nothing. These fields have no SQL expression to filter or sort by (they are computed in Elixir, several from data outside the row itself — the type registry, the block tree), so the write side of that query would have nothing to push down to Postgres. published is the one exception: it is a real state == :published SQL expression, so both filtering and sorting work normally.

URLs, pathauto & redirects

Slugs auto-derive server-side (focus keyphrase → title, stop words stripped, collision-deduped base-2, base-3, …), so a POST with just a title gets a final slug back — never implement slugging client-side. A content type may also define a pathauto-style slug pattern (TypeDefinition.slug_pattern or the Content macro's slug_pattern: option) such as [yyyy]-[mm]-[title], which then drives derivation for that type; tokens are [title], [focus-keyphrase], [category], [yyyy], [mm], [dd], composing the final URL segment (the type's path prefix stays in front). Date tokens anchor to the publish date, else the scheduled date, else the record's creation date; a pattern that expands to nothing for a record (e.g. [category] on an uncategorized entry) falls back to the default derivation, so a title alone is still always enough. Three surfaces let a front end handle URLs without mirroring Kiln's scheme:

  • path field — every content read exposes a path calculation, the full public path (/blog/my-post, /about, /<path_segment>/<slug> for dynamic types). Request it explicitly: ?fields[post]=title,slug,path.
  • path_alias — an optional multi-segment canonical URL (/products/shoes/size/42), settable over the write API. When set it becomes the record's path; the flat /<prefix>/<slug> URL 301s to it, and changing or removing it on published content leaves a 301 behind like any slug rename. GET /api/resolve reports the flat path as moved and the alias as ok.
  • GET /api/resolve?path=/blog/old-slug&locale=en — one call answers "what lives at this URL?", for catch-all routes in live (SSR) front ends: {"status":"ok","type":"post","slug":…,"id":…} renders, {"status":"moved","to":"/blog/new-slug",…} should be answered with your own 301, and a 404 {"status":"not_found"} is a real 404. Mirrors delivery exactly: published-only, content beats stale redirects, no redirect chains.
  • GET /api/json/redirects — the redirect table (world-readable, written automatically when a published record's slug changes). Static-site generators pull it — filterable, e.g. ?filter[updated_at][greater_than]=2026-07-01T00:00:00Z for incremental builds — and emit platform-native maps (Netlify _redirects, Next.js redirects()). Rows carry path, locale, target_type, target_id; resolve a row's current destination via /api/resolve or the target's path field. The <type>.updated webhook fires on published renames, so SSGs can rebuild redirect maps on push instead of polling.

Writing (#330)

Reverses D7. The JSON:API was originally read-only by design; write routes were added so external apps can write back into the CMS. Writes require an API key (Authorization: Bearer kiln_…) or an editor/admin JWT — a read-only key and anonymous callers are rejected by the resource policies. This is the same auth model as /mcp: a key acts as its owning user, bounded by its access scope. Mint keys at /editor/api-keys.

Use the JSON:API media type on both Accept and Content-Type: application/vnd.api+json.

RouteActionWhoEffect
POST /api/json/posts:create:read_write key, editor+Creates a draft, attributed to the key's owner
PATCH /api/json/posts/:id:update:read_write key, editor+Edits content; re-fires if already published
PATCH /api/json/posts/:id/submit-for-review:submit_for_review:read_write key, editor+draft → in_review
PATCH /api/json/posts/:id/return-to-draft:return_to_draft:read_write key, adminin_review → draft — the return half of the approve/return pair
PATCH /api/json/posts/:id/publish:publish:read_write key, adminPublishes and fires artifacts
PATCH /api/json/posts/:id/unpublish:unpublish:read_write key, adminTakes content down, purges artifacts
DELETE /api/json/posts/:id:destroy:read_write key, adminReversible soft-delete (AshArchival)

Pages expose the identical set; the dynamic tier is /api/json/entries (a create needs a type_definition_id — look it up with GET /api/json/type-definitions/by-name/:name, see Discovering dynamic types; /mcp's read_type_definitions is the same read).

Authorization mirrors /mcp: a read-only key can run none of these; a :read_write key on a :viewer account can run none; a :read_write key on an :editor can create/update/submit; return-to-draft, publish, unpublish and delete require an :admin account. An editor submits for review; deciding the outcome — approve or return — is the admin's half. Hard delete (:purge) is never routed and is API-key-banned regardless of scope — DELETE is the reversible soft-delete.

Creating and editing

# Create a draft post
curl -s http://localhost:4000/api/json/posts \
  -H 'accept: application/vnd.api+json' \
  -H 'content-type: application/vnd.api+json' \
  -H "authorization: Bearer $KILN_API_KEY" \
  -d '{
    "data": {
      "type": "post",
      "attributes": {
        "title": "Written over the API",
        "slug": "hello-api",
        "block_tree": [{ "type": "rich_text", "content": "<p>Body</p>", "order": 1 }]
      }
    }
  }'

tag_ids / category_id / featured_image_id and the SEO / audience / custom_fields / scheduling attributes are all writable. Relationship arrays (tag_ids, related_post_ids) are passed as attributes.

scheduled_at takes publish rights (an admin, or an editor on a site that lets editors publish). Without them, write proposed_publish_at instead: a date you are asking for, which publishes nothing until someone with publish rights sets scheduled_at (which clears the proposal, as publishing does). Like the body's blocks, it is write-only on the APIs: no read returns it. It is an editorial request, not something a reader of the content should see (content lifecycles).

Discovering dynamic types

An entry of an admin-defined type is created on /api/json/entries with the type's type_definition_id. The type registry is read-only over the API:

RouteAnswers
GET /api/json/type-definitionsEvery live type in the request's org; filter[name]=, sort=, page[...] as usual
GET /api/json/type-definitions/by-name/:nameOne type by its machine name — 404 on a miss
GET /api/json/type-definitions/:idOne type by id

Each row carries name (the permanent machine key), label, plural_label, path_segment, description, icon, schema_org_type, has_excerpt, has_published_feed, the slug/alias/SEO patterns and default_review_after_days. Add ?include=field_definitions to get the type's custom-field schema (name, label, field_type, required, options, default, help_text, position, …) — the keys an entry's custom_fields map takes. ?include=field_definitions.group_fields adds the sub-fields of each :group field — the keys each of its rows takes; a sub-field names its group in group_of_id.

It is the same read as /mcp's read_type_definitions, with the same policy: the credential must belong to an editor or admin of the request's org (a read-only key is enough — this is a read). A viewer's key or an anonymous caller gets an empty list and a 404 on a single type. The org is the host's, as everywhere else, so a key never sees another site's types; archived (trashed) types are not listed. There are no write routes — types are defined in /editor/types.

# 1. Resolve the type's machine name to its id
TYPE_ID=$(curl -s http://localhost:4000/api/json/type-definitions/by-name/doc \
  -H 'accept: application/vnd.api+json' \
  -H "authorization: Bearer $KILN_API_KEY" | jq -r '.data.id')

# 2. Create a draft entry of that type (`:read_write` key, editor+)
curl -s http://localhost:4000/api/json/entries \
  -H 'accept: application/vnd.api+json' \
  -H 'content-type: application/vnd.api+json' \
  -H "authorization: Bearer $KILN_API_KEY" \
  -d '{
    "data": {
      "type": "entry",
      "attributes": {
        "title": "Written against a discovered type",
        "slug": "hello-doc",
        "type_definition_id": "'"$TYPE_ID"'",
        "custom_fields": {}
      }
    }
  }'

Writing tags — replace vs merge

tag_ids is the content's complete tag set (append-and-remove), so a PATCH carrying a partial list detaches everything omitted. A partial writer should use the merge verbs on PATCH /:id instead (#521):

AttributeSemantics
add_tag_idsAttaches the listed tags and leaves the others alone. Re-adding an attached tag is a no-op; a repeated id is de-duplicated; an id that matches no tag is a 404 (not_found, no field pointer — the lookup, not the record, is what failed).
remove_tag_idsDetaches the listed tags and leaves the others alone. Removing a tag that isn't attached — or an id that matches no tag at all — is a silent no-op, so retries are safe and a wrong id is not reported.
tag_idsReplaces the set. Omitting it leaves tags untouched; both [] and null clear them.
curl -X PATCH http://localhost:4000/api/json/posts/<uuid> \
  -H "authorization: Bearer $KILN_API_KEY" \
  -H 'content-type: application/vnd.api+json' \
  -d '{"data":{"type":"post","id":"<uuid>","attributes":{"add_tag_ids":["<tag-uuid>"]}}}'

tag_ids may not be sent alongside either merge verb, and an id may not appear in both add_tag_ids and remove_tag_ids — both are rejected with a 400 rather than resolved in some arbitrary order. "Alongside" includes "tag_ids": null, which clears rather than being ignored; an empty add_tag_ids/remove_tag_ids carries no intent and is not a conflict, so a client that always serializes all three keys still gets the replace path. The same three attributes exist on GraphQL's updatePost (addTagIds / removeTagIds) and on the MCP update_* tools. They are update-only — POST has no existing links to merge against, so a create takes tag_ids alone.

The related-content arrays carry the same verbs (#637): related_post_ids replaces, and add_related_post_ids / remove_related_post_ids merge, with identical rules (an explicit null in the complete-set argument counts as replacing; the same id may not appear in both verbs). The sibling arrays follow the same naming — add_related_page_ids / remove_related_page_ids, and add_related_entry_ids / remove_related_entry_ids on the dynamic tier.

Writing relationships

Kiln has two kinds of relationship a client writes, and they go through different doors.

A :reference custom field is part of the record. Write it with PATCH /api/json/<plural>/:id, sending the field's whole new value under custom_fields (other custom fields are left alone; see Partial updates merge in KilnCMS.CMS.Changes.ApplyCustomFields). It is the record's normal update, so policies, If-Match, versioning, the webhook and the re-fire all apply, and the edges in content_links are rewritten to match in the same transaction. The list's order is the order you send.

A field that connects several records (multiple: true), one type:

{
  "data": {
    "type": "post",
    "id": "<post-uuid>",
    "attributes": {
      "custom_fields": { "ingredients": ["<page-uuid-1>", "<page-uuid-2>"] }
    }
  }
}

Several allowed types (target_types): every item names its type.

{ "custom_fields": { "see_also": [
  { "id": "<page-uuid>", "type": "page" },
  { "id": "<entry-uuid>", "type": "recipe" }
] } }

Link fields (R3) ride on each item as "link". A link value left out keeps what that connection stored, so a reorder that re-sends bare ids keeps every connection's data:

{ "custom_fields": { "ingredients": [
  { "id": "<page-uuid-1>", "link": { "quantity": 2, "role": "main" } },
  { "id": "<page-uuid-2>", "link": { "quantity": 1 } }
] } }

To clear the field send [] (or null for a single-valued one). A repeated id, a target that doesn't exist, a type the field doesn't allow, a link value of the wrong type, or a list outside min_items / max_items fails the PATCH with a 400 naming the item. GraphQL has the same edit as connect / disconnect / reorder mutations that build the list server-side (headless-graphql-api.md).

A payload link is not part of either record. It is a content_link with no field: a curated link with a kind (default related), position, label and free metadata. It has routes of its own:

RouteActionWhoEffect
POST /api/json/content-links:create_linkmay update the source recordLinks source_id → target_id
PATCH /api/json/content-links/:id:update_linkmay update the sourceChanges position, label, metadata
DELETE /api/json/content-links/:id:destroy_linkmay update the sourceRemoves the link
GET /api/json/content-links/:id:readmay read both endsOne link
curl -s -X POST http://localhost:4000/api/json/content-links \
  -H 'accept: application/vnd.api+json' -H 'content-type: application/vnd.api+json' \
  -H "authorization: Bearer $KILN_API_KEY" \
  -d '{"data":{"type":"content_link","attributes":{
        "source_id":"<page-uuid>","target_id":"<post-uuid>",
        "label":"Further reading","metadata":{"order":"2"}}}}'

A link belongs to its source: every write is authorized as an update of the source record, by that record's own policy — a read-only key, a viewer, or an editor whose editable_types leave out the source's type gets a 403. The target must be a record the writer can read on the same site, and a record can't link to itself. source_type and target_type are set from the records. The ends are fixed: to point somewhere else, delete the link and create another. A link is not part of its source's document, so it does not bump the source's lock_version, write a version or send a webhook.

kind takes a name the server already knows (related and the kinds Kiln uses); an unknown name is refused rather than created. reference is refused on create, and PATCH / DELETE refuse a reference edge (one with a field): those are a copy of a custom field's value, and change only when the field does. A payload link to a record of the source's own type also shows in the source's related_<type>s, beside the related content the editor curates.

Idempotency-Key works on POST and PATCH here as on every other write.

Writing body content — the block_tree attribute

The typed blocks union isn't exposed on the auto API (it isn't public?), so body content is written through a public block_tree attribute: an array of block maps (the same shape the editor and MCP submit), cast into the union — which sanitizes rich-text HTML and media URLs. On an update, omit block_tree to leave the body untouched (a metadata-only PATCH never wipes it); send [] to clear it.

Or send Markdown — body_markdown. A Markdown string in place of block_tree, converted server-side by KilnCMS.Markdown (the converter the editor's paste and .md import use) into the same block maps, then cast and sanitized the same way: headings, lists, tables and fenced code become Portable Text, a standalone image an image block, a bare YouTube/Vimeo link an embed. Raw HTML in it goes through the rich-text allowlist. Send one or the other — both is a 400. "" clears the body; front matter is dropped (title and slug are their own attributes). The blocks it produces carry no _ids, so it replaces the body wholesale, like an id-less block_tree. Details: Markdown.

Round-trip block ids when updating. Read the tree's identity first — the block_ids calculation (?fields[post]=block_ids) projects the stored tree to _id/_type only, nested columns children included in the positions they render — and echo each block's _id on the maps you send back. That is what lets the server tell an in-place edit from a replacement; on a page carrying an admin-set nested value (a field behind editable_by), a non-admin write that drops the ids is refused (#954), with the error naming this surface. The fired :json artifact carries the same _ids for published content.

Concurrency: ETag and If-Match

Without a precondition, a PATCH is last-write-wins: the server reads the record and applies your attributes in the same request, so a client editing from a copy it fetched earlier overwrites anything an editor saved in between, without an error. To make a write conditional on the version you read:

  1. Every single-record response carries an ETag: GET /:id, and the responses to PATCH /:id and the workflow routes. It looks like "4-draft" and is built from the record's lock_version (bumped by every content edit) and its state (moved by every workflow transition). Treat it as opaque and echo it back unchanged.
  2. Send it as If-Match on PATCH /:id, on PATCH /:id/publish, /unpublish, /submit-for-review, /return-to-draft, or on DELETE /:id. The write happens only if the record still carries that tag. Otherwise you get 412 Precondition Failed and nothing changes:
{
  "errors": [{
    "status": "412",
    "code": "precondition_failed",
    "detail": "this content was changed since you read it — it is now version 5 (draft); re-read it and retry",
    "meta": { "etag": "\"5-draft\"", "lock_version": 5, "state": "draft" }
  }]
}
# Read, keeping the ETag…
curl -si http://localhost:4000/api/json/posts/<uuid> \
  -H 'accept: application/vnd.api+json' -H "authorization: Bearer $KEY" | grep -i etag
# etag: "4-draft"

# …and write only if nobody has changed it since.
curl -s -X PATCH http://localhost:4000/api/json/posts/<uuid> \
  -H 'accept: application/vnd.api+json' -H 'content-type: application/vnd.api+json' \
  -H "authorization: Bearer $KEY" -H 'if-match: "4-draft"' \
  -d '{ "data": { "type": "post", "id": "<uuid>", "attributes": { "title": "New" } } }'
  • If-Match: * passes whenever the record exists. Several tags, comma-separated, pass if any one matches.
  • A tag the server could not have issued fails with 412. That covers a weak W/"…" tag (If-Match uses strong comparison), a mistyped tag, or one without quotes. It is not ignored: ignoring it would turn a typo into an unguarded write.
  • Publishing moves the tag even though it doesn't edit content. A client that read a draft and PATCHes after someone published it gets a 412 rather than silently editing live content. If-Match on /publish likewise means "publish the version I reviewed": an edit made after your read refuses it.
  • The check runs inside the write's transaction, against the row locked for update, so a save that lands between your request arriving and the write cannot slip past it.
  • No If-Match, no check: existing clients behave exactly as before.

lock_version is also a read-only attribute on every content type, if you'd rather track versions yourself. The write routes accept it as an optional expected_lock_version attribute, which compares lock_version alone, like the GraphQL argument. Browsers can read ETag and send If-Match cross-origin: both are allowed in the CORS headers.

Retrying a write safely

Send an Idempotency-Key header and a retry of the same POST or PATCH replays the first attempt's response instead of creating a second document or running a second transition. See api.md → Idempotent writes.

Re-fire semantics

Firing (immutable per-surface artifact regeneration) is bound to :publish, so the publish route re-fires automatically. Editing already-published content with PATCH /:id also re-fires (a published-guarded re-fire on :update, #330), so a write-through to live content never leaves a stale artifact. Draft edits do not fire.

Editing media metadata

Files are created through the upload API (POST /api/media — JSON:API has no file upload; see api.md → Uploading media). What an item says about itself is edited here:

RouteActionWhoEffect
PATCH /api/json/media-items/:id:update_metadata:read_write key (or JWT), editor+Edits alt, caption, decorative, focal_x, focal_y and tags
curl -s -X PATCH http://localhost:4000/api/json/media-items/<uuid> \
  -H 'accept: application/vnd.api+json' \
  -H 'content-type: application/vnd.api+json' \
  -H "authorization: Bearer $KILN_API_KEY" \
  -d '{ "data": { "type": "media_item", "id": "<uuid>",
        "attributes": { "alt": "The kiln at dusk", "focal_x": 0.3, "add_tag_ids": ["<tag uuid>"] } } }'
  • The focal point is 0.0–1.0 on each axis; moving it re-derives the focal-aware crops in the background, as clicking the point in the media library does.
  • Tags take the same three arguments as content — tag_ids replaces the set, add_tag_ids / remove_tag_ids merge — with the same rules (see "Writing tags — replace vs merge" above).
  • Nothing the pipeline owns is writable: url, storage_key, variants, content_type, dimensions and sizes come from the bytes. Sending one is a 400, not a silent write. audience (gating a document) stays an editor-UI action for now, because it moves the file between storage buckets.
  • Deleting media is not routed for any key.

Workflow routes take an empty resource object

The workflow PATCH routes (/publish, /unpublish, /submit-for-review, /return-to-draft) carry no attributes — send the JSON:API resource identifier only:

curl -s -X PATCH http://localhost:4000/api/json/posts/<uuid>/publish \
  -H 'accept: application/vnd.api+json' \
  -H 'content-type: application/vnd.api+json' \
  -H "authorization: Bearer $KILN_ADMIN_API_KEY" \
  -d '{ "data": { "type": "post", "id": "<uuid>", "attributes": {} } }'

The full request/response schemas (including the write routes) are in the published OpenAPI spec at /api/json/open_api and the Swagger UI at /api/json/swaggerui.