---
title: "Save a page draft"
method: PUT
path: "/projects/{projectId}/page-drafts/{pageId}"
tags: ["Drafts and publishing"]
---

# Save a page draft

`PUT /projects/{projectId}/page-drafts/{pageId}`

## Request

- Base URL: `https://studio.weaverse.io/api/v1/content`
- URL: `https://studio.weaverse.io/api/v1/content/projects/{projectId}/page-drafts/{pageId}`
- Auth: HTTP bearer

Saves a new draft head for the page from a full item list. Live content is never changed and no cache is invalidated. Publishing is a separate call that needs `content:publish`.

**Workflow.** Drafts and publishing work together like this:

1. `GET /projects/{projectId}/page-drafts/{pageId}` reads the editable content and its revision tokens.
2. `PUT` on the same path saves a new draft head (`versionId`). Live content is not touched.
3. Publish the draft with `POST /publish-versions/{versionId}`, or schedule it with `POST /versions/{versionId}/schedule`.
4. Manage history and the schedule with `GET /projects/{projectId}/versions`, `/versions/{versionId}` (read, reschedule, replace content, cancel), `publish-now` and `restore`.

**Body**

- `items`: the complete item graph, 1 to 200 items. Each item needs `id` and `type`; `children` are `{ "id": ... }` references and `data` is an object. Every child must reference an item in the list, ids must be unique, and every item must be reachable from the root. A malformed body is 400; a broken graph (duplicate ids, dangling child, orphaned items) is 404 `PAGE_NOT_FOUND`. Nothing is saved in either case.
- The root is the item whose id matches the page's current `rootId`; otherwise the first item.
- `name` (optional): renames the draft. Defaults to the live page name.

**Revision fence.** All three tokens are required and must be copied exactly from the GET response:

- `expectedHeadVersionId`: GET's `headVersionId` (`null` means "no draft yet").
- `expectedPageUpdatedAt`: GET's `pageUpdatedAt`, the exact ISO string or `null`.
- `expectedProjectUpdatedAt`: GET's `projectUpdatedAt`, the exact ISO string or `null`.

`null` is a real baseline for a legacy row and only matches a row that is still `null`. If the draft head, the live page or the project moved since your GET, the save is refused with 409 `STALE_PAGE` and nothing is written. Re-read with GET, reapply your edit and save again.

**Retries.** Retrying a save that already committed returns the same head instead of a conflict.

**Response.** `versionId` is the new draft head. Pass it to `POST /publish-versions/{versionId}` or `POST /versions/{versionId}/schedule`, and send it as `expectedHeadVersionId` on your next save or discard.

Required scope: `content:write`

## Request body

- PageDraftSave
  - `items` DraftItem[], required
    - `id` string, required
    - `type` string
    - `data` object
    - `children` object[]
      - `id` string, required
  - `name` string
  - `expectedHeadVersionId` string, nullable, required: The head this edit started from; null means "no draft yet"
  - `expectedPageUpdatedAt` string, date-time, nullable, required: GET `pageUpdatedAt` exactly as GET returned it (null = a legacy row with no revision). The save is refused with 409 STALE_PAGE when it no longer matches live.
  - `expectedProjectUpdatedAt` string, date-time, nullable, required: GET `projectUpdatedAt` exactly as GET returned it (null = a legacy row with no revision). The save is refused with 409 STALE_PAGE when it no longer matches live.

## Response `200`

New head version id

- object

## Other responses

- `401`: Missing or invalid credentials
- `403`: Foreign project or missing scope (INSUFFICIENT_SCOPE)
- `404`: Not found (never discloses other tenants)
- `409`: STALE_PAGE — the draft head, live page or project moved since GET; nothing was saved, re-read

---

[API](https://skmtc.dev/weaverse/apis/weaverse-content-api.md) - [All operations](https://skmtc.dev/weaverse/apis/weaverse-content-api/llms.txt) - [OpenAPI document](https://skmtc.dev/weaverse/apis/weaverse-content-api/revisions/d189794169bb?raw)
