AsentioCMS v4 — REST API Reference

Audience: automated agents (LLMs, scripts, integrations) and developers. Base URL: https:///api Format: application/json for request bodies and responses. Auth: Bearer token in Authorization header. 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() → live
  • published_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

[---]

\n

Below the fold.

", "category_id": 3, "meta_title": null, "meta_description": null, "published_at": 1735689600 }
  • body is sanitized server-side via HtmlSanitizer (article profile).
  • 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.
  • slug must 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_typeglobal | 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 extend ApiController if 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

[---]

\n

More 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/route require an id.
  • Placement — slot name (header, main, sidebar, footer, …). Defined per theme (see theme.json#supports.slots).
  • Layout version — monotonically increasing integer per scope. Sent with every POST /blocks/tree for 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 (article profile). 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