Every save appends an immutable snapshot of the whole entry. Nothing to switch on — every content type has history.
Reading history
curl 'https://cms.example.com/api/content/article/0b9e…/revisions?page=1&pageSize=20' \
-b 'orthacms_session=…' -H 'X-Workspace-Id: 7d0c…'{
"items": [
{ "id": "…", "number": 5, "status": "draft", "isPublished": false, "isLatest": true,
"createdAt": "2026-09-29T10:12:00.000Z", "authorId": "…" },
{ "id": "…", "number": 4, "status": "published", "isPublished": true, "isLatest": false,
"createdAt": "2026-09-28T08:40:00.000Z", "publishedAt": "2026-09-28T08:41:03.000Z", "authorId": "…" }
],
"total": 5
}One version, with its snapshot and resolved relation references:
curl https://cms.example.com/api/content/article/0b9e…/revisions/4 \
-b 'orthacms_session=…' -H 'X-Workspace-Id: 7d0c…'{
"id": "…",
"number": 4,
"status": "published",
"isPublished": true,
"isLatest": false,
"createdAt": "2026-09-28T08:40:00.000Z",
"snapshot": {
"values": { "title": "Hello world", "author": "a7f2…" },
"relations": { "tags": ["9c4b…", "77aa…"] }
},
"relationRefs": {
"tags": [
{ "id": "9c4b…", "title": "Design", "slug": "design" },
{ "id": "77aa…", "title": "Research", "slug": "research" }
]
},
"relationTotals": { "tags": 2 }
}History is admin API only. Newest first; pageSize defaults to 20, max 100.
Endpoints
| Route | Permission | Returns |
|---|---|---|
GET /api/content/:type/:id/revisions | content:read | The timeline, { items, total }. |
GET /api/content/:type/:id/revisions/:number | content:read | One snapshot, with resolved relation and media refs. |
POST /api/content/:type/:id/revisions/:number/restore | content:update | Re-applies it as a new version. Returns the entry. |
POST /api/content/:type/:id/revisions/:number/publish | content:publish | Makes that version live. Returns the entry. |
What a snapshot holds
One table, content_entry_revisions, shared by every type. A snapshot is
{ values, relations, extra? }:
values— the whole values bag: scalars, single-relation FKs, media values.relations— the full ordered link set of every join-backed relation, so a reorder is a recorded change.extra— state other plugins keep about the entry, such as the segments access lists. A restore hands it back. A version written before such a plugin was installed has noextra, meaning “unknown”, not “none”.
Revisions are per locale — each translation has its own timeline. Every
read is keyed on (content_type, entry_id, workspace_id).
When a revision is written
- Inside the save’s transaction, atomically with the row and its links.
- The entry’s advisory lock serialises concurrent saves, so version numbers never collide.
- The live row still edits in place; history only appends.
Version status
| Status | Meaning |
|---|---|
draft | Written by a save, never published. |
published | The live version. At most one per entry. |
superseded | Was live; another version replaced it. |
- Publishing the entry marks its latest revision
publishedand demotes the previous one tosuperseded, in the same transaction. - Saving a published entry makes it Modified: the row returns to
draft, thepublishedrevision keeps its mark. - The public API serves that
publishedrevision while the entry is Modified — REST, GraphQL and MCP alike (since 0.7.0). Before 0.7.0 a Modified entry vanished from public reads until it was published again. - Unpublishing returns the live version to
draft.
Restoring versus publishing a version
| Restore | Publish a version | |
|---|---|---|
| Route | …/revisions/:number/restore | …/revisions/:number/publish |
| New version? | Yes — restoring v2 appends v6 equal to v2 | No — v2 itself is marked live |
| Live row | Updated through the normal save path | Re-applied from v2 if it is older |
| Goes live? | No, it is a draft edit | Yes, after the publish gate |
Publishing a version re-runs the publish gate. On a 422 the content stays re-applied as a draft — recoverable, not half-done.
After publishing an old version, latest is not live
Publish v2 while v5 exists: v5 is the latest, never-published draft; v2 is
live. isLatest and isPublished point at different versions until the next
save appends one equal to the live row.
Diffing in the admin
The entry’s History tab lists versions newest first, grouped by day,
marking Live and the Current draft, with the record’s activity events
between them. A selected version is compared with the current draft beside the
list; previewing one compares it field by field with the current version, and
unchanged fields fold behind “Show n unchanged”. Empty values do not show as "" →
null changes, and link sets compare in order.
A relation target that has since been soft-deleted, or is no longer visible
from this workspace, is flagged missing instead of leaking its title.
No retention policy, and no pruning
Every save keeps a full snapshot and nothing expires them. A permanent delete
leaves them in place; they go only with the workspace. On wide types with large
rich-text bodies, content_entry_revisions is the table that grows fastest.