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

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

  1. Bump @version in mix.exs, add a CHANGELOG.md entry, and merge.
  2. Tag the merge commit kiln_client-vX.Y.Z and push the tag.
  3. Approve the run in the hex environment.

One-time setup a maintainer must do before the first run can succeed: