Kiln in a native app
How to keep an iOS or Android app's content current from Kiln without wasting the user's battery or data. Nothing here is mobile-only; it is the headless API used from a client that sleeps, loses its network and pays for every byte. The endpoint reference is api.md. The general consumer guide is headless-consumer-guide.md.
Three reads cover almost every app:
| Need | Endpoint | Section |
|---|---|---|
| Keep a local copy of a type (or all public content) | GET /api/sync | Mirroring with sync |
| Refresh the one document on screen | GET /api/content/:type/:slug + If-None-Match | Refreshing one document |
| A list row or widget, not the whole document | GET /api/content/:type/:slug?fields=… | Smaller payloads |
Read anonymously. A delivery app needs no credential for published, public
content, and a credential makes every response private, no-store
(api.md → Caching and CDNs). Never ship an editor's
API key inside an app binary. Anyone who unpacks the app has it, and it reads
drafts.
Mirroring with sync
GET /api/sync sends the public corpus once, then only what changed since a
cursor, deletions included (api.md → Sync). It is the
right base for an offline-first app: the local store is a mirror keyed by
document id, and every launch or background wake applies one delta.
first run: GET /api/sync?initial=true&type=post&limit=100
→ follow "cursor" while "has_more" is true
→ store the last cursor
later: GET /api/sync?cursor=<stored>
→ follow "cursor" while "has_more" is true
→ store the last cursorApply items in order, keyed by id. An upsert replaces your copy with
artifact, the same body GET /api/content/:type/:slug serves. A delete
removes it. A delete for an id you don't hold is normal; ignore it. The same
item can arrive twice, and applying it twice is harmless.
Persisting the cursor
- Store the cursor in the same transaction as the items it covers. Write the
page's upserts and deletes and the page's
cursortogether (one Core Data / SwiftData save, one Room@Transaction). If the app is killed between the two, the next run either repeats the page, which is harmless, or has both. It never has a cursor that has moved past items it never stored. - Store it per page, not per run. A first sync of a large site is many
pages. Saving after each one means a sync cut short by the OS resumes where it
stopped, not from
initial=true. - Treat it as opaque. It is a signed token. Don't parse it, and don't build
one. It carries its own scope (site,
type,surface), sotypeandsurfaceon a cursor request are ignored. One cursor per scope: an app that mirrorspostandrecipeseparately keeps two. - It does not expire on its own. A cursor from months ago still resumes.
When to restart with initial=true
Only when the server refuses the cursor: 400 with code invalid_cursor.
That means it was signed with a SECRET_KEY_BASE the server has since rotated,
belongs to another site, or was altered. Drop the stored cursor and run
initial=true. Keep your local data while the snapshot downloads, then delete
the ids the snapshot did not send. A refused cursor says nothing about which of
your documents are still live.
Do not restart on any other error:
| Response | Meaning | What to do |
|---|---|---|
503 artifact_compiling + Retry-After | A document on this page has no fired artifact yet. One request queues firing for all of them. | Retry the same request, same cursor, after Retry-After. |
503 temporarily_unavailable + Retry-After | The database is unreachable. | Retry the same request later. |
429 + retry-after | Rate limit (below). | Wait the given seconds. |
404 | Unknown type (the type was removed or renamed). | Stop syncing that scope. |
| Network error, timeout | — | Retry the same request with backoff. |
Restarting on a 503 throws away a good cursor and turns one slow page into a
full re-download.
A periodic full resync (say weekly, on Wi-Fi and power) is still worth having. A few changes write no version to the document itself, such as editing a fragment it embeds or its type's custom-field definitions, so sync does not report them (api.md → Sync, "Not seen").
Backoff
Retry-After is in seconds; honour it when present. Otherwise use exponential
backoff with jitter: for example 2 s, 4 s, 8 s … capped at a few minutes, each
multiplied by a random factor between 0.5 and 1.5, so a fleet of phones waking
together does not retry in lockstep. Give up for this wake after a handful of
attempts and try again on the next one. The stored cursor has not moved, so
nothing is lost.
Rate limits are per client IP. /api/sync and /api/content share the
api bucket (120 requests a minute by default,
api.md → Rate limits). Behind carrier-grade NAT many of
your users can share one address, so keep request counts low. Use limit=500
for a first sync rather than many small pages, and don't poll faster than the
content changes. An app with a large audience on one carrier is the case for
putting a CDN, or your own backend, in front (below).
The official clients already do the page loop and retry 503s:
kiln.sync({ cursor }) in @kiln-cms/client (3 retries, 2 s apart by default)
and KilnClient.sync(cursor: …) in kiln_client. Neither restarts on
invalid_cursor; that decision is yours.
Background refresh on iOS and Android
Neither platform runs your code on a schedule you choose:
- iOS.
BGAppRefreshTask(BackgroundTasks) runs when the system decides, based on how often the user opens the app, battery and network. It is typically hours apart and may not run at all for an app the user rarely opens. Each run gets a short budget, about 30 seconds. Fetch one or two sync pages, save, and finish the task; the stored cursor carries the rest to the next run. BackgroundURLSessiontransfers survive suspension but are meant for large downloads, not chatty JSON polling. - Android.
WorkManagerperiodic work has a 15-minute minimum interval, and Doze and App Standby buckets defer it further. Constrain the work toNetworkType.CONNECTED(orUNMETEREDfor the first full sync).
So background refresh is best-effort freshness, not delivery. Always sync on foreground too: on launch and on returning to the app, run one delta before showing "latest" lists. A delta for a quiet site is one small request. For "show it now" (breaking news, a price change), use push (below).
Refreshing one document
The single-document read, GET /api/content/:type/:slug (surface=json by
default), carries a weak ETag (W/"…"), Last-Modified and
Cache-Control: public, max-age=300. Send the ETag back as If-None-Match
and an unchanged document is a bodyless 304 Not Modified. That makes a
pull-to-refresh on a detail screen cost a few hundred bytes.
GET /api/content/post/hello
If-None-Match: W/"6f1c…-en-json-1792656000"
HTTP/1.1 304 Not Modified- Send the tag back as received. The comparison is weak: the
W/prefix is ignored, a comma-separated list matches if any tag in it does, and*matches any current version. The tag is weak so that Kiln can gzip the response: sendAccept-Encoding: gzip(URLSessionand OkHttp do by default) and the JSON arrives compressed. The tag changes when the document is republished, and it also names the served locale, the A/B variant (?variant_key=) and the?expand=targets, so a tag from one of those representations never304s another. A?fields=response is tagged by its body instead (below). URLCache(iOS) and OkHttp'sCache(Android) store theETagand revalidate for you once themax-agehas passed. Enable the cache with a real disk size and you get conditional requests without writing them.- A passphrase-locked document read with an unlock grant is
private, no-storeand has noETag. It is never304ed.
For a list screen, don't refresh each row with its own request. Run a sync delta, which reports every changed or removed document in one request.
Smaller payloads: ?fields=
The json artifact is the whole document: every block and every custom field.
A list row needs a title and a thumbnail. ?fields= keeps only what you name:
curl -s 'https://cms.example.com/api/content/recipe/pancakes?fields=title,slug,custom_fields.hero_image'{"id": "…", "type": "recipe", "title": "Pancakes", "slug": "pancakes",
"custom_fields": {"hero_image": {"id": "…", "url": "…", "alt": "…"}}}- Comma-separated top-level keys (
title,slug,locale,blocks,custom_fields,custom_fields_plain,custom_fields_portable, and any other key the artifact carries) pluscustom_fields.<name>for one custom field.custom_fieldson its own keeps all of them. idandtypeare always kept. So isexpandedwhen you also pass?expand=.- Unknown names are ignored, not refused, so an app built against an older
schema keeps working when a field is removed. A
custom_fields.<name>that matches nothing leaves"custom_fields": {}. - At most 50 names.
fieldsneedssurface=json(the default); with another surface it is a400 invalid_fields. It also works with?as_of=. - The
ETagis a digest of the filtered body, so it moves only when a value you kept does. After an editor republishes a recipe with a new method but the same title and hero image, revalidating the list row's fieldset is still a304. That is wherefieldssaves the most on a phone. Revalidate a fieldset response with the tag that fieldset returned. - Order and repeats don't matter.
?fields=slug,titleand?fields=title,slug,titlereturn the same body and the sameETag. The response'sx-kiln-fieldsheader is the canonical spelling: sorted, de-duplicated, withoutid/type. A CDN keys on the full URL, so send the canonical spelling everywhere in your app and every install shares one cache entry.
fields is on the single-document artifact read only. /api/sync always sends
the whole artifact. For filtered lists with sparse fields, JSON:API has
fields[<type>]= (json-api.md), and GraphQL selects fields by
construction (headless-graphql-api.md).
A fieldset response is deliberately not valid against the full document
schema from GET /api/schema, which lists blocks, custom_fields, slug
and the rest as required. Decode a fieldset response into a separate, smaller
type, or into a type whose properties beyond id and type are optional.
Rich text without a web view
Prose in blocks is already structured: a rich_text block's body is
Portable Text, an array of blocks whose spans carry their formatting as data.
A rich :text custom field stores HTML instead (its format is :rich
by default; extending-content.md), and
custom_fields delivers that HTML as it is.
So that an app does not need a browser engine to show it, an admin can tick
two per-field options in the fields admin. Both are off by default, and each
adds a sibling map beside custom_fields, which never changes:
| Option | Key | Value |
|---|---|---|
Also deliver as plain text (deliver_plain) | custom_fields_plain | the text with tags removed and whitespace collapsed (paragraphs run together), for a list row or a search index |
… and Keep paragraph breaks (plain_breaks) | custom_fields_plain | the same, but with \n\n between paragraphs and \n per line break or list item, for a text view that shows the prose as written; the schema export marks the field x-kiln-plain-breaks |
Also deliver as Portable Text (deliver_portable, rich fields only) | custom_fields_portable | the same Portable Text tree a rich_text block's body holds: paragraphs, h1–h6, quotes, nested lists (listItem + level), strong/em/code/strike/underline marks, links in markDefs |
"custom_fields": {"actions": "<p>Clears <em>heat</em>.</p>"},
"custom_fields_portable": {
"actions": [
{"_type": "block", "_key": "b0", "style": "normal", "markDefs": [],
"children": [{"_type": "span", "marks": [], "text": "Clears "},
{"_type": "span", "marks": ["em"], "text": "heat"},
{"_type": "span", "marks": [], "text": "."}]}
]
}- Every flagged field has a key. It is
nullwhen the record holds no value for it, and[]when the value is blank. A value stored as JSON data isnull: it is not prose. A value with no markup at all becomes one paragraph per blank-line-separated run, with single newlines kept as\ninside the span. - It is computed when the document fires, so the artifact read and
/api/synccarry it at no extra cost. JSON:API (fields[<type>]=…,custom_fields_portable) and GraphQL (customFieldsPortable) serve it only when asked for. Ticking or clearing the option re-fires the type's published documents. - Render it with the same code that renders a
rich_textblock'sbody. InGET /api/schemaeach value is the typebodyhas, an array of#/$defs/portable_text_block, so generated models share one type. - A link with an unsafe scheme keeps its text, but its
hrefis"". An<img>inside the prose is dropped, as in a block's body (images are their own block in Kiln). The HTML incustom_fieldsis always the full value. - Values inside a
:groupfield's rows are not converted: the rows arrive whole incustom_fields.<group>.
Push: webhooks, through your backend
Kiln has no device push. It does not hold APNs or FCM credentials or device tokens. What it has is signed outbound webhooks (webhooks.md). If your app has a backend, that backend is the push path:
- Register an endpoint on your backend at
/editor/webhooks, subscribed to<type>.published,<type>.updated,<type>.unpublishedand, if you mirror,<type>.archived/<type>.deleted. - Verify
x-kilncms-webhook-signature(HMAC-SHA256 of"<t>.<raw body>") and reject atmore than five minutes off. Usedelivery_idto dedupe retries (webhooks.md → Verifying the signature). - Send an APNs/FCM message. Prefer a silent / data message that says
"content changed" (iOS
content-available: 1, an FCM data message). The app wakes and runs a sync delta. A visible notification is for things the user asked to be told about.
Don't put the document in the push payload. APNs and FCM payloads are capped
at about 4 KB, and the webhook body is the editor-facing shape, not the
delivery artifact. The device should fetch through sync or the artifact read,
which only ever serve what an anonymous reader may see. Mind audience and
locked in the webhook data too
(see Gated content is delivered too in webhooks.md):
a members-only publish is still a published event.
The same backend can also front the content API: one sync loop on the server, devices reading from your own store or CDN. That removes the per-IP rate-limit concern above and lets you shape payloads for your app. It is worth it once the audience is large.
An app without a backend relies on foreground sync plus best-effort background refresh. That is fine for most content apps.
Typed models from GET /api/schema
GET /api/schema is a JSON Schema (draft 2020-12) of exactly what the json
artifact serves: one document schema per content type and a _type-
discriminated block union (api.md → Schema discovery).
It is per site, so it includes admin-defined types and custom fields, and it
changes when an admin edits them, without a redeploy.
- Generate models from it at build time, commit the output, and
regenerate when the schema changes. Swift and Kotlin have built-in emitters
(Swift, Kotlin); TypeScript has
mix kiln.export.schema --format tsand thekiln-typesgenerator bundled with@kiln-cms/client. - Use
?type=post,recipeto export only the types the app reads. An unknown name is a400. - Decode leniently.
custom_fieldsis an open object and nothing in it is required: a field added after a document was last saved has no value, so every custom field is optional. Decode an unknown block_typeto a placeholder case instead of failing the whole document, because a Kiln upgrade or a plugin can add a block kind your shipped binary has never seen. - The fieldset caveat above applies: generated types describe the full document.
The schema is cacheable (public, max-age=300). An app can fetch it at runtime
to detect drift, but it can't change its compiled types. Use it in CI
instead: fail the build when the live schema no longer matches the committed
one.
Swift models
mix kiln.export.schema --format swift writes one self-contained Swift file
from the same document GET /api/schema serves:
mix kiln.export.schema --format swift --org-id <site uuid> --out Sources/Kiln/Kiln.swiftAdd --type post,recipe to emit only the types the app reads. The file needs
only the Swift standard library, so it drops into an app target or a Swift
package. What it contains:
- A
public struct … : Codable, Hashable, Sendableper content type (PostDocument), block (HeadingBlock) and shared definition, with a public memberwiseinitfor previews and tests. Inline objects nest inside the struct that holds them, named after their key:PostDocument.CustomFields, and a repeating:grouprow asCustomFields.DosagesItem. The media snapshot is one sharedMediaSnapshot, used by a:mediafield, a gallery, and theimageandgalleryblocks alike. - Swift names in
lowerCamelCase, withCodingKeyskeeping the JSON key exact (readTime = "read_time", a block'stype = "_type"). Keywords are back-ticked; a key Swift can't spell is sanitized, and its JSON name is kept. - An optional for every key that is nullable or not required, so every custom field is optional, as it is on the wire.
public indirect enum Block, switching on_type, andDocument, switching ontype. Each has an.unknown(type: String)case: a block kind added by an upgrade or a plugin decodes to that case and the rest of the document still decodes.- A string
enumin the schema becomes aString-backed enum with anunknown(String)case, so a new option never breaks decoding.:selectcustom fields are plainStrings (or[String]for a multiple select), because the schema doesn't publish their options. [String: JSONValue]for an open map (a single:referencesnapshot,inherited_fields) andJSONValuefor anything the schema leaves unconstrained.JSONValueis a small enum bundled in the file.
What Swift can't state is dropped, as in the TypeScript emitter. A uuid or
date-time is a String: pick a date strategy where you decode. Numeric
bounds and const pins are dropped. Unknown keys on an open object are
ignored rather than kept.
Decode a delivery with JSONDecoder:
let document = try JSONDecoder().decode(Document.self, from: data)
if case .post(let post) = document {
for block in post.blocks {
if case .heading(let heading) = block { print(heading.text) }
}
}Regenerating. The file describes one site at the moment of export, so
regenerate it when an admin adds or changes a content type or custom field,
and after a Kiln upgrade or plugin adds a block. An app built against an older
file keeps decoding: new keys are ignored, and new block types and enum values
land in unknown. So regenerating is about gaining the new fields, not about
staying compatible. To catch drift in CI, regenerate and run
git diff --exit-code on the file.
Kotlin models
mix kiln.export.schema --format kotlin writes one Kotlin file of
kotlinx.serialization
models from the same document:
mix kiln.export.schema --format kotlin --package com.example.kiln \
--org-id <site uuid> --out app/src/main/java/com/example/kiln/Kiln.kt--package sets the file's package and defaults to kiln. --type post,recipe works as it does for Swift. The file needs the serialization
compiler plugin and the JSON runtime, nothing else. In build.gradle.kts:
plugins {
kotlin("plugin.serialization") version "<your Kotlin version>"
}
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.12.0")
}What it contains:
- A
@Serializable public data classper content type (PostDocument), block (HeadingBlock) and shared definition. Inline objects nest inside the class that holds them, named as in Swift:PostDocument.CustomFields, and a repeating:grouprow asCustomFields.DosagesItem. The media snapshot is the one sharedMediaSnapshot. An object with no keys is adata object. - Kotlin names in
lowerCamelCase, with@SerialNamekeeping the JSON key exact (@SerialName("read_time") val readTime, a block's@SerialName("_type") val type). Hard keywords are back-ticked. - A nullable property with a
= nulldefault for every key that is nullable or not required, so every custom field is optional. sealed interface Block, which every block class implements, read by the bundledBlockSerializer. It switches on_type, and a block type the app doesn't know decodes toBlock.Unknown(type, raw)while the rest of the document still decodes.rawis the block'sJsonObject, and encoding anUnknownwrites it back unchanged.DocumentandDocumentSerializerdo the same for content types, switching ontype. The serializers name each class's serializer directly, so there is no reflection lookup and nothing for R8 to strip.- A string
enumin the schema becomes a@JvmInline value classoverString, with a companion constant per listed value (Style.Primary),entriesandisKnown. Any string decodes, so a new option never breaks decoding. As in Swift,:selectcustom fields are plainStrings (orList<String>), because the schema doesn't publish their options. Longfor integers (JSON integers are not 32-bit),JsonObjectfor an open map, andJsonElementfor anything the schema leaves unconstrained.
Decode with the bundled KilnJson, which sets ignoreUnknownKeys = true.
A plain Json fails on the first key the generated file doesn't list, and a
delivery gains keys before you regenerate:
when (val document = KilnJson.decodeFromString(Document.serializer(), body)) {
is PostDocument -> document.blocks.forEach { block ->
if (block is HeadingBlock) println(block.text)
}
is Document.Unknown -> Log.w("kiln", "unknown type ${document.type}")
else -> Unit
}The same things are dropped as in Swift: a date-time or uuid is a
String, numeric bounds and const pins are gone, and unknown keys on an
open object are ignored rather than kept. The regenerating advice above
applies unchanged.