API reference

Pages

Pages form trees. Each root page (parent_id: null, path /) is a separate website; which one is served is decided by the requested domain (domains), otherwise the default website (default_site). Root pages cannot be moved; any page can be deleted, even the last root page (along with its pages). An instance without a website – which is how a new binary starts – returns 404 on every path. A page’s path is made up of the slugs of its ancestors: /produkte/schuhe/sneaker. Paths are only unique within a website – root_id tells you which website a page belongs to.

Page object

{
  "id": 2,
  "parent_id": 1,
  "slug": "blog",
  "title": "Blog",
  "html": "<main>{% for p in data(\"blog\") %}…{% endfor %}</main>",
  "head": "<meta property=\"og:title\" content=\"{{ page.title }}\">",
  "description": "Neuigkeiten",
  "meta_title": "{% if route.slug %}{{ item(\"blog\", \"slug\", route.slug).titel }} – {% endif %}Blog",
  "html_attrs": "",
  "lang": "de",
  "effective_lang": "de",
  "translation_group": "k3j9q2xw7m",
  "body_attrs": "class=\"blog\"",
  "published": true,
  "catch_all": true,
  "no_cache": false,
  "sitemap_exclude": false,
  "noindex": false,
  "nofollow": false,
  "hide_in_nav": false,
  "source_url": "https://www.firma.de/blog",
  "import_root": 1,
  "local_modified": false,
  "root_id": 1,
  "domains": "",
  "default_site": false,
  "notfound_page_id": 0,
  "favicon_file_id": 0,
  "og_image_file_id": 0,
  "robots_txt": "",
  "sort": 0,
  "created_at": 1791191665,
  "updated_at": 1791191700,
  "path": "/blog",
  "assets": [
    { "asset_id": 1, "inherit": true, "name": "base", "kind": "css" },
    { "asset_id": 4, "inherit": false, "name": "blog", "kind": "js" }
  ],
  "blocks": []
}
Field Type Description
parent_id int | null Parent page; null only for the home page
slug string URL segment: a-z, 0-9, ., _, -; must start with a letter/digit. Unique among siblings. Home page: always ""
title string Required; name of the page in the page tree, in nav()/children() and default for <title>
meta_title string Optional SEO title for <title>, evaluated as a template (e.g. article title on catch-all pages). Empty = title. Available in the template as page.meta_title
html string Page content as a pongo2 template. Inserted into <body>. If it contains an <html> element itself, it is used unchanged as the whole document, and CSS/JS are inserted before </head> and </body> respectively
head string Additional template for <head>
description string Output as <meta name="description">; template syntax allowed
html_attrs string Attributes of the <html> element, output raw (not a template). Without lang=, IllusionCMS adds lang="<effective_lang>"; an own lang= wins. Webflow imports set e.g. data-wf-page/data-wf-site here
lang string Own language of the page (BCP 47, normalized to lower case, e.g. de, en, de-at); empty = inherited from the nearest page above. PUT without the field = unchanged. Invalid: 400 translation.langInvalid. See language versions
effective_lang string Read-only: effective language (own, inherited or de) – page.lang in templates
translation_group string Read-only: translation group (empty = none); changed via /pages/{id}/translations
body_attrs string Attributes of the <body> element, output raw
published bool Unpublished pages (drafts) return 404 in the frontend (except for admins with ?preview=1). A draft hides only itself – its subpages remain reachable, e.g. a folder /blog without its own page above /blog/mein-artikel
catch_all bool The page also answers non-existent subpaths. The rest is available in the template as route.slug / route.rest – ideal for detail pages. If the entry doesn’t exist, {{ not_found() }} returns the 404 page
no_cache bool Never keep the page in the page cache, render it anew on every request
sitemap_exclude bool Don’t list the page in /sitemap.xml (e.g. thank-you pages, landing pages)
noindex bool Outputs <meta name="robots" content="noindex">; the page is also left out of /sitemap.xml
nofollow bool Outputs <meta name="robots" content="nofollow"> (together with noindex: noindex, nofollow)
sitemap_source object | null Catch-all only: source of the detail pages for /sitemap.xml and the static export. null/missing = automatic (collections via item(…, route.slug)). IllusionDB: { "type": "illusiondb", "connection": "shop", "table": "landingpages", "column": "slug", "lastmod": "geaendert", "view": "", "rules": [{ "col": "aktiv", "op": "eq", "val": "true" }], "match": "and" } (rules/op as in the workflow node, without placeholders; view = saved view as a filter). Workflow: { "type": "workflow", "path": "orte", "column": "slug", "lastmod": "" } – the GET input receives sitemap=1 and returns a list of objects or strings (or { "records": [...] }). column defaults to slug, values with / produce multi-level paths; lastmod optional (ISO date or Unix time). For IllusionDB, only these two columns are queried (?fields=). Invalid: 400
hide_in_nav bool The page is left out of the template functions nav()/children() (included with nav(true)/children(id, true)), but stays reachable and in the sitemap
source_url string Read-only: origin of imported pages
import_root int Read-only: target page of the import the page comes from
root_id int Read-only: website (root page) the page belongs to
renders_blocks bool Read-only, only in the response for a single page (GET/POST/PUT /pages/{id}): does the page’s template, including extends/include, output a block area ({% blocks page.blocks %})? Static analysis like has_blocks of the page layouts. The editor uses it for the hint in the Blocks tab
domains string Root pages only: domains of the website, one per line. Normalized (lowercase, without scheme/port/path); a domain belongs to at most one website (409)
default_site bool Root pages only: default website for all domains without their own website. Setting it to true removes the flag from other websites. Without a flag, the oldest website applies
notfound_page_id int Root pages only: page of this website that is rendered as the 404 page (0 = automatic: page with slug 404 directly below the home page). Must be a subpage of the same website, otherwise 400; ignored on subpages. If the page is a draft or deleted, the automatic choice applies
og_image_file_id int Preview image for link previews (og:image) – ID of an image file, 0 = that of the nearest page above. Every page automatically gets Open Graph and twitter:card tags (title, description, absolute URL via the website’s first domain or the request); tags already present in the head field/HTML are not output twice. Not an image file: 400
favicon_file_id int Root pages only: website icon – ID of an image file from file management (SVG, PNG, ICO …; 0 = none). Every page of the website gets <link rel="icon"> (large PNG/JPEG from 120 px additionally apple-touch-icon), /favicon.ico serves the file. Not an image file: 400; ignored on subpages
robots_txt string Root pages only: custom content for /robots.txt. Empty = automatic (User-agent: * / Allow: /). If a Sitemap: line is missing, it is added with the requested domain
local_modified bool Read-only: the page was edited in the CMS after the import (comparison of the content with the imported version; for pages never imported: edited at all)
sort int Position among siblings (set via /move)
path string Computed URL path (read-only)
assets array CSS/JS assignments in loading order (see below)
blocks array Page built from blocks (block tree), output in the template via {% blocks page.blocks %}. Without blocks []; missing in GET /pages (list without content). PUT without blocks leaves the tree unchanged, otherwise it is validated and normalized (400 with block.* codes). Versioned and part of the bundle

Reserved slugs at the top level (directly below the home page): cms-admin, api, media, _assets, sitemap.xml.

Asset assignments

Field Type Description
asset_id int ID of a CSS/JS asset
inherit bool true = also loaded on all subpages
name, kind string Read-only, for display

When rendering, the inherited assets of the ancestors (from the home page downward) are loaded first, then the page’s own; duplicates are removed. CSS goes into <head>, JS at the end of <body>.

GET /pages 🔑

All pages as a flat list, sorted by parent and sort – without html and head (for tree views). assets is included. You build the tree via parent_id.

With ?roots=1, only the root pages are returned, i.e. the websites.

GET /pages/{id} 🔑

A page including html, head and assets.

POST /pages ✏️

Creates a subpage (at the end of the sibling list).

{
  "parent_id": 1,
  "title": "Über uns",
  "slug": "ueber-uns",
  "html": "<main><h1>{{ page.title }}</h1></main>",
  "published": true,
  "assets": [{ "asset_id": 1, "inherit": true }]
}

Required: parent_id, title, slug. Response 201 with the complete page object.

With layout_id instead of html the page is created from a page layout: its HTML is then {% extends "<layout name>" %} (also in a batch under data). The server rejects layout_id together with a non-empty html (400 page.layoutWithHTML) and a snippet without the page layout flag (400 page.layoutUnknown).

Errors: 400 invalid/reserved slug, 404 parent page missing, 409 slug already taken among siblings.

PUT /pages/{id} ✏️

Merge update. If assets is included in the body, the asset assignments are completely replaced; if the field is missing, they remain unchanged. parent_id and sort are ignored here – use /move to move pages.

{ "title": "Über uns", "html": "<main>…</main>", "published": true }

Response 200 with the updated object.

DELETE /pages/{id} ✏️

Deletes the page including all subpages. For a root page, the entire website is deleted – even the last one. 204.

POST /sites ✏️

Creates a new website (root page with default HTML, published).

{ "title": "Shop", "domains": "shop.firma.de\nwww.shop.firma.de" }

Response 201 with the page object of the new root page. 409 if a domain already belongs to another website.

GET /sites/{id}/static 🔑

Exports the website {id} (root page) as a static website: finished HTML files with CSS, JS and media as a ZIP – to unpack into the document root of a web server such as Apache.

  • Pages: All published pages of the tree are rendered. In addition, there are the detail pages behind catch-all pages that also appear in the sitemap (collections or sitemap_source, e.g. thousands of landing pages from IllusionDB). The export also follows all internal links (<a href>), so other linked detail pages are included as well (e.g. /blog/mein-artikel). Each page becomes <pfad>/index.html (/ → index.html); paths with a file extension such as /feed.xml remain files. Apache then redirects /pfad to /pfad/.
  • Other files: 404.html (the website’s 404 page), .htaccess (ErrorDocument 404 /404.html, no directory listings, the website’s redirects as RedirectMatch – mod_alias is sufficient), robots.txt, all CSS/JS assets under _assets/…, sitemap.xml (if a base URL is known; with more than 50,000 addresses as an index with sitemap-1.xml, sitemap-2.xml, …) and the media under media/….
  • URLs remain unchanged (absolute paths from /) – the export belongs in the document root of the domain, not in a subfolder.
  • Query variants (?seite=2), forms posting to the CMS, /api/v1/… calls from JavaScript and REST data after the export don’t work statically; REST data is frozen at the state of the export.
Query Meaning
dry=1 Check only: returns the report as JSON instead of the ZIP
media=all All files from media management; default: only those that appear in the rendered pages or in CSS/JS (including srcset variants)
base Base URL for sitemap.xml, e.g. https://www.firma.de. Empty = first domain of the website with https://; without a domain, no sitemap

Report (dry=1):

{
  "pages": ["/", "/blog", "/blog/hallo", "/kontakt"],
  "errors": {},
  "broken_links": { "/kaputt": "/" },
  "media_files": 29, "media_bytes": 7900000, "missing_media": [],
  "asset_files": 11, "base": "https://www.firma.de", "too_many": false,
  "private_media": 0
}

errors: pages that cannot be rendered – in that case the export without dry returns 422 with { "error", "report" } instead of an incomplete ZIP. broken_links: internal links without a target (path → page containing the link). missing_media: files that are used but don’t exist. private_media: left-out files of private media (original, variants, WebP) – private files are always missing, also with media=all. Beyond 20,000 pages, link following stops (too_many).

PUT /pages/{id}/assets ✏️

Replaces only the asset assignments. The body is an array in loading order:

[
  { "asset_id": 1, "inherit": true },
  { "asset_id": 4, "inherit": false }
]

Response 200 with the page object.

POST /pages/{id}/move ✏️

Moves a page within the tree.

{ "parent_id": 5, "index": 0 }
Field Description
parent_id New parent page
index Position among the new siblings (0 = first). Invalid values (< 0 or too large) append to the end

Response 200: the complete page list as with GET /pages.

Errors: 400 moving the home page, moving into its own subtree, reserved slug at the top level; 409 slug already taken among the new siblings.

POST /pages/{id}/preview 🔑

Renders a page with unsaved content – used for the live preview in the admin. All fields are optional; missing ones are taken from the saved page.

{
  "html": "<h1>Entwurf</h1>",
  "head": "",
  "title": "Neuer Titel",
  "description": "",
  "assets": [{ "asset_id": 1, "inherit": true }],
  "overrides": { "1": "body { background: hotpink }" },
  "query": { "seite": "2" },
  "rest": ["mein-artikel"],
  "blocks": [{ "id": "b1", "type": "hero", "params": { "titel": "Ungespeichert" } }],
  "item": { "collection": "blog", "id": 12, "data": { "titel": "Ungespeichert", "slug": "mein-artikel" } }
}
Field Description
assets Unsaved asset assignments of this page
overrides Unsaved content of assets (asset_id → content)
query Simulated query parameters (route.query)
rest Simulated subpath for catch-all pages (route.rest)
blocks Unsaved block tree of the page (page.blocks) – not validated; unknown block types appear as a notice box
item Unsaved collection entry: replaces the entry id (or is added as new with id: 0) in data()/item() – for the preview while editing an entry on its detail page. Blocks fields in it are rendered like stored ones

In the preview, all CSS/JS assets are output inline, and unpublished collection entries are taken into account as well; blocks carry preview markers. Addresses of private files from image()/file() (also the site icon and og:image) are signed (?exp=…&sig=…, valid for one to two hours) so that they load in the iframe without cookies – likewise in preview links and with ?preview=1, never on live pages.

Response: 200 text/html with the finished document; on template errors 422 text/html with an error page. If the template calls not_found(), the response is 200 with a notice (for catch-all pages with existing subpaths).

POST /pages/{id}/sitemap-preview ✏️

Checks a (possibly unsaved) sitemap source of the page: body { "sitemap_source": { … } }, response { "count": 3920, "sample": [{ "path": "/leistungen/berlin", "lastmod": "2026-09-02" }, …], "duration_ms": 46 } (the first 10). Errors from the source (IllusionDB unreachable, workflow missing): 502. Does not clear the page cache.

POST /pages/{id}/template-data 🔑

Returns what a template of the page sees – for the Template data panel in the editor. Optionally with unsaved title, meta_title, description as well as query and rest as in the preview; computed as in the preview (drafts visible). Does not clear the page cache.

{
  "variables": { "page": { "id": 2, "title": "Blog", "meta_title": "Blog | Firma", … }, "route": { … }, "site": { … }, "today": "2026-10-08", "now": "2026-10-08T14:03:00+02:00", "preview": true },
  "nav": [{ "id": 2, "title": "Blog", "path": "/blog", … }],
  "children": [],
  "collections": [{ "slug": "blog", "name": "Blog", "fields": [{ "name": "titel", "type": "text" }], "count": 42, "items": [{ "id": 1, "titel": "…", "_created": 1791457748, "_updated": 1791457748 }] }],
  "sources": [{ "slug": "wetter", "name": "Wetter", "url": "https://…" }]
}

variables contains only values, no functions. items shows the first 20 entries as data("slug") returns them, count the total number. REST sources are not fetched (the admin fetches them on click via GET /data/{slug}). Response 422 on errors in the SEO title or the description.

POST /pages/{id}/reimport ✏️

“Update from source”: reloads an imported page from source_url – with the download hosts of the last import of this source into its website (without a saved profile: derived from the hosts of the files already imported from the source). The page is written even if the source hasn’t changed; files and CSS/JS are reused as in a re-import.

{ "overwrite_modified": true }

overwrite_modified is required if the page was edited in the CMS (local_modified: true) – otherwise it stays unchanged. Response 202 with an import job.

GET /pages/{id}/render 🔑

Renders the saved page and returns the HTML as JSON – handy for headless scenarios or static exports. Query parameters of the request are passed through as route.query.

{ "html": "<!doctype html>…", "path": "/blog" }

422 with { "error": "HTML: … line 3 …" } on template errors.

GET /pages/{id}/preview-links – valid links of the page. POST /pages/{id}/preview-links with { "days": 7 } (1–90, default 7) creates one (201). DELETE /preview-links/{id} revokes it (204).

{ "id": 3, "page_id": 12, "token": "9faf061b27c6…", "author": "erik", "created_at": 1791368754, "expires_at": 1791973554 }

Usage: <Seitenpfad>?ic_preview=<token> – renders the link’s page or one below it (including catch-all details) with drafts (pages and collection entries), without login, Cache-Control: no-store, X-Robots-Tag: noindex, nofollow, without statistics. The website is determined by the link, not by the domain. Invalid, expired or another page: 404. Links belong to the instance (not in the bundle).

GET /pages/{id}/mhtml 🔑

The saved page as an MHTML archive (multipart/related, downloaded as <slug>-<datum>.mhtml) – page plus stylesheets, images, fonts and icons in a single file, including from third-party hosts. Scripts are not packed (browsers don’t run them in MHTML); srcset/sizes/loading and <picture><source> are removed. The references in the HTML remain unchanged; they are resolved via the Content-Location of the parts – the base is the website’s first domain (https://…), otherwise the address of the request.

Limits: 25 MB per file, 250 MB in total, 1,000 parts. Anything that doesn’t fit or can’t be fetched is missing from the archive. The response headers X-IC-MHTML-Parts and X-IC-MHTML-Skipped give the number of packed and skipped files.

curl -OJ "https://example.com/api/v1/pages/12/mhtml?token=$TOKEN"

422 on template errors.

Language versions

Multilingual websites consist of separate subtrees (/en/…) or separate websites (firma.de ↔ firma.com). To tell search engines and language switchers which pages contain the same content in another language, you link them into a translation group.

  • Language of a page: field lang (own language, BCP 47, e.g. de, en, de-at; stored in lower case, invalid → 400 translation.langInvalid). The effective language effective_lang is the page’s own language or that of the nearest page above with lang, otherwise de. It is written to <html lang="…"> (unless html_attrs sets lang= itself) and available in templates as page.lang.
  • Group rules: a group contains only pages or only entries, at most one member per language (for pages: the effective language). Pages of different websites may share a group. Changing the language or moving a page in a way that would duplicate a language in a group (of the page or a subpage) returns 409 translation.langTaken; so does restoring a version with a different language.
  • Output: if the rendered page has a group with at least two published members, IllusionCMS writes <link rel="alternate" hreflang="<language>" href="<absolute URL>"> per member into the <head> (including the page itself) plus hreflang="x-default" for the member in the language of its own website if all members are in the same website (language subtrees), otherwise in the language of the default website. Absolute URLs use the first domain of the respective website, for the page’s own website the request as a fallback (as with Open Graph); members without an absolute URL are left out. If the head field or HTML already contains a <link … hreflang>, the automatic output is skipped. Catch-all detail pages use the group of the entry shown (detected via item(…, route.slug) or the collection’s detail page). /sitemap.xml adds the same information as <xhtml:link> (also in the sitemap index and the static export).
  • translation_group is read-only on pages and entries; PUT ignores it. New pages/entries (also from CSV import) have no group. Groups are included in bundles. Deleting cleans up: groups with only one member left are cleared.

Language version object (response of all endpoints, sorted by language code, including the requested object with current: true):

[
  { "id": 3, "lang": "de", "title": "Kontakt", "path": "/kontakt", "site_id": 1, "url": "/kontakt", "published": true, "current": true },
  { "id": 9, "lang": "en", "title": "Contact", "path": "/contact", "site_id": 4, "url": "https://www.company.com/contact", "published": true, "current": false }
]
Field Description
lang Effective language of the page or language of the collection
path, site_id Path within the website and website (root page); for entries the collection’s detail page, without a detail page ""/0
url Absolute via the website’s first domain, without a domain = path
collection Entries only: slug of the collection
Endpoint Access Description
GET /pages/{id}/translations 🔑 Language versions of the page (without a group only the page itself)
POST /pages/{id}/translations ✏️ Body { "page_id": 9 } – adds page 9 to the group of {id} (creating it if needed). With "move": true, page 9 leaves its previous group. Response 200 with the list
DELETE /pages/{id}/translations ✏️ Removes the page from its group (if only one member is left, its group is cleared as well). Response 200 with the list
Code Status Meaning
translation.langInvalid 400 Invalid language code (lang)
translation.targetRequired 400 page_id or item_id missing
translation.sameLang 400 Same language as the object itself (lang)
translation.alreadyLinked 409 Target is already in another group (title) – move it with "move": true
translation.langTaken 409 The group already has a member in this language (lang)
translation.noLang 400 Entries: collection without a language (collection)
translation.collectionLinked 409 Removing the language of a collection whose entries are linked (count = groups)