Audience: automated agents (LLMs, scripts, integrations) and developers. Base URL:
https://Format:/api application/jsonfor request bodies and responses. Auth: Bearer token inAuthorizationheader. Response envelope: success →{"data": ..., "meta": {...}?}; error →{"error": "...", "message"?: "...", "errors"?: {...}}.
1. Authentication
All endpoints under /api/* require a token issued from
the admin UI (Admin → Ustawienia → API tokens). Tokens
are hashed in DB and shown only once on creation.
Authorization: Bearer
Missing / invalid / revoked / expired tokens →
401 Unauthorized.
GET /api/me HTTP/1.1
Authorization: Bearer 9f4e...
200 OK
{ "data": { "id": 1, "email": "admin@example.com", "role": "admin" } }
2. HTTP semantics
| Code | Meaning |
|---|---|
| 200 | OK — {"data": ...} returned |
| 201 | Created — resource created, full body returned |
| 204 | No Content — for DELETEs that succeed |
| 400 | Invalid request body / malformed query |
| 401 | Missing / bad token |
| 403 | CSRF / permission failure (rare on API; mostly admin) |
| 404 | Resource not found |
| 409 | Optimistic-lock conflict (block-tree saves) |
| 422 | Validation failed →
{"error":"...","errors":{"field":[...]}} |
| 500 | Server error |
Validation errors example:
{
"error": "Validation failed",
"errors": {
"title": ["required"],
"slug": ["already exists in this category"]
}
}
3. Resources at a glance
| Verb | Path | Section |
|---|---|---|
| GET | /me |
§4 |
| GET | /articles |
§5 |
| GET | /articles/{id} |
§5 |
| POST | /articles |
§5 |
| PUT | /articles/{id} |
§5 |
| DELETE | /articles/{id} |
§5 |
| GET | /categories |
§6 |
| GET | /categories/{id} |
§6 |
| POST | /categories |
§6 |
| GET | /blocks |
§7 |
| GET | /blocks/{id} |
§7 |
| GET | /blocks/types |
§8 |
| GET | /blocks/tree |
§9 |
| POST | /blocks/tree |
§9 |
| GET | /blocks/sources |
§10 |
| POST | /blocks/copy |
§10 |
| GET | /blocks/snapshots |
§11 |
| POST | /blocks/restore/{snapshotId} |
§11 |
| GET | /media |
§12 |
4. Current user
GET /api/me
{ "data": { "id": 1, "email": "admin@example.com", "role": "admin" } }
5. Articles
Public visibility is driven only by
published_at:
published_at = null→ draft (never visible publicly)published_at <= NOW()→ livepublished_at > NOW()→ scheduled
The legacy status column is still present and accepted
on writes, but does not affect public visibility.
GET /api/articles
Query params (all optional):
| Param | Type | Notes |
|---|---|---|
status |
string | draft / scheduled / published
(mapped to published_at conditions) |
category_id |
int | Filter by category |
q |
string | LIKE search on title/subtitle/excerpt/body |
page |
int | 1-based |
per_page |
int | default 25, max 100 |
{
"data": [
{
"id": 12, "slug": "hello", "title": "Hello",
"category_id": 3, "category_name": "News", "category_slug": "news",
"status": "published", "published_at": 1735689600,
"updated_at": 1735691000
}
],
"meta": { "page": 1, "per_page": 25, "total": 1 }
}
GET /api/articles/{id}
Full article including body (sanitized HTML).
POST /api/articles
{
"title": "Hello",
"slug": "hello",
"subtitle": "An optional lede",
"body": "Body in HTML…
\n[---]
\nBelow the fold.
",
"category_id": 3,
"meta_title": null,
"meta_description": null,
"published_at": 1735689600
}
bodyis sanitized server-side viaHtmlSanitizer(articleprofile).- Excerpt is auto-derived from body. Insert
[---]on its own line to pick the boundary; the marker is stripped from the public body. Without a marker, the first 200 chars (cut on the last space) become the excerpt. slugmust be unique per category ((category_id, slug)).
201 Created returns the same shape as
GET /articles/{id}.
PUT /api/articles/{id}
Same body, partial allowed. Only fields that are present are updated.
DELETE /api/articles/{id}
Soft delete. Returns 204 No Content.
6. Categories
GET /api/categories
Returns the full tree (flat list with _depth for
indentation):
{ "data": [
{ "id": 1, "name": "News", "slug": "news", "parent_id": null, "_depth": 0 },
{ "id": 4, "name": "Tech", "slug": "tech", "parent_id": 1, "_depth": 1 }
] }
GET /api/categories/{id}
Full category record.
POST /api/categories
{ "name": "Reviews", "slug": "reviews", "parent_id": null, "description": "" }
7. Blocks (flat list)
Useful when you only need raw rows, not the tree.
GET /api/blocks
Query: scope_type, scope_id,
placement (any combination). Returns up to 500 rows ordered
by scope/placement/parent/position.
{
"data": [
{
"id": 42, "parent_id": null,
"type": "container", "name": null, "position": 0,
"placement": "main", "scope_type": "home", "scope_id": null,
"config": { "layout": "grid", "columns": 3 },
"status": "published"
}
]
}
GET /api/blocks/{id}
Returns the full row including body /
config.
8. Block types
GET /api/blocks/types
Discover registered block types and their schemas — use this to build a dynamic UI or to validate your tree before submitting.
{
"data": [
{
"type": "container",
"name": "Container",
"accepts_children": true,
"allowed_children": ["html", "container", "content", "menu", "text"],
"default_config": { "layout": "stack", "columns": 2 },
"schema": [
{ "key": "layout", "type": "select", "options": ["stack","grid","flex"] },
{ "key": "columns", "type": "number", "min": 1, "max": 6 }
],
"config_version": 1
}
]
}
9. Block tree
Tree node shape
A “tree” is a JSON array of root nodes. Each node:
{
"id": 42, // optional on input; ignored on save (regenerated)
"type": "container", // required, must exist in /blocks/types
"name": "Hero", // optional, max 128 chars
"position": 0, // optional, recomputed by index in array
"placement": "main", // root nodes only, must match request placement
"config": { "layout": "grid", "columns": 3 },
"children": [
{ "type": "html", "config": { "html": "Hi
" } }
]
}
GET /api/blocks/tree?scope_type=&scope_id=&placement=
scope_type ∈
global | home | category | article | route.
scope_id is required for
category/article/route and
forbidden for global/home.
{
"data": [ /* root nodes with nested children */ ],
"meta": {
"scope_type": "home", "scope_id": null,
"placement": "main", "layout_version": 7,
"count": 4
}
}
POST /api/blocks/tree
Replaces a whole slot transactionally with optimistic-lock.
{
"scope": { "type": "home", "id": null },
"placement": "main",
"layout_version": 7,
"tree": [
{
"type": "container",
"config": { "layout": "grid", "columns": 3 },
"children": [
{ "type": "html", "config": { "html": "Cell 1
" } },
{ "type": "html", "config": { "html": "Cell 2
" } },
{ "type": "html", "config": { "html": "Cell 3
" } }
]
}
]
}
Success:
{
"data": { "layout_version": 8 },
"meta": { "scope_type": "home", "scope_id": null, "placement": "main" }
}
Conflict (409) — server detected a
newer version since you last read the tree. Re-fetch
GET /blocks/tree, merge your changes, retry:
{
"error": "conflict",
"message": "Layout was changed by another client. Reload tree and retry.",
"server_layout_version": 9
}
Validation (422) — one or more nodes
failed TreeValidator (unknown type, child not allowed under
parent, depth exceeded, etc.):
{ "error": "Validation failed", "errors": { "0.children.2.type": ["unknown block type 'foo'"] } }
max_depth defaults to 4 (configurable in
config/blocks.php).
Note on versioning: the optimistic lock compares against the scope-level MAX(layout_version), not per-placement, so a save in any slot of the scope bumps the version everywhere — re-fetch before retrying.
10. Cross-scope copy
GET /api/blocks/sources?placement=
List scopes that currently have content in the given placement. Useful when populating a “Copy from” picker.
{
"data": [
{ "scope_type": "global", "scope_id": null, "placement": "header", "cnt": 3 },
{ "scope_type": "category", "scope_id": 5, "placement": "header", "cnt": 2 }
]
}
POST /api/blocks/copy
Copy a slot tree from one scope into another. Source IDs are stripped so the target receives fresh inserts. The target slot is replaced (snapshot of the previous state is stored automatically — see §11).
{
"from_scope": { "type": "global", "id": null },
"to_scope": { "type": "category", "id": 5 },
"placement": "header"
}
{
"data": { "layout_version": 12, "copied_roots": 3 },
"meta": { "placement": "header", "to_scope": { "type": "category", "id": 5 } }
}
404 if source slot is empty.
11. Snapshots
Every destructive write (save tree, copy, restore) auto-creates a snapshot of the previous slot state. Use this for diff/history/restore flows.
GET /api/blocks/snapshots
Filters: scope_type, scope_id,
placement, limit (default 30, max 100).
{
"data": [
{
"id": 117, "scope_type": "home", "scope_id": null, "placement": "main",
"layout_version": 7, "snapshot_kind": "auto",
"note": "before save", "tree_hash": "9c4e…",
"created_at": 1735690000, "created_by": 1
}
]
}
POST /api/blocks/restore/{snapshotId}
Restores the slot to the snapshot’s tree. Strips IDs, re-validates
against the registry, replaces the current slot, bumps version.
Auto-snapshots the “about-to-be-overwritten” state with note
"before api restore from #.
{
"data": { "restored_from": 117, "layout_version": 13 },
"meta": { "scope_type": "home", "scope_id": null, "placement": "main" }
}
12. Media
GET /api/media
Returns up to 200 latest non-deleted media records.
{ "data": [
{
"id": 88, "folder_id": null, "filename": "photo_a1b2.jpg",
"original_name": "photo.jpg", "mime_type": "image/jpeg",
"size_bytes": 124321, "width": 1920, "height": 1280,
"alt_text": "", "caption": "",
"uploaded_at": 1735689600
}
] }
Upload, update, trash and folder management are not exposed on the public REST API yet — use the admin endpoints (
/admin/media/*) with a session, or extendApiControllerif you need machine access.
13. Quick recipes (LLM-friendly)
Replace a slot in one shot
TOKEN=...
curl -sS https://example.com/api/blocks/tree?scope_type=home&placement=main \
-H "Authorization: Bearer $TOKEN" \
| jq '{scope:{type:"home",id:null}, placement:"main",
layout_version: .meta.layout_version,
tree:[ {type:"html",config:{html:"Replaced
"}} ] }' \
| curl -sS -X POST https://example.com/api/blocks/tree \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d @-
Copy global header into category 5
curl -X POST https://example.com/api/blocks/copy \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "from_scope":{"type":"global"},
"to_scope":{"type":"category","id":5},
"placement":"header" }'
Roll back a slot to last good snapshot
SID=$(curl -sS "https://example.com/api/blocks/snapshots?scope_type=home&placement=main&limit=1" \
-H "Authorization: Bearer $TOKEN" | jq '.data[0].id')
curl -X POST "https://example.com/api/blocks/restore/$SID" -H "Authorization: Bearer $TOKEN"
Create a draft article (no publish date)
curl -X POST https://example.com/api/articles \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "title":"Draft", "slug":"draft",
"body":"Intro.
\n[---]
\nMore body.
",
"category_id":3 }'
# published_at omitted ⇒ draft (invisible publicly)
14. Concepts cheat-sheet
- Scope — one of
global | home | category | article | route. Identifies where a block tree applies.category/article/routerequire anid. - Placement — slot name (
header,main,sidebar,footer, …). Defined per theme (seetheme.json#supports.slots). - Layout version — monotonically increasing integer
per scope. Sent with every
POST /blocks/treefor optimistic locking. - Block tree — JSON array of root nodes; each node
may have
children. Position is implicit (array index). Max depth defaults to 4. - Snapshot — frozen copy of a slot tree taken automatically before destructive writes. Restorable.
- Sanitizer — article body is run through
HtmlSanitizer(articleprofile). Block configs are stored as JSON without sanitization — the block type is responsible for safe rendering.
15. Versioning
This API is v4.x. There is no URL prefix yet — the
/api namespace is the contract. Breaking changes will be
communicated in the changelog and a versioned prefix
(/api/v5/...) will be introduced when needed.
MafiaAI / t8.pl — AsentioCMS is our content management system. Integration and deployment questions: t8.pl