# Management API

Source: https://capacms.com/docs/api/management

The routes behind the Capa admin, for scripts: scheduled publishing, workspaces, entry layouts, field changes, API keys and projects.

The Capa admin manages a project over these routes, and you can script them too. They run as a signed-in person, with that person's role in the project. Several are also mounted under `/v2/agent/` for an API key.

They are older than `/api/` and answer in their own shape: `{ "error": "<sentence>" }`, usually with a `code` beside it, never the [`/api/` error envelope](https://capacms.com/docs/errors).

## Authentication

Send every request on this page to `https://api.capacms.com`, never to the CDN.

Sign in with an email and a password to get a session token:

```bash
curl -X POST https://api.capacms.com/v2/user/login \
  -H 'content-type: application/json' \
  -d '{"email":"maya@example.com","password":"…"}'
# {"accessToken":"eyJ…","refreshToken":"eyJ…"}
```

Then send the token as a bearer token:

```bash
curl https://api.capacms.com/v2/projects \
  -H "Authorization: Bearer $SESSION_TOKEN"
```

The access token lasts 24 hours. `POST /v2/user/refresh-token` with `{ "refreshToken": "…" }` returns a new one, valid for 12 hours. Wrong credentials, and an account with no password, are `401 {"error":"Invalid credentials"}`.

| Problem                            | Answer                                                                  |
| ---------------------------------- | ----------------------------------------------------------------------- |
| no `Authorization` header          | `401 {"error":"Authentication token required"}`                         |
| your role does not allow the route | `403 {"error":"Insufficient permissions","required":[…],"current":"…"}` |

### Which project

A request runs in the project you last opened. To choose one for a single request, send its id:

```
x-capa-project: 3f6c1a8e-2b4d-4c9a-9e21-7d5b0c8f4a12
```

| The header                                                             | What happens                                           |
| ---------------------------------------------------------------------- | ------------------------------------------------------ |
| absent                                                                 | the request runs in the project you last opened        |
| a project you are a member of                                          | the request runs there, with your role in it           |
| a project you are not a member of, an unknown id, or a deleted project | `403 {"error":"You are not a member of this project"}` |
| not a project id                                                       | `400 {"error":"x-capa-project must be a project id"}`  |

The header only chooses. It never changes which project you last opened. API key requests ignore it: a key always acts in its own project.

### With an API key instead

Scheduled publishing, publishing now, workspaces, entry layouts and field changes are mounted a second time under `/v2/agent/`, behind a legacy API key in `x-api-key`. The routes, bodies and answers are the same. What a key may do depends on its permission, and a refusal is `403 {"error":"Insufficient permissions"}`. See [Agent API](https://capacms.com/docs/ai/agent-api).

## Scheduled publishing

Publish or unpublish entries at a time you choose.

| Route                                              | Permission         | Does                                                                                                       |
| -------------------------------------------------- | ------------------ | ---------------------------------------------------------------------------------------------------------- |
| `POST /v2/scheduled-actions`                       | `instance:publish` | schedule one or more targets                                                                               |
| `GET /v2/scheduled-actions`                        | `instance:read`    | list actions. Filters: `status`, `targetId`, `batchId`, `from`, `to`, `order`, `page`, `limit` (up to 200) |
| `GET /v2/scheduled-actions/:id`                    | `instance:read`    | one action                                                                                                 |
| `GET /v2/scheduled-actions/batch/:batchId`         | `instance:read`    | a batch, with a count per status                                                                           |
| `GET /v2/scheduled-actions/failures`               | `instance:read`    | how many actions failed in the last 7 days and nobody answered, and the latest                             |
| `GET /v2/scheduled-actions/defaults`               | `instance:read`    | the project's publishing time zone, or `null`                                                              |
| `PATCH /v2/scheduled-actions/:id`                  | `instance:publish` | reschedule one pending action                                                                              |
| `PATCH /v2/scheduled-actions/batch/:batchId`       | `instance:publish` | reschedule a batch's pending actions                                                                       |
| `POST /v2/scheduled-actions/:id/cancel`            | `instance:publish` | cancel a pending, failed or dead action                                                                    |
| `POST /v2/scheduled-actions/batch/:batchId/cancel` | `instance:publish` | cancel a batch's pending actions                                                                           |
| `POST /v2/scheduled-actions/:id/retry`             | `instance:publish` | retry a failed or dead action, as a new action                                                             |

```bash
curl -X POST https://api.capacms.com/v2/scheduled-actions \
  -H "Authorization: Bearer $SESSION_TOKEN" \
  -H 'content-type: application/json' \
  -d '{
    "action": "publish",
    "targets": [{ "type": "instance", "id": "8b0a1f2e-7d34-4c55-9e51-0f9b1d225c3e" }],
    "wallTime": "2026-10-01T09:00",
    "timezone": "America/New_York"
  }'
```

* `action` is `publish` or `unpublish`. `targets` holds 1 to 500 entries or models. A model target also needs `model:publish`, which no API key holds.
* `wallTime` is a local clock time with no offset, and `timezone` is an IANA name. The server converts them and says what it decided in `resolved`. When daylight saving moved the time, `resolved.note` says so in a sentence you can show a person.
* The time must be at least 60 seconds ahead. Earlier is `400` with `code: "in_the_past"`.
* A target has at most one active publish and one active unpublish. Scheduling another replaces the first, and `replaced` lists the ids it cancelled.
* With no `versionId`, an action publishes the latest draft at the time it runs.

The answer is `201` with a `batchId`, `resolved`, the `actions` and `replaced`. An action's `status` is `pending`, `running`, `done`, `failed`, `dead` or `cancelled`. Only a pending action can be rescheduled. A running one answers `409` with `code: "already_running"` to a cancel.

### Publishing now

| Route                                           | Permission         | Does                                                          |
| ----------------------------------------------- | ------------------ | ------------------------------------------------------------- |
| `POST /v2/model-instances/:instanceId/publish`  | `instance:publish` | publish an entry's current draft, or the `versionId` you send |
| `GET /v2/model-instances/:instanceId/scheduled` | `instance:read`    | the entry's pending and running actions                       |
| `POST /v2/model-instances/bulk-publish`         | `instance:publish` | publish many entries, by id or by a filter on one model       |
| `POST /v2/model-instances/bulk-unpublish`       | `instance:publish` | the same, unpublishing                                        |

A bulk body names entries with `instanceIds`, or with `modelId` and a `filter`. Send `"dryRun": true` first to see what would happen, then send the count you were shown as `expectedCount`. If the selection changed in between, the answer is `409` with `code: "selection_changed"`. Up to 50 entries publish inside the request. Above that the answer is `202` with a `poll` link to the batch.

## Workspaces

A workspace is an arrangement of the admin's left rail: folders holding models, entries and media folders. Arranging one never changes a record.

| Route                                     | Permission         | Does                                                                                   |
| ----------------------------------------- | ------------------ | -------------------------------------------------------------------------------------- |
| `GET /v2/workspaces`                      | `workspace:read`   | the workspaces you can see                                                             |
| `POST /v2/workspaces`                     | `workspace:create` | create one, optionally with a `template` and a `tree`                                  |
| `PUT /v2/workspaces/current`              | `workspace:read`   | choose the workspace you are looking at                                                |
| `PUT /v2/workspaces/:id`                  | `workspace:update` | rename it or change its icon                                                           |
| `POST /v2/workspaces/:id/duplicate`       | `workspace:create` | copy it                                                                                |
| `DELETE /v2/workspaces/:id`               | `workspace:delete` | delete it. Its records are untouched                                                   |
| `GET /v2/workspaces/:id/tree`             | `workspace:read`   | its nodes                                                                              |
| `GET /v2/workspaces/:id/document`         | `workspace:read`   | the whole arrangement as one document                                                  |
| `PUT /v2/workspaces/:id/tree`             | `workspace:update` | apply a document, with `mode` `replace` or `merge`                                     |
| `POST /v2/workspaces/:id/nodes`           | `workspace:update` | add a node                                                                             |
| `PUT /v2/workspaces/:id/nodes/:nodeId`    | `workspace:update` | change a node                                                                          |
| `POST /v2/workspaces/:id/nodes/move`      | `workspace:update` | move nodes, in one step                                                                |
| `POST /v2/workspaces/:id/nodes/group`     | `workspace:update` | put nodes in a new folder                                                              |
| `DELETE /v2/workspaces/:id/nodes/:nodeId` | `workspace:update` | remove a node. A folder's children move up a level unless you send `withChildren=true` |
| `PUT /v2/workspaces/:id/default`          | `settings:update`  | make it the project's default. Owners and admins only, never a key                     |
| `PUT /v2/workspaces/:id/assign`           | `settings:update`  | set where people land. Owners and admins only, never a key                             |

A document is a list of nodes. A folder holds more nodes; a model is named by its namespace or id; an entry and a media folder by id:

```bash
curl -X PUT https://api.capacms.com/v2/workspaces/$WORKSPACE_ID/tree \
  -H "Authorization: Bearer $SESSION_TOKEN" \
  -H 'content-type: application/json' \
  -d '{
    "mode": "merge",
    "tree": [
      { "folder": "Blog", "children": [{ "model": "blog_post" }, { "model": "author" }] },
      { "folder": "Media", "children": [{ "media_folder": "41a2b3c4-d5e6-4f70-8192-a3b4c5d6e7f8" }] }
    ]
  }'
```

`merge` adds what is missing and never removes. `replace` makes the workspace exactly the document. `template` on create is `blank`, `website` or `catalog`, and seeds top-level folders only.

A workspace is `team`, visible to everyone in the project, or `private`, visible to the person who made it. An API key can only create `team` workspaces.

## Entry layouts

An entry layout arranges a model's fields in the entry editor, in cards across a main column and a side column. It is live as soon as it is saved: layouts are not versioned with the model.

| Route                          | Permission     | Does                                                                     |
| ------------------------------ | -------------- | ------------------------------------------------------------------------ |
| `GET /v2/models/:id`           | `model:read`   | the model, with its `layout` (or `null`) and `embedByDefault`            |
| `PUT /v2/models/:id/layout`    | `model:update` | save a layout. `{ "layout": null }` resets the model to the plain editor |
| `DELETE /v2/models/:id/layout` | `model:update` | reset the model to the plain editor                                      |

`:id` is the model's id or its namespace.

```bash
curl -X PUT https://api.capacms.com/v2/models/blog_post/layout \
  -H "Authorization: Bearer $SESSION_TOKEN" \
  -H 'content-type: application/json' \
  -d '{
    "layout": {
      "v": 1,
      "main": [{
        "t": "card", "id": "content", "title": "Content",
        "collapsible": false, "collapsed": false,
        "items": [{ "t": "field", "id": "f1", "fieldId": "41a2b3c4-d5e6-4f70-8192-a3b4c5d6e7f8", "width": "full" }]
      }],
      "aside": []
    }
  }'
```

A field item's `width` is `full`, `two_thirds`, `half` or `third`. A relation field may set `display` to `picker`, `inline` or `embedded`. A layout holds at most 50 cards and 500 fields, a card title at most 80 characters and a description at most 200. A body with no `layout` key is refused, so a typo never resets a layout. A refusal carries `path`, naming the part of the document to fix.

## Field changes

Changing a field's type, its array type, its relation or its namespace on a model that already has entries is a field change. You plan it, choose what happens to the values, then a background job converts them and moves the model to the new shape at the end.

| Route                                      | Permission     | Does                                                       |
| ------------------------------------------ | -------------- | ---------------------------------------------------------- |
| `POST /v2/models/:id/field-changes/plan`   | `model:update` | plan a change: what happens to every value. Writes nothing |
| `POST /v2/models/:id/field-changes`        | `model:update` | apply a plan, with a decision per field                    |
| `GET /v2/models/:id/field-changes/current` | `model:read`   | the change in progress, or `204`                           |
| `GET /v2/models/:id/field-changes`         | `model:read`   | past changes, newest first                                 |
| `GET /v2/field-changes/:id`                | `model:read`   | one change, with the values that failed to convert         |
| `POST /v2/field-changes/:id/cancel`        | `model:update` | cancel, until the model starts moving to the new shape     |
| `POST /v2/field-changes/:id/resume`        | `model:update` | resume a failed or cancelled change                        |

The plan body holds the same `editFields` and `removeIds` you would send to update the model:

```bash
curl -X POST https://api.capacms.com/v2/models/$MODEL_ID/field-changes/plan \
  -H "Authorization: Bearer $SESSION_TOKEN" \
  -H 'content-type: application/json' \
  -d '{ "editFields": [{ "id": "6d2e4f8a-0c91-4b11-8a77-5c3e0f9b1d22", "type": "number" }] }'
```

A plan returns a `planHash`, one `op` per field with the choices it allows, and counts of the values that convert, lose detail or fail. A model with many entries is counted in the background: the answer is `202` with a `poll` link instead.

Apply sends the same diff again with the `planHash` and one decision per op. A decision's `choice` is `convert`, `clear`, `keep_both` or `delete`. For `convert`, `onFailure` is `clear` (the default: values that fail are left empty and listed) or `stop`. If the model moved since the plan, the answer is `409` with `code: "plan_stale"`: plan again.

Where field changes are not switched on, every route here answers `404` with `code: "feature_disabled"`.

## API keys

These routes mint and manage your project's keys. Developers > Keys in the admin covers the same ground. See [Keys and scopes](https://capacms.com/docs/api/authentication) for what each key can do.

| Route                                            | Permission       | Does                                                                                                                             |
| ------------------------------------------------ | ---------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `POST /v2/tenants/api-keys`                      | `api_key:create` | mint a key                                                                                                                       |
| `GET /v2/tenants/api-keys`                       | `api_key:read`   | list keys. Add `?includeStatus=true` for `active`, `allowedOrigins`, `name`, `keyPrefix`, `scopes`, `expiresAt` and `lastUsedAt` |
| `GET /v2/tenants/api-keys/grantable`             | `api_key:read`   | the scopes and presets a key can be given, and which of them you hold                                                            |
| `PATCH /v2/tenants/api-keys/:id`                 | `api_key:update` | change `name`, `scopes` or `expiresAt`                                                                                           |
| `POST /v2/tenants/api-keys/:id/rotate`           | `api_key:update` | mint a successor. Both work for `graceHours` (0 to 168, default 24)                                                              |
| `PATCH /v2/tenants/api-keys/:id/active`          | `api_key:update` | `{ "active": false }` revokes a key                                                                                              |
| `PATCH /v2/tenants/api-keys/:id/allowed-origins` | `api_key:update` | set the origins a key may be used from, up to 50                                                                                 |
| `GET /v2/tenants/api-keys/:id/domains-seen`      | `api_key:read`   | the domains a key was used from in the last 30 days                                                                              |
| `DELETE /v2/tenants/api-keys/:id`                | `api_key:delete` | delete a key                                                                                                                     |

Mint a scoped `cap_` key by sending `scopes`:

```bash
curl -X POST https://api.capacms.com/v2/tenants/api-keys \
  -H "Authorization: Bearer $SESSION_TOKEN" \
  -H 'content-type: application/json' \
  -d '{"name":"Website build","environment":"production","scopes":["instance:read","model:read"]}'
```

The answer is `201`, with the secret in `apiKey`. It is the only time this key's secret is shown. A `cap_` key cannot be read again: a lost one is rotated. You can only grant scopes you hold yourself.

Without `scopes`, the route mints a legacy key. Send `permission`, one of `read`, `write`, `delete` or `agent`, and it answers `apiKey`, `environment` and `permission`.

## Projects

| Route                      | Permission                                     | Does                                                                                                      |
| -------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| `GET /v2/projects`         | any signed-in person                           | every project you are a member of, with its `id`, `name`, `slug`, your `role` and when you last opened it |
| `PATCH /v2/projects/:id`   | the owner, or an admin                         | change the project's `slug`, the name in its admin URLs                                                   |
| `GET /v2/projects/resolve` | `model:read`, and `instance:read` with `entry` | turn the `model` and `entry` parts of an admin URL back into ids                                          |

A slug is 3 to 48 characters: lowercase letters and digits, with single hyphens between them. A slug another project holds is `409 {"error":"That URL is taken"}`. The old slug stops working at once, with no redirect.

```bash
curl -X PATCH https://api.capacms.com/v2/projects/$PROJECT_ID \
  -H "Authorization: Bearer $SESSION_TOKEN" \
  -H 'content-type: application/json' \
  -d '{"slug":"northwind-journal"}'
```
