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 withAPI_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+jsonRequests 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
| Resource | Collection | Single record | Extra reads |
|---|---|---|---|
| Page | GET /api/json/pages | GET /api/json/pages/:id | /pages/by-slug/:slug, /pages/search, /pages/semantic-search, /pages/autocomplete, /pages/published |
| Post | GET /api/json/posts | GET /api/json/posts/:id | /posts/by-slug/:slug, /posts/search, /posts/semantic-search, /posts/autocomplete, /posts/published |
| MediaItem | GET /api/json/media-items | GET /api/json/media-items/:id | /media-items/search, /media-items/library; PATCH /media-items/:id edits metadata (below) |
| Category | GET /api/json/categories | GET /api/json/categories/:id | /categories/by-slug/:slug |
| Tag | GET /api/json/tags | GET /api/json/tags/:id | /tags/by-slug/:slug |
| TagGroup | GET /api/json/tag-groups | GET /api/json/tag-groups/:id | /tag-groups/by-slug/:slug |
| Entry (dynamic types) | GET /api/json/entries | GET /api/json/entries/:id | same set as Post, filtered by filter[type_name]=; /entries/by-slug/:slug takes ?type_definition_id= |
| TypeDefinition | GET /api/json/type-definitions | GET /api/json/type-definitions/:id | /type-definitions/by-name/:name — editor-or-above credential; see Discovering dynamic types |
| ContentRelease | GET /api/json/releases | GET /api/json/releases/:id | ?include=items — editor-or-above credential, read-only; see Content releases |
| ReleaseItem | GET /api/json/release-items | GET /api/json/release-items/:id | ?filter[release_id]= — same |
| ContentLink | — | GET /api/json/content-links/:id | POST, 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.
| Route | Answers |
|---|---|
GET /api/json/releases | Every release in the request's org; filter[state]=scheduled, sort=scheduled_at, page[...] as usual |
GET /api/json/releases/:id | One release; add ?include=items for its contents |
GET /api/json/release-items | Release items; filter[release_id]=<id> for one release's |
GET /api/json/release-items/:id | One 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/pngSorting
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 → ZAny 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=-priceFiltering — 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
FieldDefinitionregistry — 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/floatfield compares numerically (9 < 10),booleanas a boolean, anddate/datetime(ISO-8601 strings) chronologically. inmatches 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%.nulltakestrue/falseand tests whether the record has the field at all.media/referencefields match on their snapshot's stableid(custom_filter[hero_image]=<media uuid>) and supporteq/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:eqmatches a list holding that id,not_eqone that doesn't,inone 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]=3The 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/inkeep the snapshot meaning above, and every operator can be combined on one field. has,has_allandhas_nonetake one id or a list of at most 100; an empty list, or something that is not an id, is a 400.counttakes 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 bycount, andhas_noneof 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. tagsandrelated_<type>stakehas,has_allandhas_noneonly. A custom field that happens to be namedtagskeeps the name: on such a typecustom_filter[tags]means the field.- The derived
filter[tags][id][in][]=<id>(any of) still works as before. - Cost.
has,has_allandhas_noneare index probes per candidate record, about a millisecond in the database on 600k edges.countaggregates 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]=100A 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.targetties 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.
customFilterin 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/customSortarguments — see headless-graphql-api.md.
Pagination
All collection reads support offset and keyset pagination via the
page[...] family of params:
| Param | Meaning |
|---|---|
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]=10Internal callers (
CMS.list_posts!/1, etc.) still receive a plain list: pagination isrequired?: falseand not applied by default, so only requests that supplypage[...](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=logosearch 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-30kind— one ofimage,video,audio,captions,document(theMediaKindbucket, derived fromcontent_type; also exposed as the filterable calculated fieldkindon 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/tagslists; thetagsrelationship is includable (?include=tags) on every media read.uploaded_by_id— the uploading user's id. Ids only: like content'sauthor,Useris deliberately not a JSON:API resource (PII redaction), so there is no?include=uploaded_by.uploaded_after/uploaded_before— ISO dates boundinginserted_at, both inclusive of the named day.unused=true— only items with no recorded reference from a published document (unused=falseinverts). "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=eliThey 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.
| Field | Type | Filterable / sortable |
|---|---|---|
path | string | No — full public URL path (/blog/my-post); see URLs, pathauto & redirects below |
published | boolean | Yes — convenience flag for state == :published (a real SQL expression, unlike the rest of this table) |
word_count | integer | No — total word count across the block tree |
reading_time_minutes | integer | No — word_count ÷ the configured words-per-minute rate |
effective_seo_title | string | No — the author's seo_title, else the type's pattern expanded (#1102) |
effective_seo_description | string | No — same as above, for seo_description |
related_links | array | No — curated related links, projected to [{id, title, slug}], in the order the editor set |
inherited_fields | object | No — 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_plain | object | No — 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_portable | object | No — 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:
pathfield — every content read exposes apathcalculation, 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'spath; 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/resolvereports the flat path asmovedand the alias asok.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:00Zfor incremental builds — and emit platform-native maps (Netlify_redirects, Next.jsredirects()). Rows carrypath,locale,target_type,target_id; resolve a row's current destination via/api/resolveor the target'spathfield. The<type>.updatedwebhook 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 itsaccessscope. Mint keys at/editor/api-keys.
Use the JSON:API media type on both Accept and Content-Type:
application/vnd.api+json.
| Route | Action | Who | Effect |
|---|---|---|---|
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, admin | in_review → draft — the return half of the approve/return pair |
PATCH /api/json/posts/:id/publish | :publish | :read_write key, admin | Publishes and fires artifacts |
PATCH /api/json/posts/:id/unpublish | :unpublish | :read_write key, admin | Takes content down, purges artifacts |
DELETE /api/json/posts/:id | :destroy | :read_write key, admin | Reversible 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:
| Route | Answers |
|---|---|
GET /api/json/type-definitions | Every live type in the request's org; filter[name]=, sort=, page[...] as usual |
GET /api/json/type-definitions/by-name/:name | One type by its machine name — 404 on a miss |
GET /api/json/type-definitions/:id | One 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):
| Attribute | Semantics |
|---|---|
add_tag_ids | Attaches 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_ids | Detaches 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_ids | Replaces 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:
| Route | Action | Who | Effect |
|---|---|---|---|
POST /api/json/content-links | :create_link | may update the source record | Links source_id → target_id |
PATCH /api/json/content-links/:id | :update_link | may update the source | Changes position, label, metadata |
DELETE /api/json/content-links/:id | :destroy_link | may update the source | Removes the link |
GET /api/json/content-links/:id | :read | may read both ends | One 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:
- Every single-record response carries an
ETag:GET /:id, and the responses toPATCH /:idand the workflow routes. It looks like"4-draft"and is built from the record'slock_version(bumped by every content edit) and itsstate(moved by every workflow transition). Treat it as opaque and echo it back unchanged. - Send it as
If-MatchonPATCH /:id, onPATCH /:id/publish,/unpublish,/submit-for-review,/return-to-draft, or onDELETE /:id. The write happens only if the record still carries that tag. Otherwise you get412 Precondition Failedand 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-Matchuses 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-Matchon/publishlikewise 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:
| Route | Action | Who | Effect |
|---|---|---|---|
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.0on 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_idsreplaces the set,add_tag_ids/remove_tag_idsmerge — 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 a400, 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.