API Cookbook
All public interfaces live under /api/public/*. Authentication: Authorization: Bearer <token> plus x-account: <account_id> when working multi-tenant.
REST — list, filter, sort, relations
curl -H "Authorization: Bearer $TOKEN" \
"$BASE/api/public/v1/article?filter[status]=published&filter[category][within]=Marketing\
&sort=-published_at&page[size]=10&expand=author&locale=pl"{
"data": [
{
"id": "8f1c…",
"title": "Schema-driven delivery",
"slug": "schema-driven-delivery",
"author": { "id": "3a…", "name": "Anna" }
}
],
"meta": { "page": { "size": 10, "total": 42 } }
}Search with relevance ranking
Every model with text fields gets a stored, weighted tsvector column plus a GIN index (src/platform/search/fulltext.ts), so ?search= is an index scan and results come back ordered by relevance instead of by date.
curl -H "Authorization: Bearer $TOKEN" \
"$BASE/api/public/v1/article?search=schema%20driven&locale=en&page[size]=5"{
"data": [{ "id": "8f1c…", "title": "Schema-driven architecture", "search_rank": 0.64 }],
"meta": {
"search": {
"query": "schema driven",
"config": "english",
"indexed": true,
"explain": "Title (A), Excerpt (C), Body (D) via stored search_vector index",
"scores": { "8f1c…": 0.64 }
}
}
}- Weights come from the schema: the display field and unique identifiers rank
A, slug/e-mailB,
long prose D, everything else C. Authors can pin a field with searchWeight, or narrow the indexed set with searchable: true on the fields that should matter.
- The query grammar is
websearch_to_tsquery: quotes for phrases,or, and-wordto exclude. localepicks the language dictionary. When it matches the model's stored configuration
(api.searchConfig) the indexed column answers the query (indexed: true); another language is still correct, just computed per row — meta.search.explain says which path ran.
- Cross-model search (
/api/public/v1/search) uses the same normalised 0..1 score, so hits from
different models are comparable and merged by rank.
- Cross-model search pages with
limit+offset(orpage, 1-based). Ranking is computed over the
whole result set first and the page is sliced afterwards, so paging never reshuffles what a reader already saw. meta.offset echoes the rank of the first returned hit and meta.hasMore says whether the ranking continues past this page.
curl -H "Authorization: Bearer $TOKEN" \
"$BASE/api/public/v1/search?q=schema%20driven&limit=25&page=2"Faceted filtering and facet counts
/api/public/v1/search returns hits and the facet counts you need to build a filter rail. Every dimension is filterable with facet[<dimension>]:
| Dimension | Meaning |
|---|---|
model | Model key (models= is the legacy alias) |
status | Entry status (status= alias) |
locale | Content locale of the entry (locale= alias) |
category | Record category plus the model's own category |
tag | Record tags plus the model's tags (tags= alias) |
<model>.<field> | Any facetable entry field: enumeration, tags/multi-select, many-to-one relation, boolean, date bucket |
curl -H "Authorization: Bearer $TOKEN" \
"$BASE/api/public/v1/search?q=lens&facet[status]=published&facet[locale]=en,de&facet[shop_product.brand]=zeiss"{
"data": [{ "model": "shop_product", "id": "3d0…", "title": "Zeiss Batis 40", "score": 0.71 }],
"meta": {
"total": 12,
"facets": {
"models": [{ "key": "shop_product", "name": "Product", "count": 12, "selected": false }],
"statuses": [{ "value": "published", "count": 12, "selected": true }],
"locales": [{ "value": "en", "count": 9, "selected": true }],
"categories": [], "tags": [],
"fields": [
{
"key": "shop_product.brand", "model": "shop_product", "field": "brand",
"label": "Brand", "type": "relation",
"values": [{ "value": "zeiss", "label": "Zeiss", "count": 12, "selected": true }]
}
],
"approximate": false
}
}
}Semantics, deliberately Algolia-like:
- Values inside one dimension are OR-ed, separate dimensions are AND-ed.
- Counts are drill-down: when counting a dimension its own filter is ignored, every other filter
applies. That is why the unselected draft bucket still shows a usable number after you pick published — checking a second value never yields zero results.
__none__matches records with no value in that field, and shows up as "Not set".- Date fields collapse into
today,7d,30d,olderbuckets computed at query time. - Facets are aggregated from rows the actor may actually read, after RLS, policies and field-level
security — a facet never leaks the existence of a hidden record or a hidden field.
perModelLimit(default 50, max 200) caps rows scanned per model. If any model hits the cap,
facets.approximate is true and counts are a lower bound.
- Unknown dimensions are rejected with
400, so a typo fails loudly instead of silently widening.
The same shape is available on the GraphQL root field search(q:, facets: [{dimension, values}]), on the MCP tool search_content, and behind the admin workspace at /admin/search.
Snippets and highlighting
Search results can carry the sentence that matched, with the matched words marked. Highlighting is on by default on /api/v1/search and opt-in on a collection read with ?highlight=true:
curl -G "$BASE/api/public/v1/search" -H "Authorization: Bearer $TOKEN" \
--data-urlencode 'q="cold brew" -decaf' --data-urlencode 'snippetLength=120'
curl -G "$BASE/api/public/v1/article" -H "Authorization: Bearer $TOKEN" \
--data-urlencode 'q=pizza' --data-urlencode 'highlight=true'A search hit gains snippet (plain text) and highlights; a collection read puts the same snippets in meta.search.highlights, keyed by entry id:
{
"field": "body",
"label": "Body",
"text": "\u2026our pizza dough rests for 24 hours\u2026",
"matches": [{ "start": 6, "length": 5 }],
"truncatedStart": true,
"truncatedEnd": true
}Rules that make this safe and predictable:
matchesare character offsets intotext, so you render<mark>yourself and never trust HTML
from content. GraphQL additionally exposes a pre-escaped html field.
- Snippets are built from the already authorised, already projected record, so a field hidden by
field-level security can never appear in an excerpt.
- Rich text, markdown, arrays and node trees are flattened to the prose a reader sees; scripts,
styles, tags and markdown syntax are stripped before matching.
- Fields are excerpted in ranking-weight order (title before body), capped by
maxSnippets
(default 3, max 5); snippetLength targets characters (default 160, max 320) and both ends cut on a word boundary. highlightFields=title,body narrows which fields are excerpted.
- Query grammar follows the search itself:
"quoted phrases"stay whole,-excludedterms are
never marked, and a term also marks its stemmed forms (kanapk marks kanapka).
- Highlighting is presentation only — it runs after the ranking, so a missing mark never hides a
result.
- GraphQL:
search(q:, highlight: true, snippetLength: 120)and
articles(search:, highlight: true) { search { highlights { id snippets { text matches { start length } } } } }. MCP search_content returns the verbatim snippet per hit.
REST — write
curl -X POST "$BASE/api/public/v1/article" \
-H "Authorization: Bearer $TOKEN" -H "content-type: application/json" \
-d '{ "title": "Nowy wpis", "slug": "nowy-wpis", "status": "draft" }'Validation returns 422 with a per-field list of errors — the same validation runs in the panel.
REST — channel projection (experience)
# full payload — no projection
curl -H "Authorization: Bearer $TOKEN" "$BASE/api/public/v1/article?page[size]=1"
# payload for a smart watch: universal fields + those tagged 'smart-watch'
curl -i -H "Authorization: Bearer $TOKEN" \
"$BASE/api/public/v1/article?channel=smart-watch&page[size]=1"
# x-cms-channel: smart-watch
# vary: x-cms-channel
# a single entry is projected too
curl -H "Authorization: Bearer $TOKEN" "$BASE/api/public/v1/article/$ID?channel=ai-agent"Rule: a field without tags is universal, a field with tags is delivered only to the listed channels. An unknown channel key is not an error — it simply matches no tag, so universal fields are returned. The Vary: x-cms-channel header lets you cache variants separately. Channel catalog: /admin/channels, the channel parameter is documented in openapi.json.
Form as an API
curl "$BASE/api/public/v1/article/form"The response includes the layout (24-column grid, breakpoints), field variants, visible/disabled/required rules, and the mapping to the submit endpoint — enough to render an advanced form in your own frontend.
GraphQL
query {
articles(filter: { status: PUBLISHED }, first: 5, locale: "pl") {
nodes { id title author { name } }
}
}Subscriptions: GET /api/public/graphql/subscribe (SSE). Explorer: /api/public/graphql.
JSON:API 1.0
curl -H "Accept: application/vnd.api+json" "$BASE/api/public/v1/jsonapi/article?include=author"Webhooks
{
"event": "entry.published",
"schema": "article",
"entryId": "8f1c…",
"revision": 7,
"occurredAt": "2026-08-11T09:00:00Z"
}Signature: x-signature: sha256=<HMAC(secret, body)>. The /admin/webhooks panel lets you send a test, pause and rotate the secret, and the logs show attempts and responses.
Translations over the API
Translations of localized fields are addressable per locale. The default locale is not a translation — write those values on the entry itself.
# all locales of one entry
curl -H "Authorization: Bearer $TOKEN" "$BASE/api/public/v1/article/$ID/translations"
# create or replace the German translation and mark it ready for delivery
curl -X PUT -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{"values":{"title":"Hallo Welt","excerpt":"…"},"status":"published"}' \
"$BASE/api/public/v1/article/$ID/translations/de-DE"
# who changed what, per locale
curl -H "Authorization: Bearer $TOKEN" \
"$BASE/api/public/v1/article/$ID/translations/revisions?locale=de-DE"
# remove a translation (delivery falls back down the locale chain)
curl -X DELETE -H "Authorization: Bearer $TOKEN" \
"$BASE/api/public/v1/article/$ID/translations/de-DE"Rules: only localized fields are accepted (anything else → 422), the locale must be enabled, and a translation reaches anonymous delivery only in status published. GraphQL mirrors the same surface:
query { articleTranslations(id: "…") { locale status updatedAt values } }
mutation { saveArticleTranslation(id: "…", locale: "de-DE", values: { title: "Hallo" }, status: "in_review") { locale status } }Media library
The asset library is a first-class API resource: upload, patch metadata, replace the binary and delete — the same service the panel uses, so permissions, audit entries and events are identical.
Upload with multipart/form-data (preferred, no base64 inflation):
curl -s -X POST "$BASE/api/public/v1/assets" \
-H "authorization: Bearer $TOKEN" \
-F file=@cover.jpg \
-F alt="Sunset over the harbour" \
-F credit="Jane Doe" \
-F tags="hero,print"Or with JSON when a client cannot do multipart (max 15 MB, larger files must use multipart):
curl -s -X POST "$BASE/api/public/v1/assets" \
-H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d "{\"fileName\":\"cover.jpg\",\"contentBase64\":\"$(base64 -w0 cover.jpg)\",\"alt\":\"Sunset\"}"The server probes the bytes for the real MIME type, dimensions and duration, so a wrong mimeType claim is corrected rather than stored. A file whose checksum already exists is rejected with 409 unless you pass allowDuplicate: true.
Metadata patch — only the keys you send change, null clears one, unknown keys fail with 400:
curl -s -X PATCH "$BASE/api/public/v1/assets/$ID" \
-H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{"alt":"Sunset over the harbour","license":"CC-BY-4.0","credit":null}'Replace the binary while keeping the id, alt text and every content reference (the old file stays as a version):
curl -s -X POST "$BASE/api/public/v1/assets/$ID/file" \
-H "authorization: Bearer $TOKEN" -F file=@cover-v2.jpg -F note="Retouched"Check before deleting, then delete:
curl -s "$BASE/api/public/v1/assets/$ID/usages" -H "authorization: Bearer $TOKEN"
curl -s "$BASE/api/public/v1/assets/$ID/versions" -H "authorization: Bearer $TOKEN"
curl -s -X DELETE "$BASE/api/public/v1/assets/$ID?force=true" -H "authorization: Bearer $TOKEN"Deleting an asset that is still embedded in content returns 409 with the usage list; force=true detaches those references first.
In GraphQL the library is the MediaAsset type (distinct from the embedded Asset reference):
mutation {
uploadMediaAsset(input: { fileName: "cover.jpg", contentBase64: "...", alt: "Sunset" }) {
id url width height
}
}
query { mediaAssets(missingAlt: true, limit: 20) { id fileName url } }Agents get the same operations over MCP: list_assets, upload_asset, update_asset, delete_asset.
Import / export
curl -H "Authorization: Bearer $TOKEN" "$BASE/api/public/v1/article/export?format=csv" -o article.csv
curl -X POST -H "Authorization: Bearer $TOKEN" -F file=@article.csv "$BASE/api/public/v1/article/import"SDK and types
/admin/sdk generates a dependency-free client and TypeScript types from the published schemas:
import { createClient } from "./platform-sdk";
const client = createClient({ baseUrl: BASE, token: TOKEN });
const articles = await client.article.list({ filter: { status: "published" } });OpenAPI and the interface catalog
GET /api/public/openapi.json— the OpenAPI 3.2 document for all models./admin/interfaces— a catalog of everything generated (17 endpoints per model plus platform interfaces) with ready-madecurlsnippets.
The document covers the whole REST surface, not only model CRUD: discovery (/schemas, /sdk/{artifact}, /environments), cross-model /search, the SSE change feed /events/stream, media, layouts, per-model /export and /import, the form contract (/form, /form/submit, /form/validate), revisions and translations, plus the lifecycle action POST /{model}/{id}/{action} with every allowed transition in the action enum. A regression test (src/platform/__tests__/openapi-coverage.test.ts) fails the build when the interface catalog advertises a REST endpoint the document does not describe, so a generated client can never be missing an endpoint that exists.
Generate a client straight from the spec:
curl -s "$BASE/api/public/openapi.json" -o openapi.json
npx openapi-typescript openapi.json -o platform-api.d.tsMachine-readable schema registry
A model registry with no need to read OpenAPI — useful for generators and agents:
curl -s "$BASE/api/public/v1/schemas" | jq '.data[].key'
curl -s "$BASE/api/public/v1/schemas/article" | jq '.data.fields[] | {name, kind, required}'For each model it returns: key, names, REST path, the localized flag, and fields with kind, validation, options, relation, component, and dynamicZone. Fields with apiVisible: false are omitted.
MCP servers
POST /mcp/content— Content MCP (read/write content, translations, pipeline, revisions, media).POST /mcp/management— Management MCP (models, permissions, webhooks, tokens, audit).
Both are protected by OAuth 2.1 (Bearer from the platform session); without a token they return 401 {"error":"unauthorized"}. Discovery: GET /mcp/content/.mcp/list-tools, GET /mcp/.mcp/list-tools.