API reference

Basics

Authentication

There are two ways to authenticate:

Method For whom How
API token External clients, scripts, sync jobs, headless frontends Header Authorization: Bearer ic_… or, for GET, ?token=ic_…
Session cookie The admin app in the browser Cookie ic_session after POST /auth/login

API tokens are created in the admin under Settings → API tokens or via POST /tokens. The plain-text token is shown only once, when it is created; only a SHA-256 hash is stored.

curl -H "Authorization: Bearer ic_Xy…" https://example.com/api/v1/pages

For simple GET requests (browser address bar, tools without header support), the token can also be passed as a URL parameter:

curl "https://example.com/api/v1/collections/faq/items?token=ic_Xy…"

?token= only applies to GET/HEAD – write requests need the header. Note: URLs end up in browser history and proxy/web server logs; it’s best to use a read-only token for this. A token in the URL also authenticates cross-origin requests: for public collections, a third-party frontend using ?token= will then receive drafts as well.

If an Authorization: Bearer header is sent, only that header counts: if the token is invalid, the request is treated as unauthenticated (protected endpoints → 401), even if a valid cookie is sent along with it.

Session cookies are HttpOnly and SameSite=Lax and are valid for 14 days. The startup option -secure-cookies (or IC_SECURE_COOKIES=1) additionally sets the Secure flag – be sure to enable it behind HTTPS.

CSRF protection

All write requests (POST, PUT, DELETE) authenticated via a session cookie must send the header

X-IC-CSRF: 1

(the value is arbitrary, it just must not be empty). If it is missing, the server responds with 403 missing X-IC-CSRF header. Browsers only send custom headers cross-origin after a CORS preflight, which the server does not answer – this protects cookie sessions against CSRF.

Requests with an API token don’t need this header.

Permissions

Every endpoint has one of the following access levels:

Level Who is allowed In this documentation
public anyone, without login 🌐
read admin session or any API token 🔑
write admin session or API token with can_write ✏️
session only logged-in admin in the browser (no token) 👤

Read-only tokens that call a write endpoint receive 403 token is read-only. This also applies to non-public workflow HTTP inputs (/flows/… with POST/PUT/PATCH/DELETE; GET stays allowed), the preview endpoints (POST /pages/{id}/preview, POST /pens/{uuid}/preview), and runs and versions of workflows and custom nodes. GET /sources returns header names without values to read-only tokens. Public HTTP inputs only accept X-IC-Webhook-Hop together with an X-IC-Delivery ID of one of the instance’s own deliveries; their error response to anonymous callers is 500 with workflow.failed and no details. Non-public GET inputs are rejected when called with a session cookie and Sec-Fetch-Site: cross-site|same-site.

Collections can additionally be marked as publicly readable (public_api: true). Their read endpoints are then reachable without authentication, but only return published entries (see Collections).

Formats & conventions

  • Request and response bodies are JSON (Content-Type: application/json), except for file uploads (multipart/form-data) and the page preview (response text/html).
  • Maximum JSON body size: 16 MB.
  • Timestamps are Unix seconds (int), e.g. "updated_at": 1791191665.
  • IDs are integers.
  • On success, DELETE responds with 204 No Content without a body.
  • POST for creating responds with 201 Created and the created object.
  • PUT is a merge: only the fields present in the body are changed; missing fields keep their value. Fields such as id and created_at are ignored.
  • Concurrent editing: PUT on pages, snippets, assets, global blocks, workflows and collection entries optionally accepts "base_updated_at" – the updated_at of the version the client loaded. If someone has saved in the meantime, the server responds with 409 and { "error": "…", "code": "common.conflict", "conflict": true, "updated_at": <aktueller Stand> } without changing anything. Without the field, the data is overwritten as before. POST /batch checks several changes at once.
  • Lists are never null, but at least [].
  • Every response under /api/v1 carries the header X-IC-Build – the build ID of the embedded admin app (also on errors and without login). The admin compares it with the ID it was loaded with and offers to reload after a server update.

Errors

Errors are always returned as JSON with an error field, most of them also with a machine-readable code and – if the message has variable parts – params:

{ "error": "slug 'api' is reserved", "code": "page.slugReserved", "params": { "slug": "api" } }
  • error is always in English and intended for humans (logs, scripts, fallback).
  • code has the form area.name (e.g. auth.invalidCredentials, page.slugTaken, workflow.unknownNodeType, item.fieldRequired) and is stable – codes are never renamed; clients should react to them (not to the text). Errors without a code (e.g. plain server errors) only have error.
  • params contains the variable parts (names, slugs, fields, counts) for the placeholders of the translation.
  • The admin translates via the code: dictionary key errors.<code> with the params; unknown codes show error.
  • If the message has a prefix (e.g. item 3: field "Titel" is required when batch creating), code describes the actual cause.
  • Lists of messages (errors/warnings in the CSV import, log of the website import) also carry code/params per entry; status objects (website import job, bundle transfer) have error_code/error_params alongside error for this purpose.
  • Runtime errors from workflows (function code, template syntax with line/column) are plain text without a code.
Status Meaning
400 Bad Request Invalid JSON, validation error (slug, required field, type …)
401 Unauthorized Not authenticated / invalid token / wrong credentials
403 Forbidden Read-only token on write access, missing CSRF header, session-only endpoint
404 Not Found Object does not exist (or is not visible to you)
405 Method Not Allowed Method not supported for this path
409 Conflict Name/slug already taken, setup already completed
422 Unprocessable Entity Template error during rendering
429 Too Many Requests Rate limit exceeded (with Retry-After) or too many failed logins
500 Internal Server Error Unexpected error (details in the server log)
502 Bad Gateway A REST data source was unreachable or returned an error

Rate limit

All requests under /api/ – including the HTTP inputs of workflows – are limited per client IP: without login 120 per minute, with a token or admin session ten times that; a short reserve (half of it) absorbs spikes. Above that, the server responds with 429 and Retry-After (seconds). Calls that workflows make to the API internally don’t count. Web pages, media and CSS/JS are not limited (they usually come from the cache).

The limit is set with -rate-limit or IC_RATE_LIMIT (requests per minute without login, 0 = off).

Behind a reverse proxy (Apache, Caddy, Traefik, Docker), the client IP is the last entry in X-Forwarded-For – but only if the connection comes from a local or private address. The proxy itself appends this entry, so visitors cannot forge it. This also applies to the lockout after ten failed logins.