API reference

Integrations

For systems that maintain the CMS via the API – IllusionPublish, n8n, your own scripts: write several changes atomically, retry after a crash without duplicate changes, record who a change was made for in the revision history, query the schema and learn about changes via webhooks.

POST /batch ✏️

Runs several operations in one transaction: either all of them are saved or none. Every operation goes through the same validation as the matching single endpoint (slugs, required fields, block trees, language versions, og:image, reserved slugs …) and has the same side effects (revision history, automatic redirects on slug changes, cleaning up translation groups …).

{
  "idempotency_key": "pkg-7f3a-2026-10-10T12:00",
  "on_behalf_of": "Maria Muster",
  "ops": [
    { "op": "create", "kind": "page", "ref": "new", "data": { "parent_id": 1, "slug": "campaign", "title": "Campaign" } },
    { "op": "create", "kind": "page", "published": true, "data": { "parent_id": "@new", "slug": "details", "title": "Details" } },
    { "op": "update", "kind": "page", "id": 12, "base_updated_at": 1791570000, "data": { "title": "New", "blocks": [] } },
    { "op": "create", "kind": "item", "collection": "blog", "ref": "b1", "data": { "title": "Hello" }, "published": false },
    { "op": "update", "kind": "item", "collection": "blog", "id": 5, "base_updated_at": 1791571111, "data": { "title": "Changed" } },
    { "op": "update", "kind": "global_block", "id": 3, "data": { "blocks": [ { "type": "hero", "params": { "title": "Hi" } } ] } },
    { "op": "update", "kind": "file", "id": 44, "data": { "alt": "Logo", "private": false } },
    { "op": "delete", "kind": "item", "collection": "blog", "id": 9, "base_updated_at": 1791500000 }
  ]
}
Field Meaning
op create, update (partial update like the respective PUT: missing fields stay) or delete
kind page, item, global_block, snippet, file (metadata only: name, alt, folder_id, private – no upload, no create)
id Object ID for update/delete: a number or "@ref"
collection For item: slug or ID of the collection
data Body as for the single endpoint (POST/PUT /pages, /global-blocks, /snippets, PUT /files/{id}; for items only the fields, i.e. the content of data in POST /collections/{ref}/items). For files, folder_id (also null) is enough to move them
published Status for item (when creating without it: published) and page (otherwise data.published)
ref Name for a created object (letters, digits, _ - ., max. 64): later operations use "@name" as id or as a value in data
base_updated_at Optional: updated_at of the loaded state (not for files – they have no edit state)

References: "@name" is replaced in id and in values on the top level of data – also inside lists (e.g. parent_id, fields of type reference/references, og_image_file_id). Values nested deeper (such as in block trees) stay as they are. Only references of earlier create operations can be used.

Version check: All base_updated_at values are checked up front against the state before the batch. If one differs, the whole batch is rejected – with all differences at once:

{ "error": "2 object(s) changed since they were loaded – batch rejected", "code": "batch.conflict",
  "params": { "count": 2, "conflicts": [ { "index": 2, "kind": "page", "id": 12, "updated_at": 1791570444 },
                                         { "index": 4, "kind": "item", "id": 5, "updated_at": 1791571999 } ] } }

Error in an operation: nothing is written. Status and code come from the failed operation (as with the single endpoint), params.index (from 0) names it:

{ "error": "op 3: slug may only contain a-z, 0-9, '.', '_' and '-'", "code": "page.slugInvalid", "index": 3, "params": { "index": 3 } }

Response 200 – per operation index, op, kind, id, where applicable ref, collection, updated_at, deleted: true or warnings (block schema):

{
  "idempotency_key": "pkg-7f3a-2026-10-10T12:00",
  "results": [
    { "index": 0, "op": "create", "kind": "page", "id": 41, "ref": "new", "updated_at": 1791572000 },
    { "index": 3, "op": "create", "kind": "item", "id": 31, "ref": "b1", "collection": "blog", "updated_at": 1791572000 },
    { "index": 7, "op": "delete", "kind": "item", "id": 9, "collection": "blog", "deleted": true }
  ],
  "applied_at": 1791572000
}

After the commit (not on errors): the page cache is purged, collection events for workflows are triggered (as with single changes, with the same nesting limit), private files are reloaded, the media files of deleted files are removed and the webhook events content.changed/schema.changed and batch.applied are triggered.

Limits: at most 500 operations, body up to 16 MB. Formal errors (before anything is written) have their own codes: batch.empty, batch.tooManyOps, batch.opUnknown, batch.kindUnknown, batch.opNotSupported, batch.idRequired, batch.idInvalid, batch.collectionRequired, batch.dataInvalid, batch.refInvalid, batch.refDuplicate, batch.refUnknown, batch.keyInvalid, batch.onBehalfTooLong – each with params.index where an operation is affected.

curl -X POST https://example.com/api/v1/batch \
  -H "Authorization: Bearer ic_…" -H "Content-Type: application/json" \
  -d '{"idempotency_key":"n8n-4711","ops":[{"op":"update","kind":"item","collection":"blog","id":5,"data":{"title":"New"}}]}'

Idempotency

With idempotency_key (1–200 characters) a batch can be retried safely, e.g. when the response got lost because of a network error:

  • Same key, same content: the server runs nothing again and returns the stored response with the header Idempotent-Replay: true. It compares a hash over the body without idempotency_key (the order of object fields does not matter).
  • Same key, different content: 409 with batch.keyReused.
  • Only successful batches are stored – a failed one may be corrected and retried with the same key. The entry is written in the same transaction as the changes.
  • Keys are kept for 30 days (the nightly run cleans up) and belong to the instance – they are not part of bundles.

GET /batch/{key} 🔑

Has a batch with this key already been applied? For clients that do not know after a restart whether their last batch went through:

{ "applied": true, "idempotency_key": "pkg-7f3a-2026-10-10T12:00", "applied_at": 1791572000, "results": [ … ] }

Unknown (or older than 30 days): 404 with common.notFound.

On behalf of

If a token writes for a specific person (e.g. IllusionPublish after an approval), every writing request can send the header X-IC-On-Behalf-Of: Maria Muster – for POST /batch alternatively on_behalf_of in the body (takes precedence). The author in the revision history then reads (the text is German, like all author texts):

Token „Publish“ – im Auftrag von Maria Muster
  • Only applies to token requests (including the master token and workflows); for admin sessions it is ignored – the account counts there.
  • At most 100 characters, otherwise 400 with batch.onBehalfTooLong. Control characters and repeated spaces are removed.
  • HTTP headers cannot carry every character reliably (browsers and Node.js only allow Latin-1): the header may therefore be percent-encoded (Maria%20M%C3%BCller). If it is not a valid escape (e.g. 100% Agency), it is used as is.
  • The two-minute window of the revision history applies per complete author: different people via the same token get their own versions.

GET /schema 🔑

A snapshot of the schema for integrations – collections with fields, blocks (like GET /blocks/types), the languages in use and websites:

{
  "version": "3f9c0a1b2c3d4e5f",
  "collections": [ { "id": 2, "slug": "blog", "name": "Blog", "lang": "en", "singleton": false, "public_api": false,
                     "live": false, "detail_page_id": 7, "detail_field": "slug", "fields": [ … ] } ],
  "block_types": [ { "name": "blocks/hero", "label": "Hero", "params": [ … ], "slots": [ … ], … } ],
  "languages": ["de", "en"],
  "sites": [ { "id": 1, "title": "Bakery", "lang": "de", "domains": ["bakery.com"], "default": true } ],
  "page_layouts": [ { "id": 12, "name": "layouts/content", "label": "Content page", "has_blocks": true, … } ]
}

page_layouts: as in GET /page-layouts.

version is a hash over this content: it changes with every schema-relevant change (collection created/changed/deleted, block schema, website title/language/domains), but not with new items or saving without changes. The ETag header contains it; with If-None-Match the server answers 304 without a body if the schema is unchanged:

curl -i -H "Authorization: Bearer ic_…" -H 'If-None-Match: "3f9c0a1b2c3d4e5f"' https://example.com/api/v1/schema

Webhooks

Webhooks report changes as a POST to an address – e.g. to n8n, a build service or a workflow HTTP input of the same instance. You manage them under Settings → Webhooks or via the API (admin session or write token, so also through a connection in remote mode). Stored in the instance setting webhooks (not in the bundle), at most 20.

GET /webhooks ✏️ – list; GET /webhooks/{id} ✏️ – one:

[ { "id": "3fa94c1b20de", "name": "n8n", "url": "https://n8n.example.com/webhook/cms",
    "events": ["content.changed", "schema.changed", "batch.applied"], "enabled": true, "secret_set": true,
    "created_at": 1791572000, "updated_at": 1791572000,
    "last": { "id": "6f1c…", "event": "content.changed", "at": 1791572100, "state": "ok", "status": 200, "duration_ms": 41, "attempts": 1, "changes": 3, "hop": 1 } } ]

POST /webhooks ✏️ – { "name": "n8n", "url": "https://…", "events": [ … ], "enabled": true, "secret": "…" }. Only url is required (http/https); without events all three events are subscribed, without secret the server generates one (whsec_…; own secrets 16–200 characters). Response 201 with the webhook and – once only – secret; it is never returned again afterwards (secret_set). It is stored encrypted only (like the S3 secret, key IC_ENCRYPTION_KEY or data/secret.key).

PUT /webhooks/{id} ✏️ – partial update with the same fields; missing ones stay. "regenerate_secret": true generates a new secret, secret sets your own – either way it appears once in the response. DELETE /webhooks/{id} ✏️ – 204.

POST /webhooks/{id}/test ✏️ – immediately sends a ping (one attempt, even for an inactive webhook) and returns the delivery result as in the log.

GET /webhooks/{id}/deliveries ✏️ – the last 50 deliveries, newest first (in memory only, empty after a restart):

Field Meaning
id Delivery ID = header X-IC-Delivery (stays the same across retries)
event, at Event and time (Unix seconds)
state pending (running or waiting for a retry), ok, failed, skipped (loop protection)
status, duration_ms, attempts HTTP status and duration of the last attempt, number of attempts
error, error_code, error_params, response Error (webhook.httpStatus, webhook.requestFailed, webhook.timeout, webhook.nestedTooDeep, webhook.shutdown, storage.secretUndecryptable) (the receiver’s response is not copied into the log, response stays empty – only the status code appears in status)
changes, hop, test Number of changes in the event, depth (see loop protection), test ping

Error codes when creating and updating: webhook.urlInvalid, webhook.eventUnknown, webhook.eventsRequired, webhook.nameTooLong, webhook.secretTooShort, webhook.secretTooLong, webhook.tooMany.

Delivery: in the background as a POST with a JSON body and the headers Content-Type: application/json, X-IC-Event (event name), X-IC-Delivery (UUID), X-IC-Signature: sha256=<hex>, X-IC-Timestamp (Unix seconds), X-IC-Signature-V2: sha256=<hex> and X-IC-Webhook-Hop. Any 2xx response counts as delivered. Timeout 10 seconds; on network errors, timeouts, 408, 429 and 5xx up to 3 retries follow after 2 s, 10 s and 30 s – other statuses (e.g. 404) are not retried. The server does not follow redirects (3xx). When the server shuts down, open bundles are still sent (at most 5 seconds); retries are dropped.

Loop protection: A webhook can call a workflow of the same instance that changes content – which triggers the webhook again. That is why every delivery carries X-IC-Webhook-Hop (1 = triggered by a normal change). Workflow HTTP inputs take over the value; changes made by that run (also via collection events and HTTP requests of the workflow) count one level further. After 3 deliveries in a row it stops: further events appear in the log as skipped with webhook.nestedTooDeep.

Webhook events

Every body contains event and at (Unix seconds). changes are objects with kind, action (created, updated, deleted, moved, imported) and, depending on the kind, id, collection (slug), slug or name.

content.changed – pages, entries, global blocks, files, snippets and CSS/JS assets (kind: page, item, global_block, file, snippet, asset). Bulk changes without a detailed list: import (website import), bundle (bundle import, sync), items (CSV import into a collection). Changes within about 2 seconds are bundled into one delivery (duplicates merged, from 1000 entries on "truncated": true):

{ "event": "content.changed", "at": 1791572100,
  "changes": [ { "kind": "page", "action": "updated", "id": 12 },
               { "kind": "item", "action": "created", "id": 31, "collection": "blog" },
               { "kind": "file", "action": "deleted", "id": 44 } ] }

schema.changed – collection created, changed or deleted (kind: collection), block type schema changed or block type removed (kind: block_type), page layouts changed (kind: page_layout), bundle import (kind: bundle). schema_version is the current version from GET /schema – a client compares it with its own and only reloads when needed. Delivered immediately:

{ "event": "schema.changed", "at": 1791572200, "schema_version": "3f9c0a1b2c3d4e5f",
  "changes": [ { "kind": "collection", "action": "updated", "id": 2, "slug": "blog" } ] }

batch.applied – after every successful POST /batch (not for a repetition with the same idempotency_key), with the results as in its response. The batch’s changes also arrive as content.changed or schema.changed:

{ "event": "batch.applied", "at": 1791572300, "idempotency_key": "pkg-7f3a-2026-10-10T12:00",
  "results": [ { "index": 0, "op": "update", "kind": "page", "id": 12, "updated_at": 1791572300 } ] }

ping – only via POST /webhooks/{id}/test: { "event": "ping", "at": 1791572400, "webhook": "3fa94c1b20de" }.

Verifying the signature

X-IC-Signature is sha256= followed by the hexadecimal HMAC-SHA256 of the unmodified body with the secret as key. Check it before anything else and compare in constant time; only parse the body as JSON afterwards (re-serialized JSON produces a different signature). at and X-IC-Delivery help against replaying old deliveries (the delivery ID stays the same across retries – so it also detects duplicate deliveries). With timestamp: X-IC-Timestamp holds the Unix seconds of the attempt, X-IC-Signature-V2 is sha256= followed by the HMAC-SHA256 of <timestamp>.<body> (dot as separator). Recommendation: verify V2 and reject deliveries whose timestamp differs from your own clock by more than 5 minutes; on retries each attempt carries a new timestamp. X-IC-Signature (body only) stays unchanged.

JavaScript (Node.js with Express):

import crypto from 'node:crypto'
import express from 'express'

const SECRET = process.env.IC_WEBHOOK_SECRET

function verify(rawBody, header, secret) {
  const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex')
  const a = Buffer.from(expected)
  const b = Buffer.from(header || '')
  return a.length === b.length && crypto.timingSafeEqual(a, b)
}

const app = express()
app.post('/hooks/illusioncms', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verify(req.body, req.get('X-IC-Signature'), SECRET)) return res.sendStatus(401)
  const event = JSON.parse(req.body.toString('utf8'))
  console.log(req.get('X-IC-Event'), event.changes)
  res.sendStatus(204)
})
app.listen(3000)

Go:

package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"io"
	"log"
	"net/http"
	"os"
)

func verify(body []byte, header, secret string) bool {
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write(body)
	want := "sha256=" + hex.EncodeToString(mac.Sum(nil))
	return hmac.Equal([]byte(want), []byte(header))
}

func main() {
	secret := os.Getenv("IC_WEBHOOK_SECRET")
	http.HandleFunc("POST /hooks/illusioncms", func(w http.ResponseWriter, r *http.Request) {
		body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
		if err != nil || !verify(body, r.Header.Get("X-IC-Signature"), secret) {
			http.Error(w, "invalid signature", http.StatusUnauthorized)
			return
		}
		var ev struct {
			Event   string           `json:"event"`
			Changes []map[string]any `json:"changes"`
		}
		json.Unmarshal(body, &ev)
		log.Println(r.Header.Get("X-IC-Delivery"), ev.Event, len(ev.Changes))
		w.WriteHeader(http.StatusNoContent)
	})
	log.Fatal(http.ListenAndServe(":3000", nil))
}

Workflows of this instance cannot verify the signature (the function node has no HMAC) – a webhook to one of your own HTTP inputs is suitable for notifications, not as access protection.