Management API
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.
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:
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:
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.
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 |
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"
}'actionispublishorunpublish.targetsholds 1 to 500 entries or models. A model target also needsmodel:publish, which no API key holds.wallTimeis a local clock time with no offset, andtimezoneis an IANA name. The server converts them and says what it decided inresolved. When daylight saving moved the time,resolved.notesays so in a sentence you can show a person.- The time must be at least 60 seconds ahead. Earlier is
400withcode: "in_the_past". - A target has at most one active publish and one active unpublish. Scheduling another replaces the first, and
replacedlists 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:
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.
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:
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 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:
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.
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"}'