# Asset Versioning

Playbook keeps a version history for every asset. Replacing an asset's file does not lose the old one: the previous state is snapshotted first, and you can list what came before and roll back to it.

The live asset row is always the **head** — the current content, reachable at its usual token and URLs. Everything before it is stored as an immutable snapshot. Because the asset keeps its token, its boards, and its shared links across a version write, links you have already handed out keep working and start serving the new content.

## Prerequisites[​](#prerequisites "Direct link to Prerequisites")

* **Access Token**: an API token with the `write` scope for anything that changes a version. Reading version history needs only `read`.
* **Organization Slug**: your workspace identifier.
* **Asset Token**: the asset whose history you want.

***

## How version numbers work[​](#how-version-numbers-work "Direct link to How version numbers work")

Each stored version has a monotonic `version_number`, and that number is the stable handle you pass to revert or to annotate.

`head_version_number` is the number the **next** version write will take — not the newest stored version. An asset that has never been versioned reports `head_version_number: 1`. The newest stored version is always `head_version_number - 1`.

This trips people up most often on revert, so it is worth stating outright:

> **Revert moves the head forward, not backward.** Reverting to version 2 does not set the head back to 2. It snapshots the current head as a new version, then rotates the asset's bytes to version 2's content. The returned `head_version_number` is `old_head + 1`.

That is what makes the operation non-destructive: the state you reverted *away from* is still in the history, so you can revert back to it.

## List version history[​](#list-version-history "Direct link to List version history")

Newest first. Available on every plan, with only the `read` scope.

```
curl "https://api.playbook.com/v1/my-org/assets/hero-image/versions" \

  -H "Authorization: Bearer YOUR_TOKEN"
```

**Response:**

```
{

  "data": [

    {

      "version_number": 3,

      "source": "REVERT",

      "comment": "Reverted to the approved crop",

      "md5": "9f2c...",

      "size": 482113,

      "media_type": "image/jpeg",

      "revertable": true,

      "created_at": "2026-08-14T10:12:04Z",

      "created_by_token": "user-abc",

      "thumbnails": []

    },

    {

      "version_number": 2,

      "source": "UPLOAD",

      "comment": null,

      "revertable": true,

      "created_at": "2026-08-02T09:41:55Z"

    }

  ],

  "head_version_number": 4,

  "pagy": { "current_page": 1, "total_count": 3 }

}
```

Note that `head_version_number` is a **top-level key on the envelope**, a sibling of `data` — not a field inside a version row.

## Revert to an earlier version[​](#revert-to-an-earlier-version "Direct link to Revert to an earlier version")

Requires the `write` scope.

```
curl -X POST "https://api.playbook.com/v1/my-org/assets/hero-image/revert" \

  -H "Authorization: Bearer YOUR_TOKEN" \

  -H "Content-Type: application/json" \

  -d '{

    "version_number": 2,

    "comment": "Client preferred the original crop"

  }'
```

The optional `comment` is recorded on the snapshot of the head being replaced, not on the content you are restoring.

Reverting to the live head is a benign no-op: it returns `200`, creates no new version, and leaves the head where it was. That also means a client that times out and retries a revert that already succeeded does not stack up duplicate versions.

## Upload new bytes as a new version[​](#upload-new-bytes-as-a-new-version "Direct link to Upload new bytes as a new version")

There is no separate "new version" endpoint. Versioning new bytes is a mode of the normal asset update, selected with `create_version`. First reserve an upload with `upload_prepare` and send the bytes exactly as you would for a new asset — on GCS that means POSTing to `upload_url` to open a resumable session and PUTting the bytes to the `Location` it returns; see [Uploading Assets](/docs/guides/upload.md) for the full handshake. Then finalize against the existing asset:

```
curl -X PATCH "https://api.playbook.com/v1/my-org/assets/hero-image" \

  -H "Authorization: Bearer YOUR_TOKEN" \

  -H "Content-Type: application/json" \

  -d '{

    "asset": {

      "create_version": true,

      "signed_gcs_id": "SIGNED_ID_FROM_UPLOAD_PREPARE",

      "media_type": "image/jpeg",

      "comment": "Reshot at higher resolution"

    }

  }'
```

Sending `create_version: true` **without** `signed_gcs_id` takes a metadata-only checkpoint — but only if the same request actually changes something. The server snapshots the head when the update changes `title`, `description`, or `text`, or when you pass a non-empty `comment`. A bare `{"asset": {"create_version": true}}` changes nothing, so it returns `200` and creates **no** version. If you want a checkpoint without editing the asset, pass a `comment`.

## Edit a version's note[​](#edit-a-versions-note "Direct link to Edit a version's note")

Send `version_number` **without** `signed_gcs_id` and the update is interpreted as a comment edit on that one version. No new version is created and the asset's content is untouched.

```
curl -X PATCH "https://api.playbook.com/v1/my-org/assets/hero-image" \

  -H "Authorization: Bearer YOUR_TOKEN" \

  -H "Content-Type: application/json" \

  -d '{ "asset": { "version_number": 2, "comment": "Approved by brand review" } }'
```

Pass an empty string to clear a note. Omitting `comment` entirely is a no-op.

## Plans and the revert window[​](#plans-and-the-revert-window "Direct link to Plans and the revert window")

**History itself is unlimited on every plan, including Free.** Version rows are never pruned and there is no cap on how many an asset may accumulate. Listing history always works.

What is gated is how far back you can *restore*:

| Plan                       | Revert window                      |
| -------------------------- | ---------------------------------- |
| Free, Pro                  | 7 days from the version's creation |
| Team, Business, Enterprise | Unlimited                          |

Trial plans inherit the window of the paid plan they are trialling. The window is measured from each version's `created_at`, and each version in the list carries a server-computed `revertable` flag so you do not have to work it out yourself — prefer that flag over computing the age yourself. If you do attempt a revert outside the window, the `403` response carries the workspace's window length as `extensions.revert_window_days`.

On a gated plan, versions older than the window also withhold the full-resolution original: their `primary_gcs_id` comes back `null`. The two surfaces differ in what they return instead, so do not assume one from the other:

* **Over REST**, thumbnails for a past-window version are still returned, watermarked and clamped to a preview size.
* **Over MCP**, `list_asset_versions` returns `thumbnails: []` for a past-window version — the tool drops them rather than passing the watermarked set through.

Either way, use the live asset's `display_url` to preview current content.

## Error codes[​](#error-codes "Direct link to Error codes")

Errors use the standard envelope, `{ "errors": [{ "message": "...", "extensions": { "code": "..." } }] }`. Branch on `extensions.code` rather than on the message text:

| Code                      | Status | Meaning                                                                                                                         |
| ------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------- |
| `versioning_not_enabled`  | 403    | The workspace plan does not allow version writes.                                                                               |
| `version_write_forbidden` | 403    | The asset's board is not version-writeable — trashed, archived, read-only, or the asset is not on a board. Not a token problem. |
| `revert_window_exceeded`  | 403    | Target is older than the plan's revert window. Also carries `revert_window_days`.                                               |
| `version_not_found`       | 404    | No such `version_number` — above the head, or a gap.                                                                            |
| `invalid_version_number`  | 422    | Not a positive integer.                                                                                                         |
| `version_blob_missing`    | 422    | The stored file is no longer available in storage.                                                                              |

## From an AI assistant[​](#from-an-ai-assistant "Direct link to From an AI assistant")

Every operation above is available to AI assistants over the hosted [MCP server](/docs/guides/mcp.md), so you can drive version history in natural language instead of writing calls:

| Tool                         | What it does                                                              |
| ---------------------------- | ------------------------------------------------------------------------- |
| `list_asset_versions`        | Version history, newest first, plus `head_version_number`. Read-only.     |
| `revert_asset`               | Revert to a `version_number`. Needs `write`.                              |
| `create_asset_version`       | Upload a public URL as a new version of an existing asset. Needs `write`. |
| `edit_asset_version_comment` | Edit the note on an existing version. Needs `write`.                      |

`create_asset_version` takes a public `https://` URL rather than raw bytes, and refuses files that would need a multipart upload — use the web or desktop app for those.

## Related[​](#related "Direct link to Related")

* [Advanced Asset Management](/docs/guides/asset_management.md) — grouping, tagging, moving, and batch operations.
* [Uploading Assets](/docs/guides/upload.md) — the `upload_prepare` flow referenced above.
* [Connect AI Assistants via MCP](/docs/guides/mcp.md) — connecting Claude, Cursor, or ChatGPT to a workspace.
