> From the [Strife developer docs](https://strife.app/docs/reference/versions). Every page is listed in [llms.txt](https://strife.app/docs/llms.txt).

# 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](https://strife.app/docs/reference/snapshots.md), 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](https://strife.app/docs/reference/snapshots.md) — 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](https://strife.app/docs/reference/snapshots.md#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 document `POST` body and after a `copy`. 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
```

```json
{
  "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=AAAAAAAAxA0BAAAAAAAAAAI
```

Returns 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.

```json
{
  "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](#restore-semantics)) |

Response mirrors `apply-snapshot`: a one-item batch-result list.

```json
[
  { "id": "blog-post-1", "status": 0, "document": { "id": "blog-post-1/draft", "...": "..." } }
]
```

A failure reports `status: 1` with a `reason`:

```json
[
  { "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-`null` JSON 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 no `reason`.
* **Batch list** — the one-item batch-result list shown above, with `status: 1` and a `reason`. 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](https://strife.app/docs/reference/snapshots.md#restoring-a-snapshot-with-a-locale-list)) share one locale-selective merge:

* **No `locales` list** — the whole draft is replaced from the source, exactly as snapshot restore always worked: every locale the source carries overwrites the draft, `__dirtyLocales` becomes 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 `locales` list** — 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. `__dirtyLocales` becomes 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` (or `APPLY_SNAPSHOT`) entry to the document's activity log naming the source, and prunes prior `EDIT` entries 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`.
