---
title: "Stage many content writes and deletes at once"
method: POST
path: "/api/v1/sites/{slug}/changeset"
tags: ["Content"]
---

# Stage many content writes and deletes at once

`POST /api/v1/sites/{slug}/changeset`

## Request

- Base URL: `https://api.dotsy.ai`
- URL: `https://api.dotsy.ai/api/v1/sites/{slug}/changeset`
- Auth: one of:
  - HTTP bearer
  - HTTP bearer

Stages writes and deletes (with their routes and menu items) to the draft in one call. Nothing is live until you publish.

The request is checked as a whole before anything is staged: a bad write, a reserved key, a CSS or JS file or an invalid menu item rejects the lot. Each write carries exactly one of `body` or `bodyStaging`. Bodies too big to send inline are uploaded in chunks first and referenced by `bodyStaging`.

A small changeset answers 200 with what was staged. A large one (many writes or many bytes; the server decides) is queued: the answer is 202 with a `job`, and you poll `GET /api/v1/sites/{slug}/jobs/{id}` until it succeeds or fails. A write whose `ifMatch` no longer matches is not applied and is listed in `conflicts`, with status 200. With `atomic: true` one stale `ifMatch` writes nothing instead, and the answer is 409 `changeset_conflict`.

## Path parameters

- `slug` string, required

## Request body

- ApplyChangesetBody: A batch of content writes and deletes. Send at least one write or delete. `.dotsy/*` and `.structure/*` keys are reserved, and CSS or JavaScript files belong in the asset store (`asset_write_not_allowed`).
  - `writes` ChangesetWrite[]: Content to write. A key cannot appear in both `writes` and `deletes`.
    - `key` string, required: Content key to write: a page (`about.html`), a layout (`layouts/<name>.html`) or a fragment (`fragments/<name>.html`). A `<dotsy-fragment name>` tag and a route's `layout` use the bare name, not the key.
    - `body` string: The whole file as text. Send exactly one of `body` or `bodyStaging`.
    - `bodyStaging` string: Id of a finished staging session (`stg_…`) holding a body too large to inline. Send exactly one of `body` or `bodyStaging`.
    - `contentType` string: Stored Content-Type. Defaults to `text/html; charset=utf-8`, or the staging session's type.
    - `ifMatch` string: The `contentHash` you read. If the key changed since, this write is not applied and is listed in `conflicts`.
    - `route` object: Create or update the route for this page in the same call.
      - `path` string, required: URL path the page serves at.
      - `input` ChangesetRouteInput, required: The route's settings: layout, title, purpose (`redirect` with `redirectTo`), noindex, and so on.
        - `contentKey` string, nullable: Explicit content key (defaults to a path-derived key when omitted). null clears an explicit key back to that path default — same as omitting it, but usable on a PATCH to undo a prior set.
        - `layout` string, nullable: Layout NAME, bare: "default" for layouts/default.html — not the file key. It must already exist as layouts/<name>.html (400 layout_not_found otherwise). null removes the layout.
        - `title` string, nullable
        - `vars` object, nullable
        - `purpose` string, nullable
        - `redirectTo` string, nullable: Redirect target, a root-relative path (/…). Required when purpose is "redirect". null clears it — only valid once purpose stops being "redirect" (a redirect route always needs a target).
        - `redirectStatus` union: 301 permanent / 302 or 308 temporary (default 301 on a redirect). null clears an explicit override.
          - 301
          - 302
          - 308
        - `preserveQuery` boolean, nullable: Append the visitor's original query string to the redirect target (default true on a redirect). null clears an explicit override.
        - `navLabel` string, nullable: DEPRECATED — use put_menu_item / menu API. Menu link text for <dotsy-nav> (separate from title). Still accepted during transition and synced to menu items.
        - `nav` object[], nullable: DEPRECATED — use put_menu_item / menu API. Menu memberships on the route row. Still accepted during transition and synced to menu items. See get_skill('nav').
          - `menu` string, required: Menu this page joins — matches a <dotsy-nav menu="…"> in the layout/fragment (e.g. "primary", "footer").
          - `order` integer: Sort position among siblings in this menu, ascending. Use gaps (10, 20, 30) to leave room for inserts.
          - `parent` string, nullable: Parent route path within this menu for a nested/dropdown item (e.g. "/services"). Omit for top level.
        - `experiment` object, nullable
          - `id` string, required: Stable experiment slug — cookie namespace; unique per site forever.
          - `name` string, required
          - `hypothesis` string
          - `status` 'draft' | 'running' | 'ended', required
          - `variants` object[], required
            - `id` string, required: Variant arm id — default "a" / "b" on create.
            - `label` string
            - `weight` integer, required
            - `contentKey` string, required: Published body key (may be routeless).
          - `goalId` string: Optional Dotsy conversion goal; omit when tracking in external analytics.
          - `changeNote` string: One line on what the variant changes (0.56e M2). Kept here so route PATCH round-trips (weights/settings updates) don't strip it.
          - `handsOff` boolean: Hands-off mode (0.56e M4): the daily sweep may auto-promote a ≥95% winner or auto-end keep-A after 30 days. Prefer setting this via start_test; kept here so route PATCH round-trips (weights/settings updates) don't strip the flag.
          - `startedAt` string
          - `endedAt` string
        - `noindex` boolean, nullable: Exclude from sitemap/RSS; inject noindex meta (0.69).
    - `menuItems` object[]: Menu items to place in the same call. Each names its `menu`.
      - `sort` integer, required
      - `parent` string, nullable, required
      - `label` string, required
      - `kind` 'page' | 'url' | 'anchor' | 'mailto' | 'group', required
      - `ref` string, required
      - `newTab` boolean
      - `attrs` object
      - `data` object
      - `html` string
      - `childShell` string, nullable
      - `childTemplate` string
      - `menu` string, required
      - `id` string
  - `deletes` union[]: Content keys to delete: a key, or `{ key, ifMatch }` with the `contentHash` you read. If that page changed since, it is not deleted and is listed in `conflicts` (with `atomic: true`, nothing is written). The site brief and `_data/*` keys are skipped (listed in `skippedDeletes`).
    - union
      - string
      - object
        - `key` string, required
        - `ifMatch` string, required
  - `message` string: Short note for the site's edit history (up to 2,000 characters).
  - `atomic` boolean: All or nothing. Every write's and delete's `ifMatch` is checked before any key is written: one stale key writes nothing and the answer is 409 `changeset_conflict` with `conflicts`. A changeset too large to apply in one request is checked against the draft before it is queued; a write that lands between that check and the queued job is still caught per key and listed in the job's `conflicts`.

## Response `200`

Staged.

- ApplyChangesetResponse
  - `staged` object, required
    - `created` integer, required
    - `updated` integer, required
    - `reverted` integer, required: Writes that put a key back to its live version.
    - `deleted` integer, required
    - `unchanged` integer, required: Writes whose body already matched.
  - `conflicts` object[], required: Writes that were not applied because their `ifMatch` no longer matched. The status is still 200, so check this list.
    - `key` string, required
  - `publishable` boolean, required: True when the draft has anything to publish.
  - `version` integer, required: The site's published version counter.
  - `skippedDeletes` string[]: Deletes that were ignored because the key is protected.
  - `warnings` string[]: Non-blocking notes about tags that will not compose, one per problem, each as `<key>: <code> — <what to change>`. Codes: `fragment_unclosed`, `fragment_bad_name`, `fragment_full_document`, `fragment_self_reference`, `fragment_attr_hyphen`, `legacy_paige_tag`, `layout_missing_content`, `unknown_dotsy_syntax`. The write still happened. At most 50 are listed.
  - `published` false, required: Always false: the change is staged in the draft until you publish.
  - `hint` string, required: Reminder that the change is staged and how to make it live.

## Other responses

- `202`: Queued: the changeset was too large to apply in the request. Poll `job` until it finishes.
- `400`: Nothing was staged. `invalid_changeset` (with `issues`; also sent as `detail`), `reserved_key`, `asset_write_not_allowed`, `staging_assemble_failed`, `intent_frontmatter_invalid`, `menu_invalid`, `empty_page_body`, or a route problem such as `layout_not_found`.
- `401`: Missing or invalid credentials.
- `403`: The key or token cannot write to this site, or a plan limit blocks the write (`page_limit_reached`, `signature_required_on_free`).
- `404`: No such site (`not_found`).
- `409`: `changeset_conflict` (only with `atomic: true`): a write's `ifMatch` no longer matches, listed in `conflicts` (`[{ key }]`), and nothing was written. Or `placement_version_conflict`: the site's storage changed during the write. Retry.
- `413`: `changeset_too_large` (more than 25 MB queued) or `storage_limit_reached`.
- `422`: A route redirect is not valid (for example a loop, or a target that is not a root-relative path). The body names the `path` and `key`.
- `503`: The queue is not available (`queue_unavailable`), or, with `atomic: true`, the site is still being indexed (`index_warming`, nothing was written; retry after `Retry-After`).

---

[API](https://skmtc.dev/dotsy/apis/dotsy-api.md) · [All operations](https://skmtc.dev/dotsy/apis/dotsy-api/llms.txt) · [OpenAPI document](https://skmtc.dev/dotsy/apis/dotsy-api/revisions/4272ed3b85d7?raw)
