Reference
Versions and History
Every publish leaves behind a Published Version — a stamped RavenDB revision of the document. This is the row model, the three endpoints, and the locale-selective restore that both versions and snapshots share.
Strife keeps one Published Version for every publish of a content document: a RavenDB revision of the canonical document, stamped with who published it and when. Together with the current draft, any scheduled publish and any active Snapshot, Published Versions make up History — the per-document timeline the Studio shows in its History drawer.
This page documents the version model and the three endpoints behind it. If you are looking for the customer-site snapshot contract (the /snapshot/{token} route, the content-index rows, the SDK middleware), see Snapshots — versions and snapshots are restored through the same mechanism, and History can freeze a version into a snapshot for review (see Creating a snapshot from a published version), but a version itself has no URL and nothing about it is served on your site.
The stamp
Publishing a document — a batch publish, a scheduled publish, an MCP publish_content call, or the CLI seed that creates a published canonical directly — writes two fields onto the canonical document:
| Field | Value |
|---|---|
__publishedBy | The id of the user who published (the scheduling user for a scheduled publish, the CLI user for a seed) |
__publishedAt | UTC round-trip timestamp ( "o" format) of the publish |
Immediately after that write is saved, Strife forces a RavenDB revision of the document — one per publish, holding the exact state that just went live, stamp included. A Published Version is a revision that carries this stamp. No revisions configuration is enabled on team databases; the stamped revision exists only because publish forces it.
Two things follow:
- Only publishes create versions. Archive, soft-delete, unpublish (the restore-to-draft transition), copy, and the hand-crafted
POST /content/{activeTeam}all write the canonical but stamp nothing and force no revision — they never appear as rows. So the newest RavenDB revision is not always the live canonical: a document can be written after its last publish without a new version existing for that write. - The stamp never reaches a draft, scheduled sibling, or snapshot. It is stripped when a draft is created or refreshed from a published canonical, protected from JSON Patch (alongside
__dirtyLocales), and stripped from an incoming documentPOSTbody and after acopy. A stamp exists only on a published canonical and its revisions — never on a working copy, and never one a client could forge.
The three endpoints
All three live under /content, require team membership and the EditContent capability, and take {activeTeam} from the route — never the ActiveTeam claim. None are gated by a Studio feature flag; the History drawer's flags (below) only control whether the Studio renders the drawer.
GET /content/versions/{activeTeam}
The published rows for a canonical, newest first, paged.
| Query param | Required | Notes |
|---|---|---|
id | yes | The canonical document id. A /draft, /scheduled or /snapshot/{token} id is rejected. |
start | no | Raw revision offset to page from. Default 0. |
pageSize | no | Default 25, capped at 100. |
GET /content/versions/my-team?id=blog-post-1&start=0&pageSize=25{
"rows": [
{
"version": "AAAAAAAAxA0BAAAAAAAAAAI",
"publishedAt": "2026-09-08T14:03:21.0000000Z",
"publishedBy": "user-42",
"locales": ["en", "sv"],
"status": "published"
},
{
"version": "AAAAAAAAwQ0BAAAAAAAAAAE",
"publishedAt": "2026-09-01T09:12:44.0000000Z",
"publishedBy": "user-7",
"locales": ["en"],
"status": "published"
}
],
"hasMore": true,
"nextStart": 25
}version is an opaque reference — a base64url encoding of the revision's RavenDB change vector, which is not path-safe on its own. Treat it as a token: pass it back verbatim to the body endpoint or to apply-version; never parse or construct one client-side.
nextStart is the raw revision offset consumed, not the number of rows returned — a page that skips an unstamped revision (there should never be one, but the read is defensive) still advances nextStart past it, so paging never desynchronizes. Pass it back as the next request's start. hasMore is true when more revisions exist beyond the page.
An id with no revisions returns an empty rows array, not a 404.
Errors ({ "error": "...", "message": "..." }):
| Status | error | When |
|---|---|---|
400 | missing_id | No id given |
400 | sibling_id | id names a /draft, /scheduled or /snapshot/{token} sibling |
400 | not_content | id exists but is not in a content collection |
400 | bad_paging | start is negative |
404 | not_found | The canonical does not exist, or is soft-deleted |
GET /content/version/{activeTeam}
One version body, for preview.
GET /content/version/my-team?id=blog-post-1&version=AAAAAAAAxA0BAAAAAAAAAAIReturns the revision body as stored, with @metadata and the client-injected Id property stripped and id set to the canonical id — the same shape a live document has when loaded.
Errors:
| Status | error | When |
|---|---|---|
400 | missing_id / sibling_id / not_content | Same as the list endpoint |
400 | bad_reference | version is not a well-formed reference |
404 | not_found | The canonical is missing or soft-deleted, or the reference does not name a stamped revision of that canonical (including a reference belonging to another document's revision) |
The refusal never distinguishes "wrong document" from "not a version" — a reference cannot be used to probe other documents.
POST /content/apply-version/{activeTeam}
Restores a History row — a snapshot or a published version — into the working copy. This is the one restore operation both PreviewAction's snapshot restore and the History drawer use.
{
"documentId": "blog-post-1",
"kind": "revision",
"version": "AAAAAAAAxA0BAAAAAAAAAAI",
"locales": ["en"]
}| Field | Type | Notes |
|---|---|---|
kind | "snapshot" | "revision" | Which source to restore from |
documentId | string | The canonical id. Required for kind: "revision"; optional for kind: "snapshot" (when given, must match the snapshot's canonical) |
snapshotId | string | The active snapshot's id ( {canonical}/snapshot/{token}), when kind: "snapshot" |
version | string | The opaque reference from a versions row, when kind: "revision" |
locales | string[] | null | null restores every locale the source carries; a non-empty subset of the team's configured locales restores only those (see Restore semantics) |
Response mirrors apply-snapshot: a one-item batch-result list.
[
{ "id": "blog-post-1", "status": 0, "document": { "id": "blog-post-1/draft", "...": "..." } }
]A failure reports status: 1 with a reason:
[
{ "id": "blog-post-1", "status": 1, "reason": "The draft changed while restoring. Save pending edits and try again." }
]Errors. Three body shapes share the 400 status, so branch on the shape, not the status code:
- Problem details —
{ "title": "...", "status": 400, "errors": { ... } }(application/problem+json), from the framework's request validation. Only a missing, empty, or literal-nullJSON body gets this. - Object —
{ "error": "...", "message": "..." }, the same shape the version reads use. This is the endpoint refusing the request before it reaches the store; there is no batch row and noreason. - Batch list — the one-item batch-result list shown above, with
status: 1and areason. Every refusal that involves the document, snapshot, or the team's configuration comes back this way.
| Status | Shape | error / reason | When |
|---|---|---|---|
400 | problem details | — | No JSON body, or a literal null body |
400 | object | bad_kind | kind is not "snapshot" or "revision" |
400 | object | missing_id | kind: "revision" without a documentId |
400 | object | missing_version | kind: "revision" without a version |
400 | object | missing_snapshot_id | kind: "snapshot" without a snapshotId |
400 | object | mismatch | kind: "snapshot" with a documentId that is not the snapshot's canonical |
400 | batch list | reason text | A sibling id where a canonical is expected, a malformed reference, an empty locales list, a locales list with more than 64 entries (refused as Too many locales in request., nothing echoed), or a locale outside the team's configured set (the reason names at most five of the unknown codes) |
403 | batch list | reason text | Restoring into the draft of a published document without the right to change live content |
404 | batch list | reason text | Unknown document or snapshot, or a version that does not name a published revision of documentId |
409 | batch list | reason text | The snapshot is expired/revoked, or the draft (or its activity document) changed underneath the restore — retry once after re-reading |
The canonical document is never written by this endpoint; only the draft (and its activity document) change.
Restore semantics
apply-version and apply-snapshot (with its own now-optional locales, see Snapshots) share one locale-selective merge:
- No
localeslist — the whole draft is replaced from the source, exactly as snapshot restore always worked: every locale the source carries overwrites the draft,__dirtyLocalesbecomes the source's full locale set, and any locale published since the source was taken (and therefore absent from it) is reverted from the live canonical rather than left stale. - A
localeslist — only those locales come from the source. Every other configured locale the draft already carried is put back byte-identical to what it was immediately before the restore (never re-derived from the canonical, so an in-progress, unpublished translation is never disturbed). A locale neither the source nor the pre-restore draft carried is reverted from the canonical, same as the no-list case. Non-localizable fields always come from the source, regardless of which locales were selected.__dirtyLocalesbecomes the union of the pre-restore dirty set and the restored locales — restored locales are marked as having unpublished changes; everything else keeps whatever mark it already had. - Activity — restoring appends one
APPLY_REVISION(orAPPLY_SNAPSHOT) entry to the document's activity log naming the source, and prunes priorEDITentries for the restored locales only; a whole-draft restore also prunes locale-less entries. - No draft — restoring creates one from the source.
- Concurrency — the write runs under optimistic concurrency over the draft and its activity document together, so it lands atomically or not at all; a 409 leaves no partial state, and the caller re-reads and retries with the same locale list.
- Restoring is never publishing — the draft is replaced, but nothing goes live until the normal publish flow runs.
Permissions
Both reads and the restore require team membership and the EditContent capability. Restoring into the draft of a published document additionally requires the right to change live content (ChangeLiveContent) — the same rule snapshot restore has always had. A caller without it can still read and preview every row; the restore call itself is refused with 403.
The Studio flags
The History drawer in Studio is gated by the history feature flag. The snapshots flag decides what is in it: with both on, the drawer also lists the document's snapshots and offers Share on every row; with history alone, the drawer shows versions only — the draft, the published version and every previous version — and nothing about snapshots. With history off, none of this is exposed in the UI, and snapshots does nothing on its own: the pre-History snapshot popover is no longer rendered. Neither flag gates these three endpoints or the snapshot endpoints — they answer the same way regardless of Studio's flag state.
Retention and erasure
No retention limit is configured: Published Versions are kept indefinitely, as far back as RavenDB holds revisions for the document. A future retention setting (a MinimumRevisionsToKeep on the revisions configuration) would trim them, but none exists today.
Soft-deleting a document does not delete its revisions — RavenDB keeps them — but both endpoints refuse to serve versions of a soft-deleted canonical (404). To actually remove a document's history, an operator runs DeleteRevisionsOperation against its canonical id, or destroys the whole team database.
What does not create a version
- Archiving a document
- Soft-deleting a document
- Unpublishing (the restore-to-draft transition)
- Copying a document
- The hand-crafted
POST /content/{activeTeam}document create/update - Discarding a draft
- Creating, renaming or revoking a snapshot
Only a publish — through the Studio batch publish, a scheduled publish, MCP publish_content, or the CLI seed — stamps the canonical and records a version.
Two publishes within seconds
The version is forced after the publish has saved, and that save can block for up to 15 seconds on index and replication waits. If a second publish of the same document commits inside that window, the first publish finds a __publishedAt it did not write and records nothing: the second publish records its own version, and the first one is not a row. The first publish's content was live only for those seconds. Each such case counts as outcome=superseded on strife.api.publish.revisions (or strife.worker.publish.revisions for a scheduled publish), next to ok and error.