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 ohneidempotency_key(Reihenfolge der Objektfelder egal). - Gleicher Schlüssel, anderer Inhalt:
409mitbatch.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
400mitbatch.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.