API-Referenz

Alle 30 Aktionen der CMF-REST-API mit Parametern, Beispielen, Antworten und Fehlercodes – dazu das vollständige Blockschema und die Stellen, an denen beim Zurückschreiben Daten verloren gehen können. Stand: CMF 1.17.1.

Diese Website wird selbst über genau diese API gepflegt: Die News-Beiträge vom 15.09.2026 sind komplett per API angelegt.

Kurzfassung README_MASCHINEN.md Diese Seite als Markdown llms.txt

Grundlagen

Die API steckt in jeder CMF-Installation unter /api.php. Die Aktion wählst du mit dem URL-Parameter a; Kennungen wie id, slug, part oder path hängen als weitere Parameter an. Anfragen und Antworten sind JSON in UTF-8, einzige Ausnahme ist der Datei-Upload per multipart/form-data.

API-Basis:  https://deinewebsite.de/api.php
Lesen:      GET  https://deinewebsite.de/api.php?a=page&slug=kontakt
Schreiben:  POST https://deinewebsite.de/api.php?a=page_update&id=a1b2c3d4e5f6
            Content-Type: application/json
            Authorization: Bearer cms_DEIN_API_TOKEN_HIER

Jede Antwort hat dieselbe Hülle:

{ "ok": true,  "data": { … } }
{ "ok": false, "error": "invalid_page_schema", "details": [ "heading h1_start text fehlt" ] }

Authentifizierung

Alle Aktionen außer search_index und version_check brauchen einen API-Token – auch sitemap_generate und lesende Aufrufe wie pages. Du schickst ihn als Bearer-Token oder, falls dein Server den Authorization-Header nicht an PHP weitergibt, im Header X-API-Token:

Authorization: Bearer cms_DEIN_API_TOKEN_HIER
X-API-Token: cms_DEIN_API_TOKEN_HIER
HTTPerrorBedeutung
401missing_tokenKein Token im Header gefunden.
401invalid_tokenToken unbekannt.
401token_disabledToken ist in config/users.json mit "enabled": false gesperrt.

Die Token-Prüfung läuft vor allem anderen: Auch eine unbekannte Aktion antwortet ohne gültigen Token mit 401, nicht mit 404.

Arbeitsweise: lesen, ändern, vollständig zurückschreiben

  1. GET ?a=site_bundle – Name, Sprache, Theme, Header, Footer und Custom CSS auf einen Blick.
  2. GET ?a=pages bzw. GET ?a=blog_posts – IDs, Slugs, Status, Navigation, Kategorien.
  3. GET ?a=page&id=… bzw. GET ?a=blog_post&id=… – das Dokument vollständig laden.
  4. Bilder und Dateien vorher mit media_upload hochladen und data.src übernehmen – Pfade nie selbst erfinden.
  5. data.content ändern: vorhandene Block-IDs behalten, neue IDs eindeutig vergeben.
  6. Mit page_update bzw. blog_update vollständig zurückschreiben.
  7. Antwort prüfen: data.content, data.page.slug und data.page.nav zeigen, was tatsächlich gespeichert wurde.

Empfehlung: GET ?a=page liefert data.content – genau dieses Objekt als {"content": …} zurückschicken.

GET  ?a=page&id=a1b2c3d4e5f6
→ { "ok": true, "data": { "page": { … }, "content": { "meta": { … }, "content": { "blocks": [ … ] } } } }

POST ?a=page_update&id=a1b2c3d4e5f6
{ "content": { "meta": { … }, "content": { "blocks": [ … ] } } }

Damit bleiben meta.image und meta.robots automatisch erhalten, und alle Block-IDs stimmen.

Ersetzt oder führt zusammen?

Die wichtigste Tabelle dieser Seite: Wer ein Teilobjekt schickt, wo das CMF komplett ersetzt, löscht alles Übrige.

Aktion / FeldVerhaltenDas heißt für dich
page_update, blog_update mit contentersetzt das ganze Dokument (meta und alle Blöcke)meta.image und meta.robots mitschicken, sonst sind sie weg. meta kennt nur title, description, image, robots – andere Felder werden verworfen.
page_update, blog_update ohne contentInhalt bleibt unverändert, keine SchemaprüfungGeeignet für reine Änderungen an Titel, Slug, Status, nav (Blog: image, description, category, order).
title, slug, statusersetzt, nur wenn mitgeschickttitle ändert nicht meta.title. Ein neuer Slug ändert die URL – Weiterleitungen gibt es nicht.
nav bei page_updateführt zusammenEinzelne Schlüssel reichen, z. B. {"nav": {"order": 3}}. "parent": null macht die Seite zum Hauptpunkt.
image, description, category, order bei blog_updateersetzt, nur wenn mitgeschicktFehlt meta.image im mitgeschickten Inhalt, wird image übernommen. Hat das gespeicherte Dokument schon ein meta.image, ändert ein neues image ohne content daran nichts – dann meta.image mit ändern.
partial_updateersetzt Header bzw. Footer komplettmeta behält nur title und description.
site_updateersetzt site.json komplettErst GET ?a=site, ändern, vollständig zurücksenden. Fehlende Felder (og_image, csp …) sind danach weg.
styles_updateführt zusammenEin Teilobjekt reicht, z. B. nur colors.primary.
custom_css_updateersetzt custom.css komplettErst GET ?a=custom_css. Ein Body ohne css leert die Datei.
site_importersetzt jeden mitgeschickten Teil; Seiten mit gleicher ID werden überschriebenNicht mitgeschickte Teile und übrige Seiten bleiben.
pages_importmode: "skip": vorhandene IDs bleiben; "overwrite": Index-Eintrag und Inhalt ersetztOhne Schemaprüfung – page_create/page_update bevorzugen.
page_delete, blog_deletelöscht endgültigUnterseiten werden zu Hauptpunkten; hochgeladene Medien bleiben liegen.

Alle 30 Aktionen

Alle Aufrufe gehen an /api.php?a=AKTION. Öffentlich (ohne Token) sind nur search_index und version_check.

AktionMethodeParameter / BodyZweck
[pages](#pages)GETAlle Seiten (Index, inkl. Entwürfe)
[page](#page)GETid oder slugEine Seite mit Inhalt
[page_create](#page_create)POSTJSON: title, slug, status, nav, contentSeite anlegen (201)
[page_update](#page_update)POSTid; JSON: content, title, slug, status, navSeite ändern
[page_delete](#page_delete)POSTidSeite löschen
[pages_export](#pages_export)GETAlle Seiten mit Inhalt
[pages_import](#pages_import)POSTJSON: mode, pagesSeiten importieren (ohne Prüfung)
[blog_posts](#blog_posts)GETBeiträge, Kategorien, Blog-Präfix
[blog_post](#blog_post)GETid oder slugEin Beitrag mit Inhalt
[blog_create](#blog_create)POSTJSON: title, slug, status, image, description, category, contentBeitrag anlegen (201)
[blog_update](#blog_update)POSTid; JSON wie blog_create, dazu orderBeitrag ändern
[blog_delete](#blog_delete)POSTidBeitrag löschen
[site](#site)GETsite.json lesen
[site_update](#site_update)POSTJSON: vollständiges site-Objektsite.json ersetzen
[site_bundle](#site_bundle)GETsite, styles, header, footer, custom_css
[site_export](#site_export)GETsite, styles, header, footer, custom_css, pages
[site_import](#site_import)POSTJSON: beliebige dieser TeileTeile einspielen
[partial](#partial)GETpart=header oder footerHeader/Footer lesen
[partial_update](#partial_update)POSTpart; JSON: contentHeader/Footer ersetzen
[styles](#styles)GETTheme-Werte lesen
[styles_update](#styles_update)POSTJSON: Teil-StylesTheme-Werte zusammenführen
[custom_css](#custom_css)GETCustom CSS lesen
[custom_css_update](#custom_css_update)POSTJSON: cssCustom CSS ersetzen
[media](#media)GETAlle Mediendateien mit Verwendung
[media_upload](#media_upload)POSTmultipart, Feld fileDatei hochladen (201)
[media_usage](#media_usage)GEToptional pathVerwendung prüfen
[media_delete](#media_delete)POSTpathUngenutzte Datei löschen
[search_index](#search_index)GET– (öffentlich)Suchindex
[sitemap_generate](#sitemap_generate)GET– (Token nötig)Sitemap & Co. neu erzeugen
[version_check](#version_check)GET– (öffentlich)Installierte Version

Seiten

pages – alle Seiten

GET ?a=pages liefert data.pages: alle Index-Einträge, nach Slug sortiert, einschließlich Entwürfen. Jeder Eintrag: id, slug, title, status (published | draft), nav (show, order, label, parent), created, updated. Die Startseite hat den Slug home.

page – eine Seite lesen

GET ?a=page&id=ID oder GET ?a=page&slug=SLUG – ist id gesetzt, wird slug ignoriert. Antwort: data.page (Index-Eintrag) und data.content (das Dokument). Entwürfe werden ebenfalls geliefert. Unbekannt oder ohne beide Parameter: 404 page_not_found.

page_create – Seite anlegen

FeldPflichtVerhalten
contentjaVollständiges Dokument (siehe Dokument). Fehlt es: 422 invalid_page_schema.
titleneinFehlt es, heißt die Seite „Neue Seite“.
slugneinAus slug oder title erzeugt: Kleinbuchstaben, ä→ae, ö→oe, ü→ue, ß→ss, alles andere wird zu -. Ist er belegt, hängt das CMF -2, -3 … an – Slug aus der Antwort übernehmen.
statusnein"published" oder "draft" (Standard; jeder andere Wert gilt als draft).
nav.shownein**Standard true** – eine veröffentlichte Seite ohne nav erscheint im Menü. Für Seiten außerhalb des Menüs "nav": {"show": false} senden.
nav.orderneinGanze Zahl, Standard: Anzahl vorhandener Seiten + 1.
nav.labelneinMenütext; null = Titel.
nav.parentneinID oder Slug der Elternseite. Unbekannt, Selbstbezug oder Schleife → still null. data.page.nav.parent in der Antwort prüfen.
POST ?a=page_create
{
  "title": "Leistungen",
  "slug": "leistungen",
  "status": "draft",
  "nav": {
    "show": false
  },
  "content": {
    "meta": {
      "title": "Leistungen",
      "description": "Was wir anbieten, kurz und konkret."
    },
    "content": {
      "blocks": [
        {
          "id": "h1_leistungen",
          "type": "heading",
          "data": {
            "level": 1,
            "text": "Leistungen"
          }
        },
        {
          "id": "t_intro",
          "type": "text",
          "data": {
            "html": "<p>Kurzer Einstieg.</p>"
          }
        }
      ]
    }
  }
}

Antwort 201:

{
  "ok": true,
  "data": {
    "page": {
      "id": "a1b2c3d4e5f6",
      "slug": "leistungen",
      "title": "Leistungen",
      "status": "draft",
      "nav": {
        "show": false,
        "order": 24,
        "label": null,
        "parent": null
      },
      "created": "2026-09-15T10:00:00+02:00",
      "updated": "2026-09-15T10:00:00+02:00"
    },
    "content": {
      "meta": {
        "title": "Leistungen",
        "description": "Was wir anbieten, kurz und konkret."
      },
      "content": {
        "blocks": [
          "…"
        ]
      }
    }
  }
}

page_update – Seite ändern

POST ?a=page_update&id=ID (die id darf auch im Body stehen). Alle Felder sind optional: content ersetzt das Dokument komplett, title, slug und status werden einzeln ersetzt, nav wird zusammengeführt. Antwort 200 mit data.page und data.content.

POST ?a=page_update&id=a1b2c3d4e5f6
{
  "content": {
    "meta": {
      "title": "Leistungen",
      "description": "Was wir anbieten, kurz und konkret.",
      "image": "/media/2026/09/titelbild-9f3c2a1b.jpg",
      "robots": "noindex"
    },
    "content": {
      "blocks": [
        {
          "id": "h1_leistungen",
          "type": "heading",
          "data": {
            "level": 1,
            "text": "Leistungen"
          }
        },
        {
          "id": "t_intro",
          "type": "text",
          "data": {
            "html": "<p>Überarbeiteter Einstieg.</p>"
          }
        }
      ]
    }
  }
}

Nur veröffentlichen und einhängen, Inhalt unverändert lassen:

POST ?a=page_update&id=a1b2c3d4e5f6
{
  "status": "published",
  "nav": {
    "parent": "anleitungen",
    "order": 3
  }
}

Fehler: 400 missing_id, 404 page_not_found, 422 invalid_page_schema mit details. Ein leerer title wird zu „Ohne Titel“.

Ohne content-Hülle geht es schief: Ein Body {"meta": …, "content": {"blocks": …}} ergibt 422 (meta fehlt, content fehlt). Ein Body {"data": …} ergibt ok: true, ändert aber nichts.

page_delete – Seite löschen

POST ?a=page_delete&id=ID (oder Body {"id": "…"}). Antwort: data.deleted: true und data.page. Unterseiten verlieren ihre Elternseite (parent wird null), Medien bleiben erhalten. Gelöschte Seiten lassen sich nicht wiederherstellen.

Blog

blog_posts – alle Beiträge

GET ?a=blog_posts liefert data.slug (Blog-Präfix, Beitrags-URL: /PRÄFIX/beitrags-slug), data.categories (Liste der im Admin angelegten Kategorien) und data.posts, sortiert nach order aufsteigend. Jeder Eintrag: id, slug, title, status, image, description, category, order, created, updated.

blog_post – einen Beitrag lesen

GET ?a=blog_post&id=ID oder &slug=SLUG. Antwort: data.post und data.content. Unbekannt: 404 post_not_found.

blog_create – Beitrag anlegen

FeldBedeutung
contentPflicht, vollständiges Dokument wie bei Seiten.
title, slug, statusWie bei page_create; Standardtitel „Neuer Beitrag“, Slug eindeutig unter den Beiträgen.
imageBeitragsbild für Karte und Vorschau; wird als meta.image übernommen, wenn das Dokument keines hat.
descriptionKurztext für Beitragskarte, feed.xml und llms.txt.
content.meta.descriptionMeta-Beschreibung für Suchmaschinen – getrennt von description setzen.
categoryGenau ein Wert aus data.categories von blog_posts, exakt geschrieben (Groß-/Kleinschreibung). Die API prüft das nicht; ein abweichender Wert fällt aus gefilterten Übersichten.
orderNicht bei blog_create: Neue Beiträge landen automatisch oben (order = kleinster vorhandener Wert minus 1, höchstens 0).
POST ?a=blog_create
{
  "title": "Neue Funktion",
  "slug": "neue-funktion",
  "status": "published",
  "image": "/media/2026/09/titelbild-9f3c2a1b.jpg",
  "description": "Kurztext für die Beitragskarte, den Feed und die llms.txt.",
  "category": "Technik",
  "content": {
    "meta": {
      "title": "Neue Funktion im Überblick",
      "description": "Meta-Beschreibung für Suchmaschinen, getrennt vom Kurztext."
    },
    "content": {
      "blocks": [
        {
          "id": "h1_funktion",
          "type": "heading",
          "data": {
            "level": 1,
            "text": "Neue Funktion im Überblick"
          }
        },
        {
          "id": "t_funktion",
          "type": "text",
          "data": {
            "html": "<p>Worum es geht.</p>"
          }
        }
      ]
    }
  }
}

Antwort 201 mit data.post und data.content. Beiträge haben kein nav.

blog_update – Beitrag ändern

POST ?a=blog_update&id=ID. Wie page_update, zusätzlich image, description, category und order (ganze Zahl; kleiner = weiter oben). Beispiel zum Umsortieren: {"order": -10}. Fehler: 400 missing_id, 404 post_not_found, 422 invalid_post_schema.

blog_delete – Beitrag löschen

POST ?a=blog_delete&id=ID. Antwort: data.deleted: true und data.post. Endgültig.

Website, Header/Footer, Theme

site – Website-Einstellungen lesen

GET ?a=site liefert data.site (Felder siehe site.json). Im Admin gibt es für site.json keine Oberfläche – die API ist der Weg.

site_update – Website-Einstellungen ersetzen

POST ?a=site_update mit dem vollständigen site-Objekt (direkt oder als {"site": …}). Pflicht: name und lang. Unbekannte Felder werden verworfen, fehlende sind danach weg. Fehler: 422 invalid_site_schema mit details.

POST ?a=site_update
{
  "name": "Meine Website",
  "lang": "de",
  "baseUrl": "https://deinewebsite.de",
  "og_image": "/media/2026/09/vorschau-1a2b3c4d.png",
  "title_suffix": "Meine Website",
  "csp": {
    "script_src": [
      "https://widget.example.com"
    ],
    "connect_src": [
      "https://widget.example.com"
    ]
  }
}

site_update erzeugt Sitemap, Feed und llms.txt nicht neu. Nach einer Änderung von baseUrl oder name deshalb sitemap_generate aufrufen.

site_bundle – alles Globale auf einmal

GET ?a=site_bundle liefert data.site, data.styles, data.header, data.footer und data.custom_css. Der beste erste Aufruf.

partial – Header oder Footer lesen

GET ?a=partial&part=header bzw. part=footer. Antwort: data.part und data.content (gleiches Dokumentformat wie Seiten). Anderer Wert: 404 partial_not_found.

partial_update – Header oder Footer ersetzen

POST ?a=partial_update&part=footer (part darf auch im Body stehen). Schema wie bei Seiten, meta.title ist Pflicht. Das Dokument wird komplett ersetzt; meta behält nur title und description. Ein leeres blocks-Array ist hier erlaubt und leert den Bereich. Fehler: 422 invalid_partial_schema.

POST ?a=partial_update&part=footer
{
  "content": {
    "meta": {
      "title": "Footer"
    },
    "content": {
      "blocks": [
        {
          "id": "t_footer",
          "type": "text",
          "data": {
            "html": "<p>© 2026 Beispiel · <a href=\"/impressum\">Impressum</a></p>"
          }
        }
      ]
    }
  }
}

styles – Theme-Werte lesen

GET ?a=styles liefert data.styles mit allen 24 Werten (siehe Styles-Schema).

styles_update – Theme-Werte zusammenführen

POST ?a=styles_update mit einem Teilobjekt (direkt oder als {"styles": …}). Nicht genannte Werte bleiben. Die Datei theme.css wird sofort neu geschrieben. Fehler: 422 invalid_styles_schema, z. B. bei einem Gewicht außer light, regular, bold.

POST ?a=styles_update
{
  "colors": {
    "primary": "#ea6b17",
    "link": "#ea6b17"
  },
  "fonts": {
    "heading": "Raleway",
    "heading_weight": "bold"
  }
}

custom_css – Custom CSS lesen

GET ?a=custom_css liefert data.css – den kompletten Inhalt von public/assets/css/custom.css.

custom_css_update – Custom CSS ersetzen

POST ?a=custom_css_update mit {"css": "…"}. Der Inhalt ersetzt die Datei komplett; PHP-Tags werden entfernt. Fehlt css, ist die Datei danach leer. Also: lesen, ergänzen, alles zurückschicken.

POST ?a=custom_css_update
{
  "css": "/* bisheriger Inhalt aus GET ?a=custom_css … */\n.hinweis { border-left: 4px solid var(--color-primary); }"
}

Medien

media – alle Dateien

GET ?a=media liefert data.files, neueste zuerst. Jeder Eintrag: path (z. B. /media/2026/09/titelbild-9f3c2a1b.jpg), url, filename, ext, mime, size, modified, used, usage_count, deletable.

media_upload – Datei hochladen

POST ?a=media_upload als multipart/form-data mit dem Feld **file**. Erlaubt: jpg, jpeg, png, webp, gif, svg, pdf, mp4, mp3, wav. Die Datei landet in /media/JJJJ/MM/, der Dateiname wird bereinigt und bekommt immer einen Zufallsanhang. SVGs werden per XML-Parser bereinigt: Unsichere Teile entfernt das CMF und speichert die bereinigte Datei. Abgelehnt (422 invalid_svg) wird nur, was kein lesbares SVG ist.

curl -H "Authorization: Bearer cms_DEIN_API_TOKEN_HIER" \
     -F "file=@titelbild.jpg" \
     "https://deinewebsite.de/api.php?a=media_upload"

Antwort 201data.src ist der Pfad für Blöcke:

{
  "ok": true,
  "data": {
    "src": "/media/2026/09/titelbild-9f3c2a1b.jpg",
    "url": "https://deinewebsite.de/media/2026/09/titelbild-9f3c2a1b.jpg",
    "filename": "titelbild-9f3c2a1b.jpg",
    "ext": "jpg",
    "mime": "image/jpeg",
    "size": 184223,
    "modified": "2026-09-15T10:12:00+02:00"
  }
}

Fehler: 400 missing_file, upload_failed (auch wenn die Datei die Upload-Grenze des Servers überschreitet), invalid_file; 415 unsupported_filetype, mime_mismatch (mit details.ext und details.mime); 422 invalid_svg; 500 media_dir_create_failed, move_failed.

media_usage – Verwendung prüfen

**Mit path:** GET ?a=media_usage&path=/media/2026/09/titelbild-9f3c2a1b.jpg liefert used, usage_count und references. Der Pfad muss mit /media/ beginnen, sonst oder bei fehlender Datei: 404 media_not_found.

{
  "ok": true,
  "data": {
    "path": "/media/2026/09/titelbild-9f3c2a1b.jpg",
    "used": true,
    "usage_count": 1,
    "references": [
      {
        "source": "Seite: Leistungen",
        "location": "content.blocks.2.data.src"
      }
    ]
  }
}

**Ohne path:** Übersicht mit data.summary (total, used, unused), data.used_files und data.unused_files. Durchsucht werden Seiten, Header, Footer, Beiträge samt Beitragsbild, Custom CSS sowie og_image und logo aus site.json.

media_delete – Datei löschen

POST ?a=media_delete&path=/media/… oder Body {"path": "/media/…"}. Gelöscht werden nur ungenutzte Dateien; Antwort data.deleted: true. Fehler: 400 missing_path, 404 media_not_found, 500 delete_failed. Ist die Datei eingebunden, kommt 409 – die Fundstellen stehen in data.references, nicht in details:

{
  "ok": false,
  "error": "media_in_use",
  "data": {
    "path": "/media/2026/09/titelbild-9f3c2a1b.jpg",
    "usage_count": 1,
    "references": [
      {
        "source": "Seite: Leistungen",
        "location": "content.blocks.2.data.src"
      }
    ]
  }
}

Export und Import

site_export – Website exportieren

GET ?a=site_export liefert data.site, data.styles, data.header, data.footer, data.custom_css und data.pages (Liste aus {index, content}). Blog und Medien sind nicht enthalten.

site_import – Teile einspielen

POST ?a=site_import mit beliebigen dieser Schlüssel. Jeder Teil wird einzeln geprüft: site (name, lang), styles, header/footer und jede Seite gegen das Schema; custom_css ersetzt die Datei. Seiten mit vorhandener ID werden überschrieben, neue angelegt. Seiten-IDs müssen a-z, 0-9, -, _ sein (höchstens 64 Zeichen), sonst wird der Eintrag still übersprungen. Doppelte Slugs prüft site_import nicht: Zwei Seiten mit gleichem Slug werden beide gespeichert, erreichbar ist nur die erste.

Die Antwort ist immer ok: true – abgelehnte Teile stehen in data.rejected:

{
  "ok": true,
  "data": {
    "imported": [
      "styles",
      "pages (2)"
    ],
    "rejected": {
      "page:a1b2c3d4e5f6": [
        "meta.title fehlt"
      ]
    }
  }
}

pages_export – alle Seiten exportieren

GET ?a=pages_export liefert data.pages (Liste aus {index, content}) und data.count.

pages_import – Seiten importieren

POST ?a=pages_import mit mode ("skip" = Standard, vorhandene IDs bleiben; "overwrite" = ersetzen) und pages. Neue Seiten mit belegtem Slug bekommen -2 usw. Antwort: data.imported und data.skipped. Fehlt pages: 422 mit error „pages array fehlt“.

POST ?a=pages_import
{
  "mode": "skip",
  "pages": [
    {
      "index": {
        "id": "a1b2c3d4e5f6",
        "slug": "leistungen",
        "title": "Leistungen",
        "status": "draft",
        "nav": {
          "show": false,
          "order": 5,
          "label": null,
          "parent": null
        }
      },
      "content": {
        "meta": {
          "title": "Leistungen",
          "description": "Was wir anbieten, kurz und konkret.",
          "image": "/media/2026/09/titelbild-9f3c2a1b.jpg",
          "robots": "noindex"
        },
        "content": {
          "blocks": [
            {
              "id": "h1_leistungen",
              "type": "heading",
              "data": {
                "level": 1,
                "text": "Leistungen"
              }
            },
            {
              "id": "t_intro",
              "type": "text",
              "data": {
                "html": "<p>Überarbeiteter Einstieg.</p>"
              }
            }
          ]
        }
      }
    }
  ]
}

pages_import prüft die Inhalte nicht gegen das Schema und übernimmt den Index-Eintrag ohne Prüfung (nur der Slug wird bereinigt). Fehlerhafte Seiten werden gespeichert und rendern kaputt. Für einzelne Seiten page_create bzw. page_update nehmen.

Eine ganze Website mit Blog, Medien und Theme klonst du über den ZIP-Export und -Import im Admin unter „Einstellungen“ – dabei bleiben alle Pfade gleich. Die config/site.json steckt nicht im ZIP: Name, Adresse und weitere Einstellungen trägst du auf der Zielinstallation selbst ein.

Öffentliche und System-Aktionen

search_index – Suchindex (öffentlich)

GET ?a=search_index ohne Token. data.pages enthält veröffentlichte Seiten und Beiträge mit slug (Beiträge mit Blog-Präfix), title, description und text. Dieselben Daten liegen als statische Datei unter /search-index.json.

sitemap_generate – abgeleitete Dateien neu erzeugen

GET ?a=sitemap_generate mit Token. Schreibt sitemap.xml, robots.txt, feed.xml, llms.txt und search-index.json neu; Antwort data.generated: true. Nötig ist das nur nach site_update (z. B. neue baseUrl) – nach Änderungen an Seiten und Beiträgen passiert es automatisch.

version_check – Version (öffentlich)

GET ?a=version_check ohne Token, mit Access-Control-Allow-Origin: *. Antwort: data.version, data.date, data.changelog, data.download_url. Antwortet auch während eines laufenden System-Updates.

Dokument: meta und nav

Seiten, Beiträge, Header und Footer haben denselben Aufbau:

{
  "meta": {
    "title": "Pflicht, nicht leer",
    "description": "empfohlen",
    "image": "/media/2026/09/vorschau-1a2b3c4d.jpg",
    "robots": "noindex"
  },
  "content": {
    "blocks": [
      "…"
    ]
  }
}
meta-FeldBedeutung
titlePflicht. Wird zum <title>, mit dem Anhang aus site.json (title_suffix, sonst name).
descriptionMeta-Beschreibung; bei Seiten auch Kurztext in llms.txt und Suche.
imageVorschaubild (og:image). Fehlt es, nimmt das CMF das erste Bild der Seite, dann og_image aus site.json.
robotsZ. B. "noindex" oder "noindex, nofollow". Enthält der Wert noindex, fehlt die Seite in Sitemap und llms.txt.

Andere meta-Felder werden verworfen.

Regeln für content.blocks

Das nav-Objekt (nur Seiten)

SchlüsselTypBedeutung
showboolIm Menü anzeigen. Standard true.
orderintReihenfolge, kleiner = weiter vorn.
labelstring | nullMenütext; null = Seitentitel.
parentstring | nullID oder Slug der Elternseite (wird als ID gespeichert). Ungültig → null.

Die 9 Blocktypen

TypPflicht in dataOptionalHinweise
headinglevel (1–6), text (nicht leer)Reiner Text, HTML erscheint als Text.
texthtml (String)Nur erlaubte Tags, siehe Filter.
imagesrc (nicht leer), alt (String, darf leer sein)caption, loading ("lazy" Standard oder "eager"), width, heightwidth/height als ganze Zahl ≥ 1, sonst 422. Sie wirken nur, wenn beide gesetzt sind, und werden nicht automatisch ermittelt.
listordered (bool), items (Array aus Strings)Einträge sind reiner Text.
buttonsitems (nicht leer), je label und hrefstyle: "primary" oder ""href nur relativ, #anker, http(s), mailto, tel – sonst wird # daraus.
columnscolumns (2–5), items: genau so viele Arrays aus BlöckenBlöcke in Spalten brauchen ebenfalls eindeutige IDs.
htmlcode (String)Wird ungefiltert ausgegeben. Für Tabellen, Karten, Raster, Einbettungen. Externe Skripte brauchen einen Eintrag in site.jsoncsp.
blog_overview– (data als {})categoryKarten der veröffentlichten Beiträge; category filtert exakt, leer = alle.
formfields (nicht leer)siehe Formular-BlockPostet auf die eigene Seiten-URL.

Eine Seite mit acht Blocktypen, die genau so gegen das Schema besteht:

{
  "meta": {
    "title": "Beispielseite",
    "description": "Kurzbeschreibung für Suchmaschinen.",
    "image": "/media/2026/09/titelbild-9f3c2a1b.jpg"
  },
  "content": {
    "blocks": [
      {
        "id": "h1_beispiel",
        "type": "heading",
        "data": {
          "level": 1,
          "text": "Beispielseite"
        }
      },
      {
        "id": "t_einleitung",
        "type": "text",
        "data": {
          "html": "<p>Absatz mit <strong>Hervorhebung</strong> und <a href=\"/kontakt\">Link</a>.</p>"
        }
      },
      {
        "id": "img_titel",
        "type": "image",
        "data": {
          "src": "/media/2026/09/titelbild-9f3c2a1b.jpg",
          "alt": "Werkstatt von innen",
          "caption": "Unsere Werkstatt",
          "loading": "eager",
          "width": 1600,
          "height": 900
        }
      },
      {
        "id": "l_vorteile",
        "type": "list",
        "data": {
          "ordered": false,
          "items": [
            "Schnell geladen",
            "Ohne Datenbank"
          ]
        }
      },
      {
        "id": "b_aktion",
        "type": "buttons",
        "data": {
          "items": [
            {
              "label": "Kontakt",
              "href": "/kontakt",
              "style": "primary"
            },
            {
              "label": "Mehr erfahren",
              "href": "/warum-cmf",
              "style": ""
            }
          ]
        }
      },
      {
        "id": "c_zwei",
        "type": "columns",
        "data": {
          "columns": 2,
          "items": [
            [
              {
                "id": "h2_links",
                "type": "heading",
                "data": {
                  "level": 2,
                  "text": "Linke Spalte"
                }
              }
            ],
            [
              {
                "id": "t_rechts",
                "type": "text",
                "data": {
                  "html": "<p>Rechte Spalte.</p>"
                }
              }
            ]
          ]
        }
      },
      {
        "id": "html_tabelle",
        "type": "html",
        "data": {
          "code": "<div class=\"table-wrap\"><table><tr><td>A</td><td>B</td></tr></table></div>"
        }
      },
      {
        "id": "blog_news",
        "type": "blog_overview",
        "data": {
          "category": ""
        }
      }
    ]
  }
}

Filter im text-Block

Der text-Block erlaubt genau diese 17 Tags:

a b strong i em u br p ul ol li code pre span small sup sub

Formular-Block

{
  "id": "f_kontakt",
  "type": "form",
  "data": {
    "title": "Schreib uns",
    "intro": "Pflichtfelder sind markiert.",
    "submit_label": "Nachricht senden",
    "success_message": "Danke, deine Nachricht ist angekommen.",
    "store": true,
    "email_to": "info@example.com",
    "confirm": false,
    "fields": [
      {
        "name": "name",
        "type": "text",
        "label": "Name",
        "required": true
      },
      {
        "name": "email",
        "type": "email",
        "label": "E-Mail",
        "required": true
      },
      {
        "name": "anliegen",
        "type": "select",
        "label": "Anliegen",
        "required": true,
        "options": [
          "Frage",
          "Angebot",
          "Sonstiges"
        ]
      },
      {
        "name": "nachricht",
        "type": "textarea",
        "label": "Nachricht",
        "required": true
      },
      {
        "name": "datenschutz",
        "type": "checkbox",
        "label": "Ich habe die Datenschutzerklärung gelesen.",
        "required": true
      }
    ]
  }
}
Feld in dataRegel
fieldsPflicht, nicht leeres Array.
titleOptional; erscheint als Überschrift (h2) über dem Formular.
introOptional; Einleitungstext.
submit_labelOptional; Standard „Absenden“.
success_messageOptional; Text nach dem Absenden.
storeStandard an. Nur false schaltet das Speichern ab. Gespeicherte Einsendungen stehen im Admin unter „Einsendungen“ (höchstens 2000 je Formular, die ältesten fallen heraus).
email_toOptional; leer oder eine gültige E-Mail-Adresse, sonst 422. Schickt jede Einsendung zusätzlich per Mail.
confirmOptional, bool, Standard false. Bestätigungsmail mit den Angaben an die Adresse aus dem ersten email-Feld.
Feld in fields[]Regel
namePflicht; nur a-z, 0-9, _; eindeutig im Formular.
labelPflicht.
typePflicht: text, email, tel, textarea, select, checkbox oder radio.
requiredOptional, bool. Eine Pflicht-Checkbox muss angehakt werden.
optionsPflicht bei select und radio: nicht leeres Array aus nicht leeren Strings.

site.json-Felder

FeldTypBedeutung
namestring, PflichtName der Website (Titel-Anhang, strukturierte Daten, Feed).
langstring, PflichtSprache, z. B. de.
baseUrlstringAbsolute Adresse ohne / am Ende. Grundlage für Canonical, Sitemap, Feed, llms.txt und url bei Medien.
og_imagestringStandard-Vorschaubild.
logostringLogo-Pfad (Favicon-Link und strukturierte Daten).
title_suffixstringTitel-Anhang „ | Wert“. Leer = kein Anhang; fehlt das Feld, wird name angehängt.
software_schemaboolGibt zusätzlich SoftwareApplication-Daten (JSON-LD) aus.
download_url, software_category, software_versionstringAngaben dafür; software_category Standard DeveloperApplication.
cspObjektErweitert die Content-Security-Policy. Schlüssel: script_src, connect_src, style_src, img_src, font_src, media_src, frame_src, je ein Array aus Quellen wie https://widget.example.com. Ungültige Quellen werden verworfen.

Andere Felder verwirft site_update. Denk daran: site_update ersetzt die Datei komplett.

Styles-Schema

24 Werte in vier Gruppen. Das Beispiel zeigt die Rückfallwerte, die gelten, wenn ein Wert nirgends gesetzt ist:

{
  "container": "1100px",
  "pad": "16px",
  "gap": "16px",
  "radius": {
    "sm": "8px",
    "md": "14px",
    "lg": "22px"
  },
  "colors": {
    "bg": "#ffffff",
    "text": "#111111",
    "muted": "#666666",
    "border": "#dddddd",
    "primary": "#0d6efd",
    "secondary": "#ff0000",
    "primary_text": "#ffffff",
    "link": "#0d6efd"
  },
  "type": {
    "body": "14px",
    "h1": "2.1rem",
    "h2": "1.6rem",
    "h3": "1.25rem",
    "h4": "1.1rem",
    "h5": "1.0rem"
  },
  "fonts": {
    "body": "",
    "body_weight": "regular",
    "heading": "",
    "heading_weight": "regular"
  }
}
GruppeSchlüsselWerte
Maßecontainer, pad, gapCSS-Längen als String, z. B. "1100px"
radiussm, md, lgCSS-Längen
colorsbg, text, muted, border, primary, secondary, primary_text, linkFarben, z. B. "#ea6b17"
typebody, h1 bis h5Schriftgrößen, z. B. "1rem"
fontsbody, headingSchriftfamilie: Inter, Lato, Merriweather, Montserrat, Nunito, OpenSans, PTSerif, PlayfairDisplay, Raleway, Roboto – oder leer für die Systemschrift
fontsbody_weight, heading_weightlight (300), regular (400), bold (700); PT Serif gibt es nur regular und bold

HTTP-Codes und Fehler

HTTPerrorWann
200Erfolg. ok: true heißt nicht automatisch „Inhalt geändert“ – data.content der Antwort prüfen.
201Angelegt: page_create, blog_create, media_upload.
400invalid_jsonBody ist kein JSON-Objekt (bei allen Aktionen, die JSON erwarten).
400missing_idpage_update, page_delete, blog_update, blog_delete ohne id.
400missing_pathmedia_delete ohne path.
400missing_file, upload_failed, invalid_filemedia_upload: kein Feld file, Upload abgebrochen oder zu groß, Datei ohne Namen oder Endung.
401missing_token, invalid_token, token_disabledToken fehlt, ist unbekannt oder gesperrt.
404page_not_found, post_not_foundID oder Slug unbekannt.
404partial_not_foundpart ist nicht header oder footer.
404media_not_foundDatei fehlt oder Pfad beginnt nicht mit /media/.
404unknown_actionAktion gibt es nicht.
405method_not_allowedFalsche Methode; der Header Allow nennt die richtige.
409media_in_useDatei ist eingebunden; Fundstellen in data.references.
415unsupported_filetype, mime_mismatchEndung nicht erlaubt bzw. Inhalt passt nicht zur Endung (details.ext, details.mime).
422invalid_page_schema, invalid_post_schema, invalid_partial_schemaDokument verletzt das Schema; details ist eine Liste.
422invalid_site_schema, invalid_styles_schemasite bzw. styles ungültig; details ist eine Liste.
422invalid_svgSVG ungültig oder nicht sicher bereinigbar (ohne details).
422„pages array fehlt“pages_import ohne pages.
500media_dir_create_failed, move_failed, delete_failedSchreibrechte in public/media/ prüfen. Andere Schreibfehler können ohne JSON-Antwort enden.
503System-Update läuft (Wartungsseite mit Retry-After). Nur version_check antwortet.

Typische details-Einträge: meta.title fehlt, doppelte id: t_intro, columns c_start items passen nicht zur spaltenzahl, image img_titel alt fehlt, form f_kontakt feld 0 name ungültig (nur a-z 0-9 _).

Was die API nicht kann

Typische Fallen

  1. Hülle vergessen: page_update ohne {"content": …} → 422; mit {"data": …}ok: true, aber nichts geändert.
  2. meta unvollständig: meta.image und meta.robots nicht mitgeschickt → gelöscht (bei Beiträgen fällt meta.image auf image zurück).
  3. Menü: page_create ohne "nav": {"show": false} → eine veröffentlichte Seite erscheint im Menü.
  4. Elternseite vertippt: nav.parent unbekannt → still null. data.page.nav.parent prüfen.
  5. Slug belegt: Das CMF hängt -2 an. Den Slug aus der Antwort verwenden, nicht den gesendeten.
  6. site_update mit Teilobjekt: 422 (name/lang fehlen) oder Verlust aller nicht mitgeschickten Felder wie og_image und csp.
  7. custom_css_update mit Ausschnitt: Der Rest des CSS ist weg; {} leert die Datei.
  8. HTML an der falschen Stelle: In heading.text, list.items und buttons.label erscheint HTML als Text; Tabellen oder Bilder im text-Block werden still entfernt.
  9. Bildmaße: width/height als 0 oder als Text wie "800px" → 422; nur eines gesetzt → wirkt nicht.
  10. IDs: Dieselbe ID in zwei Spalten → 422 doppelte id.
  11. Leere Seite: "blocks": [] ergibt einen Platzhalter, keine leere Seite.
  12. Kategorie: "technik" statt "Technik" → der Beitrag fehlt in der gefilterten Übersicht.
  13. Zwei Beschreibungen: description (Karte, Feed, llms.txt) und content.meta.description (Meta-Tag) sind getrennte Felder.
  14. pages_import: speichert auch fehlerhafte Inhalte.
  15. site_import: ok: true trotz abgelehnter Teile – data.rejected prüfen.
  16. Neue baseUrl: nach site_update sitemap_generate aufrufen.
  17. ok heißt nicht geändert: immer data.content der Antwort gegen das Gesendete prüfen.
Zu den Anleitungen Seiten per Maschine erstellen