API-Referenz

Integrationen

Für Systeme, die das CMS per API pflegen – IllusionPublish, n8n, eigene Skripte: mehrere Änderungen atomar schreiben, Wiederholungen nach einem Absturz ohne doppelte Änderungen ausführen, den Auftraggeber in der Versionshistorie vermerken, das Schema abfragen und per Webhook von Änderungen erfahren.

POST /batch ✏️

Führt mehrere Operationen in einer Transaktion aus: Entweder werden alle gespeichert oder keine. Jede Operation läuft durch dieselbe Prüfung wie der passende Einzel-Endpunkt (Slugs, Pflichtfelder, Blockbäume, Sprachfassungen, og:image, reservierte Slugs …) und hat dieselben Nebenwirkungen (Versionshistorie, automatische Weiterleitungen bei Slug-Änderung, Aufräumen von Übersetzungsgruppen …).

{
  "idempotency_key": "pkg-7f3a-2026-10-10T12:00",
  "on_behalf_of": "Maria Muster",
  "ops": [
    { "op": "create", "kind": "page", "ref": "neu", "data": { "parent_id": 1, "slug": "aktion", "title": "Aktion" } },
    { "op": "create", "kind": "page", "published": true, "data": { "parent_id": "@neu", "slug": "details", "title": "Details" } },
    { "op": "update", "kind": "page", "id": 12, "base_updated_at": 1791570000, "data": { "title": "Neu", "blocks": [] } },
    { "op": "create", "kind": "item", "collection": "blog", "ref": "b1", "data": { "titel": "Hallo" }, "published": false },
    { "op": "update", "kind": "item", "collection": "blog", "id": 5, "base_updated_at": 1791571111, "data": { "titel": "Geändert" } },
    { "op": "update", "kind": "global_block", "id": 3, "data": { "blocks": [ { "type": "hero", "params": { "titel": "Hi" } } ] } },
    { "op": "update", "kind": "file", "id": 44, "data": { "alt": "Logo", "private": false } },
    { "op": "delete", "kind": "item", "collection": "blog", "id": 9, "base_updated_at": 1791500000 }
  ]
}
Feld Bedeutung
op create, update (Teil-Update wie das jeweilige PUT: fehlende Felder bleiben) oder delete
kind page, item, global_block, snippet, file (nur Metadaten name, alt, folder_id, private – kein Upload, kein create)
id Objekt-ID bei update/delete: Zahl oder "@ref"
collection Bei item: Slug oder ID der Collection
data Body wie beim Einzel-Endpunkt (POST/PUT /pages, /global-blocks, /snippets, PUT /files/{id}; bei Einträgen nur die Felder, also der Inhalt von data aus POST /collections/{ref}/items). Bei Dateien genügt folder_id (auch null) zum Verschieben
published Status bei item (beim Anlegen ohne Angabe: veröffentlicht) und page (sonst data.published)
ref Name für ein angelegtes Objekt (Buchstaben, Ziffern, _ - ., max. 64): spätere Operationen verwenden "@name" als id oder als Wert in data
base_updated_at Optional: updated_at des geladenen Stands (nicht bei Dateien – sie haben keinen Bearbeitungsstand)

Verweise: "@name" wird in id und in Werten der obersten Ebene von data ersetzt – auch in Listen (z. B. parent_id, Felder vom Typ reference/references, og_image_file_id). Tiefer verschachtelte Werte (etwa in Blockbäumen) bleiben, wie sie sind. Nur Verweise früherer create-Operationen sind möglich.

Versionsprüfung: Alle base_updated_at werden vorab gegen den Stand vor dem Batch geprüft. Weicht eines ab, wird der ganze Batch abgelehnt – mit allen Abweichungen auf einmal:

{ "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 } ] } }

Fehler in einer Operation: Nichts wird geschrieben. Status und code stammen von der gescheiterten Operation (wie beim Einzel-Endpunkt), params.index (ab 0) nennt sie:

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

Antwort 200 – je Operation index, op, kind, id, bei Bedarf ref, collection, updated_at, deleted: true oder warnings (Baustein-Schema):

{
  "idempotency_key": "pkg-7f3a-2026-10-10T12:00",
  "results": [
    { "index": 0, "op": "create", "kind": "page", "id": 41, "ref": "neu", "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
}

Nach dem Commit (nicht bei Fehlern): Seiten-Cache leeren, Collection-Ereignisse für Workflows auslösen (wie bei Einzeländerungen, mit derselben Verschachtelungsgrenze), private Dateien neu laden, Mediendateien gelöschter Dateien entfernen, Webhook-Ereignisse content.changed/schema.changed und batch.applied auslösen.

Grenzen: höchstens 500 Operationen, Body bis 16 MB. Formfehler (vor jedem Schreiben) haben eigene 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 – jeweils mit params.index, soweit eine Operation betroffen ist.

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":{"titel":"Neu"}}]}'

Idempotenz

Mit idempotency_key (1–200 Zeichen) lässt sich ein Batch gefahrlos wiederholen, z. B. wenn die Antwort wegen eines Netzfehlers nicht ankam:

  • Gleicher Schlüssel, gleicher Inhalt: Der Server führt nichts erneut aus und liefert die gespeicherte Antwort mit Header Idempotent-Replay: true. Verglichen wird ein Hash über den Body ohne idempotency_key (Reihenfolge der Objektfelder egal).
  • Gleicher Schlüssel, anderer Inhalt: 409 mit batch.keyReused.
  • Gespeichert werden nur erfolgreiche Batches – ein gescheiterter darf mit demselben Schlüssel korrigiert und wiederholt werden. Der Eintrag entsteht in derselben Transaktion wie die Änderungen.
  • Schlüssel bleiben 30 Tage gespeichert (der Nachtlauf räumt auf) und gehören zur Instanz – sie stehen nicht im Bundle.

GET /batch/{key} 🔑

Wurde ein Batch mit diesem Schlüssel schon angewendet? Für Clients, die nach einem Neustart nicht wissen, ob ihr letzter Batch durchging:

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

Unbekannt (oder älter als 30 Tage): 404 mit common.notFound.

Im Auftrag von

Schreibt ein Token für eine bestimmte Person (z. B. IllusionPublish nach einer Freigabe), kann jede schreibende Anfrage den Header X-IC-On-Behalf-Of: Maria Muster mitschicken – bei POST /batch alternativ on_behalf_of im Body (hat Vorrang). Der Autor in der Versionshistorie lautet dann:

Token „Publish“ – im Auftrag von Maria Muster
  • Gilt nur für Token-Anfragen (auch Master-Token und Workflows); bei Admin-Sitzungen wird die Angabe ignoriert – dort zählt das Konto.
  • Höchstens 100 Zeichen, sonst 400 mit batch.onBehalfTooLong. Steuerzeichen und mehrfache Leerzeichen werden entfernt.
  • HTTP-Header transportieren nicht jedes Zeichen zuverlässig (Browser und Node.js erlauben nur Latin-1): Der Header darf deshalb prozentkodiert sein (Maria%20M%C3%BCller). Ist er kein gültiges Escape (z. B. 100% Agentur), gilt er unverändert.
  • Das Zwei-Minuten-Fenster der Versionshistorie gilt je vollständigem Autor: Verschiedene Personen über dasselbe Token ergeben eigene Versionen.

GET /schema 🔑

Momentaufnahme des Schemas für Integrationen – Collections mit Feldern, Bausteine (wie GET /blocks/types), verwendete Sprachen und Websites:

{
  "version": "3f9c0a1b2c3d4e5f",
  "collections": [ { "id": 2, "slug": "blog", "name": "Blog", "lang": "de", "singleton": false, "public_api": false,
                     "live": false, "detail_page_id": 7, "detail_field": "slug", "fields": [ … ] } ],
  "block_types": [ { "name": "bausteine/hero", "label": "Hero", "params": [ … ], "slots": [ … ], … } ],
  "languages": ["de", "en"],
  "sites": [ { "id": 1, "title": "Bäckerei", "lang": "de", "domains": ["baeckerei.de"], "default": true } ],
  "page_layouts": [ { "id": 12, "name": "layouts/inhaltsseite", "label": "Inhaltsseite", "has_blocks": true, … } ]
}

page_layouts: wie GET /page-layouts.

version ist ein Hash über diesen Inhalt: Sie ändert sich bei jeder Schema-relevanten Änderung (Collection angelegt/geändert/gelöscht, Baustein-Schema, Website-Titel/-Sprache/-Domains), aber nicht durch neue Einträge oder Speichern ohne Änderung. Der Header ETag enthält sie; mit If-None-Match antwortet der Server bei unverändertem Schema mit 304 ohne Body:

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

Webhooks

Webhooks melden Änderungen als POST an eine Adresse – z. B. an n8n, einen Build-Dienst oder einen Workflow-HTTP-Eingang derselben Instanz. Verwaltet werden sie unter Einstellungen → Webhooks oder per API (Admin-Sitzung oder Schreib-Token, also auch über eine Verbindung im Remote-Modus). Gespeichert in der Einstellung webhooks der Instanz (nicht im Bundle), höchstens 20.

GET /webhooks ✏️ – Liste; GET /webhooks/{id} ✏️ – einer:

[ { "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": "…" }. Nur url ist Pflicht (http/https); ohne events sind alle drei Ereignisse abonniert, ohne secret erzeugt der Server eins (whsec_…; eigene Secrets 16–200 Zeichen). Antwort 201 mit dem Webhook und einmalig secret – danach wird es nie mehr ausgegeben (secret_set). Gespeichert ist es nur verschlüsselt (wie das S3-Secret, Schlüssel IC_ENCRYPTION_KEY bzw. data/secret.key).

PUT /webhooks/{id} ✏️ – Teil-Update mit denselben Feldern; fehlende bleiben. "regenerate_secret": true erzeugt ein neues Secret, secret setzt ein eigenes – beides steht dann einmalig in der Antwort. DELETE /webhooks/{id} ✏️ – 204.

POST /webhooks/{id}/test ✏️ – sendet sofort ein ping (ein Versuch, auch bei inaktivem Webhook) und liefert das Ergebnis der Zustellung wie im Protokoll.

GET /webhooks/{id}/deliveries ✏️ – die letzten 50 Zustellungen, neueste zuerst (nur im Speicher, nach einem Neustart leer):

Feld Bedeutung
id Zustell-ID = Header X-IC-Delivery (bleibt bei Wiederholungen gleich)
event, at Ereignis und Zeitpunkt (Unix-Sekunden)
state pending (läuft bzw. wartet auf Wiederholung), ok, failed, skipped (Schleifenschutz)
status, duration_ms, attempts HTTP-Status und Dauer des letzten Versuchs, Anzahl Versuche
error, error_code, error_params, response Fehler (webhook.httpStatus, webhook.requestFailed, webhook.timeout, webhook.nestedTooDeep, webhook.shutdown, storage.secretUndecryptable) (die Antwort des Empfängers wird nicht ins Protokoll übernommen, response bleibt leer – nur der Statuscode steht in status)
changes, hop, test Anzahl Änderungen im Ereignis, Tiefe (siehe Schleifenschutz), Test-Ping

Fehlercodes beim Anlegen und Ändern: webhook.urlInvalid, webhook.eventUnknown, webhook.eventsRequired, webhook.nameTooLong, webhook.secretTooShort, webhook.secretTooLong, webhook.tooMany.

Zustellung: im Hintergrund als POST mit JSON-Body und den Headern Content-Type: application/json, X-IC-Event (Ereignisname), X-IC-Delivery (UUID), X-IC-Signature: sha256=<hex>, X-IC-Timestamp (Unix-Sekunden), X-IC-Signature-V2: sha256=<hex> und X-IC-Webhook-Hop. Jede Antwort 2xx gilt als zugestellt. Zeitlimit 10 Sekunden; bei Netzwerkfehlern, Zeitüberschreitung, 408, 429 und 5xx folgen bis zu 3 Wiederholungen nach 2 s, 10 s und 30 s – andere Status (z. B. 404) werden nicht wiederholt. Weiterleitungen (3xx) folgt der Server nicht. Beim Beenden des Servers gehen offene Bündel noch raus (höchstens 5 Sekunden), Wiederholungen entfallen.

Schleifenschutz: Ein Webhook kann einen Workflow derselben Instanz aufrufen, der Inhalte ändert – was wieder den Webhook auslöst. Deshalb trägt jede Zustellung X-IC-Webhook-Hop (1 = ausgelöst durch eine normale Änderung). Workflow-HTTP-Eingänge übernehmen den Wert; Änderungen aus diesem Lauf (auch über Collection-Ereignisse und HTTP-Anfragen des Workflows) zählen eine Stufe weiter. Nach 3 Zustellungen hintereinander ist Schluss: Weitere Ereignisse erscheinen im Protokoll als skipped mit webhook.nestedTooDeep.

Webhook-Ereignisse

Jeder Body enthält event und at (Unix-Sekunden). changes sind Objekte mit kind, action (created, updated, deleted, moved, imported) und je nach Art id, collection (Slug), slug oder name.

content.changed – Seiten, Einträge, globale Blöcke, Dateien, Snippets und CSS/JS-Assets (kind: page, item, global_block, file, snippet, asset). Massenänderungen ohne Einzelliste: import (Website-Import), bundle (Bundle-Import, Abgleich), items (CSV-Import einer Collection). Änderungen innerhalb von etwa 2 Sekunden werden zu einer Zustellung gebündelt (doppelte zusammengefasst, ab 1000 Einträgen "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 angelegt, geändert oder gelöscht (kind: collection), Baustein-Schema geändert oder Baustein entfernt (kind: block_type), Seitenlayouts geändert (kind: page_layout), Bundle-Import (kind: bundle). schema_version ist die aktuelle Version aus GET /schema – ein Client vergleicht sie mit seiner und lädt nur bei Bedarf neu. Sofort zugestellt:

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

batch.applied – nach jedem erfolgreichen POST /batch (nicht bei einer Wiederholung mit demselben idempotency_key), mit den Ergebnissen wie in dessen Antwort. Die Änderungen des Batches kommen zusätzlich als content.changed bzw. 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 – nur über POST /webhooks/{id}/test: { "event": "ping", "at": 1791572400, "webhook": "3fa94c1b20de" }.

Signatur prüfen

X-IC-Signature ist sha256= plus der hexadezimale HMAC-SHA256 des unveränderten Bodys mit dem Secret als Schlüssel. Prüfe sie vor allem anderen und vergleiche in konstanter Zeit; den Body erst danach als JSON lesen (neu serialisiertes JSON ergibt eine andere Signatur). Gegen das Wiedereinspielen alter Zustellungen helfen at und X-IC-Delivery (bei Wiederholungen gleich – also auch zum Erkennen doppelter Zustellungen). Mit Zeitstempel: X-IC-Timestamp enthält die Unix-Sekunden des Versuchs, X-IC-Signature-V2 ist sha256= plus der HMAC-SHA256 über <Timestamp>.<Body> (Punkt als Trenner). Empfehlung: V2 prüfen und Zustellungen ablehnen, deren Zeitstempel mehr als 5 Minuten von der eigenen Uhr abweicht; bei Wiederholungen trägt jeder Versuch einen neuen Zeitstempel. X-IC-Signature (nur Body) bleibt unverändert erhalten.

JavaScript (Node.js mit 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 dieser Instanz können die Signatur nicht prüfen (der Function-Node kennt kein HMAC) – ein Webhook auf einen eigenen HTTP-Eingang eignet sich für Benachrichtigungen, nicht als Zugangsschutz.