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-mail B,

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 -word to exclude.
  • locale picks 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 (or page, 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>]:

DimensionMeaning
modelModel key (models= is the legacy alias)
statusEntry status (status= alias)
localeContent locale of the entry (locale= alias)
categoryRecord category plus the model's own category
tagRecord 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, older buckets 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:

  • matches are character offsets into text, 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, -excluded terms 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-made curl snippets.

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

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