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 (responsetext/html). - Maximum JSON body size: 16 MB.
- Timestamps are Unix seconds (
int), e.g."updated_at": 1791191665. - IDs are integers.
- On success,
DELETEresponds with204 No Contentwithout a body. POSTfor creating responds with201 Createdand the created object.PUTis a merge: only the fields present in the body are changed; missing fields keep their value. Fields such asidandcreated_atare ignored.- Concurrent editing:
PUTon pages, snippets, assets, global blocks, workflows and collection entries optionally accepts"base_updated_at"– theupdated_atof the version the client loaded. If someone has saved in the meantime, the server responds with409and{ "error": "…", "code": "common.conflict", "conflict": true, "updated_at": <aktueller Stand> }without changing anything. Without the field, the data is overwritten as before.POST /batchchecks several changes at once. - Lists are never
null, but at least[]. - Every response under
/api/v1carries the headerX-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" } }
erroris always in English and intended for humans (logs, scripts, fallback).codehas the formarea.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 acode(e.g. plain server errors) only haveerror.paramscontains the variable parts (names, slugs, fields, counts) for the placeholders of the translation.- The admin translates via the code: dictionary key
errors.<code>with theparams; unknown codes showerror. - If the message has a prefix (e.g.
item 3: field "Titel" is requiredwhen batch creating),codedescribes the actual cause. - Lists of messages (errors/warnings in the CSV import, log of the website import) also carry
code/paramsper entry; status objects (website import job, bundle transfer) haveerror_code/error_paramsalongsideerrorfor 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.