KilnClient
Official Elixir client for the KilnCMS
APIs: the JSON:API read surface (/api/json/*), the JSON:API write surface
(create, update, workflow transitions, soft-delete), per-type keyword /
semantic search and autocomplete, hybrid search (/api/search), fired
artifacts (/api/content/:type/:slug), and a minimal GraphQL helper for
/gql.
Extracted from the client Verscienta's production site built and hardened against a live Kiln (kiln_cms#300). Its job is to encode the safe defaults once, so consumers don't rediscover the traps (kiln_cms#297) one production incident at a time.
Installation
def deps do
[
{:kiln_client, "~> 0.3"}
# or, until it's published to Hex:
# {:kiln_client, github: "The-Verscienta/kiln_cms", sparse: "clients/elixir/kiln_client"}
]
end
Publishing is prepared, not yet done. The package metadata and the release workflow are in place (see Releasing), but the first publish to Hex is a manual maintainer step. Until it happens, use the
github:/sparse:dependency above.
Configuration
config :kiln_client,
base_url: "https://cms.example.com",
api_key: System.get_env("KILN_API_KEY"), # optional bearer key
public_url: "https://cms.example.com", # optional, defaults to base_url
req_options: [] # merged into every Req request
Mint delivery keys on a :viewer account (see Kiln's docs/api.md →
"API keys") so a leaked credential can't widen visibility anywhere.
Published-only by default
Kiln's read policy authorizes any :editor/:admin identity for every
workflow state — including a service API key minted on such an account. This
client therefore reads the server-side filtered surfaces by default:
| Call | Route |
|---|---|
list/2, one/3, by_ids/3 |
GET /api/json/:plural/published |
text_search/3 |
GET /api/json/:plural/search/published |
semantic_search/3 |
GET /api/json/:plural/semantic-search/published |
autocomplete/3 |
GET /api/json/:plural/autocomplete/published |
The state == :published filter lives in the server action, so it holds
whatever identity your key carries. Editor-facing callers that genuinely need
drafts opt out per call with published: false (which uses the plain
routes and requires an editor/admin bearer key).
The hybrid search/2 (GET /api/search) has no published-only variant —
its visibility follows the credential, which is why viewer-minted keys matter.
Usage
# Lists, filters, includes, pagination
{:ok, %{items: posts, total: total}} =
KilnClient.list("posts", filter: %{locale: "en"}, include: ["tags"], limit: 10)
# First match or {:error, :not_found}
{:ok, post} = KilnClient.one("posts", %{slug: "hello-world", locale: "en"})
# Admin-defined custom fields (filter[…] can't reach into custom_fields)
{:ok, %{items: cheap}} =
KilnClient.list("entries",
filter: %{type_name: "product"},
custom_filter: %{price: {:lte, 10}}
)
# The dynamic-type registry (editor-or-above key) and its custom-field schema
{:ok, product_type} =
KilnClient.one("type-definitions", %{name: "product"}, include: ["field_definitions"])
# Search
{:ok, %{items: hits}} = KilnClient.text_search("posts", "elixir", locale: "en")
{:ok, %{items: near}} = KilnClient.semantic_search("posts", "functional programming")
{:ok, %{items: sugg}} = KilnClient.autocomplete("posts", "eli")
{:ok, sections} = KilnClient.search("kiln", facets: true)
# Rendered content (fired artifact; retries once on a cold cache)
{:ok, artifact} = KilnClient.artifact("posts", "hello-world", surface: "json")
# Mirror the public site: a snapshot the first time, then upserts AND
# deletes since the stored cursor (anonymous view, whatever key is set)
{:ok, %{items: items, cursor: cursor}} = KilnClient.sync(cursor: stored_cursor)
# Join relationships through the included lookup
{:ok, %{items: [post | _], included: included}} =
KilnClient.list("posts", include: ["tags"], limit: 1)
tags = KilnClient.resolve(post, "tags", included)
Results are flattened JSON:API resources: the attributes map (string keys)
plus "id"/"type", with relationships reduced to {type, id} ref maps.
Image transforms
KilnClient.Image builds URLs for Kiln's on-the-fly image transforms
(GET /media/:id/t/:ops) from a media item as the client returns it. Pure
string building — no request is made.
# `media` is a media item as the client returns it — e.g. joined through
# `resolve/3` — needing "id", "url", "focal_x"/"focal_y" (and "width"/"height"
# for a srcset); atom keys work too.
KilnClient.Image.url(media, width: 800, aspect_ratio: "16:9", format: :auto)
#=> "https://cms.example.com/media/<id>/t/w_828,ar_16:9,fm_auto,v_4b87b277"
KilnClient.Image.srcset(media, aspect_ratio: "16:9", format: :auto)
#=> "https://cms.example.com/media/<id>/t/w_256,ar_16:9,fm_auto,v_… 256w, …"
Options: :width, :height, :aspect_ratio ("16:9" or {16, 9}),
:dpr (1–3), :fit (:cover/:contain), :crop (:focal, :center,
:top, :bottom, :left, :right), :format (:auto, :jpg, :png,
:webp, :avif), :quality (1–100). srcset/2 also takes :widths.
path/2 gives the root-relative path; KilnClient.image_url/2 and
KilnClient.image_srcset/2 are shorthands.
Unsigned (the default) URLs are served only for allowlisted values, so
:width/:height snap up to Kiln's size ladder (pass :sizes if the server's
ladder was changed). Signed URLs take any size exactly as given. Signing
needs the server's KILN_IMAGE_TRANSFORM_KEY, which is a server-side secret —
configure it only in code that runs on your server, never in a browser bundle:
config :kiln_client, image_transform_key: System.get_env("KILN_IMAGE_TRANSFORM_KEY")
Pass sign_key: nil on a call to build an unsigned URL anyway.
Verifying webhooks
KilnClient.Webhook.verify/4 checks a delivery's x-kilncms-webhook-signature
(t=<unix>,v1=<hex>, an HMAC-SHA256 of "<t>.<raw body>") against the
endpoint's signing secret. It refuses a t more than five minutes from your
clock, so a captured request can't be replayed later. Verify the raw body.
A re-encoded parse won't match.
with [header] <- Plug.Conn.get_req_header(conn, KilnClient.Webhook.signature_header()),
:ok <- KilnClient.Webhook.verify(secret, conn.assigns.raw_body, header) do
%{"event" => event, "delivery_id" => delivery_id, "data" => data} =
Jason.decode!(conn.assigns.raw_body)
# delivery_id is stable across retries: remember it for the window to drop duplicates.
else
_ -> send_resp(conn, 400, "bad signature")
end
The error reasons are :malformed, :expired and :mismatch. Pass
tolerance: seconds to change the window.
Media uploads
Files enter the library over REST, not JSON:API — but they are writes like
any other: they take a :read_write API key per call (api_key:) or from
config, refuse with {:error, %KilnClient.Error{reason: :no_api_key}} before
sending anything when there is none, and report failures as the same error
struct (a read-only key is reason: :forbidden).
# Multipart, streamed from disk; metadata optional.
{:ok, item} = KilnClient.upload_media("priv/kiln.jpg", alt: "The kiln at dusk", focal_x: 0.3)
item["processing"] # true while a video's metadata strip is pending — its url isn't live yet
# Server-side fetch of a public URL (SSRF-guarded, ≤ 25 MB).
{:ok, item} = KilnClient.import_media("https://example.com/cat.png", tag_ids: [tag_id])
# Metadata edits: alt/caption/decorative/focal point/tags (add_tag_ids / remove_tag_ids merge).
{:ok, item} = KilnClient.update_media(item["id"], caption: "Firing day", add_tag_ids: [tag_id])
# Large files straight to object storage (server on S3 with a private bucket; else a 501).
{:ok, item} = KilnClient.upload_media_direct("footage.mp4", alt: "Loading the kiln")
The server byte-sniffs every file and runs it through the media library's own
pipeline — metadata stripping, size caps, variants. See Kiln's docs/api.md →
"Uploading media".
Editorial reads (editor-or-above key)
For tools about the content — migrations, audit exports, launch dashboards — never a delivery site's key. Anonymous calls are a 401, a viewer's key a 404.
# A document's version history, newest first (by id, not slug)
{:ok, %{"data" => revisions, "meta" => %{"next_cursor" => cursor}}} =
KilnClient.list_revisions("post", post_id, limit: 50)
# One revision's changes + the full document as it stood then
{:ok, %{"snapshot" => snapshot}} = KilnClient.revision("post", post_id, version_id)
# Revert the content to it (a :read_write key; a read-only key gets a 403)
{:ok, %{"revision" => new_revision}} = KilnClient.restore_revision("post", post_id, version_id)
# Content releases (read-only) and what each will publish or take down
{:ok, %{items: releases, included: included}} =
KilnClient.list_releases(filter: %{state: "scheduled"}, include: ["items"])
Writing content
create/3, update/4, transition/4 (with submit_for_review/3,
return_to_draft/3, publish/3, unpublish/3) and delete/3 drive Kiln's
JSON:API write surface (see
docs/json-api.md → Writing). They need
a :read_write API key — editor-or-above to create, update and submit for
review; admin to return to draft, publish, unpublish and delete. The key comes
from the per-call :api_key option, else the configured :api_key; with
neither, the call returns {:error, %KilnClient.Error{reason: :no_api_key}}
without sending anything.
A writer's key is the opposite of the :viewer key delivery reads want, so
keep the configured key for reads and pass the writer's per call:
writer = [api_key: System.fetch_env!("KILN_WRITE_KEY")] # editor, :read_write
admin = [api_key: System.fetch_env!("KILN_ADMIN_KEY")] # admin, :read_write
# Always created as a draft, attributed to the key's owner.
{:ok, post} =
KilnClient.create(
"posts",
%{title: "Written over the API", slug: "hello-api", body_markdown: "# Hello"},
writer
)
# Only what you send changes. `tag_ids` REPLACES the set; merge with
# add_tag_ids / remove_tag_ids instead (not both styles in one call).
{:ok, _} = KilnClient.update("posts", post["id"], %{add_tag_ids: [tag_id]}, writer)
{:ok, _} = KilnClient.submit_for_review("posts", post["id"], writer)
{:ok, _} = KilnClient.publish("posts", post["id"], admin) # fires the artifacts
:ok = KilnClient.delete("posts", post["id"], admin) # reversible soft-delete
- The first argument is the plural route, as for reads. The JSON:API
typethe server validates is derived from it ("entries"→"entry"); passtype: "person"for an irregular plural. - A dynamic-type entry is created on
"entries"with itstype_definition_id— look it up withKilnClient.one("type-definitions", %{name: "product"}). - Body content goes in
block_tree(a list of block maps) orbody_markdown, never both. When rewriting ablock_tree, echo each block's_id(read them withfields: %{"post" => ["block_ids"]}) so the server can tell an edit from a replacement. - Editing published content re-fires its artifacts; draft edits do not.
transition/4takes any verb and kebab-cases it into the route, so a verb a newer server adds is reachable before a client release names it.
GraphQL
{:ok, %{"postBySlug" => post}} =
KilnClient.graphql(
"query ($slug: String!, $locale: String!) { postBySlug(slug: $slug, locale: $locale) { title } }",
%{slug: "hello-world", locale: "en"}
)
A minimal helper, not a GraphQL client: it posts {query, variables, operationName?} to /gql (operation_name: option), sends the API key when
there is one (the published-content queries need none), and returns
{:ok, data} — or {:error, %KilnClient.Error{reason: :graphql}} with the
errors and any partial data when the response carries a top-level errors
array. No codegen. Ash mutations report a refused write inside data —
the payload's own errors field next to result: nil — so select
errors { message code } on mutations and check it.
Errors
Writes and graphql/3 return {:error, %KilnClient.Error{}}. Branch on
:reason:
:reason |
When |
|---|---|
:no_api_key |
a write with no key configured or passed — nothing was sent |
:unauthorized / :forbidden |
401 (no/invalid/expired key) / 403 (the key's owner lacks the right) |
:not_found |
404 |
:validation |
400 / 422 — KilnClient.Error.pointers/1, field_errors/1. AshJsonApi answers most attribute errors with 400, not 422 |
:conflict |
409 — a transition from the wrong state (code: "invalid_state_transition", KilnClient.Error.current_state/1) or a lost race |
:rate_limited |
429 — wait :retry_after seconds |
:server |
5xx (a 503 may carry :retry_after) |
:transport |
no response (DNS, refused, TLS, timeout); the exception is in :exception |
:graphql |
/gql answered with top-level errors |
:http |
any other status |
Every error also carries :status, :code (the first error's) and :errors
(the JSON:API errors list). The API key is only ever sent as the
Authorization header — no error carries it.
Reads are unchanged: list/2 and friends still return
{:error, {:http_status, status, body}} (or a transport exception), exactly as
before. KilnClient.Error.normalize/1 converts one into the struct when you
want a single error handler for both.
Testing your integration
Every request honors req_options, so Req.Test
stubs the whole client without a running Kiln:
# config/test.exs
config :kiln_client, req_options: [plug: {Req.Test, KilnClient}]
# in a test
Req.Test.stub(KilnClient, fn conn ->
Req.Test.json(conn, %{"data" => [%{"id" => "1", "type" => "post", "attributes" => %{}}]})
end)
Releasing
Publishing runs from .github/workflows/release-clients.yml, triggered only
by a tag named kiln_client-vX.Y.Z (it cannot match the core's vX.Y.Z
release tags). The workflow checks the tag equals @version in mix.exs,
runs the same format/compile/test gate as CI, builds the package with
mix hex.build, and — after approval in the hex environment — attests the
tarball's build provenance, proves a rebuild is byte-identical to it (Hex has
no "publish this tarball" task, but its tarballs are reproducible), and runs
mix hex.publish --yes (package and HexDocs).
- Bump
@versioninmix.exs, add aCHANGELOG.mdentry, and merge. - Tag the merge commit
kiln_client-vX.Y.Zand push the tag. - Approve the run in the
hexenvironment.
One-time setup a maintainer must do before the first run can succeed:
- Hex: the first publish claims the name
kiln_client(check it is still free on hex.pm) and makes the account whose key published it the package owner — so generate the key on the maintainers' Hex account, not a personal one: hex.pm → Dashboard → Keys, with theapi:writepermission. Add co-owners afterwards withmix hex.owner add kiln_client <user>. - GitHub: create an environment named
hex(Settings → Environments), add required reviewers, and store the key there as the environment secretHEX_API_KEY.