Docs

Management API

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

View as Markdown

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"}.

ProblemAnswer
no Authorization header401 {"error":"Authentication token required"}
your role does not allow the route403 {"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 headerWhat happens
absentthe request runs in the project you last opened
a project you are a member ofthe request runs there, with your role in it
a project you are not a member of, an unknown id, or a deleted project403 {"error":"You are not a member of this project"}
not a project id400 {"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.

RoutePermissionDoes
POST /v2/scheduled-actionsinstance:publishschedule one or more targets
GET /v2/scheduled-actionsinstance:readlist actions. Filters: status, targetId, batchId, from, to, order, page, limit (up to 200)
GET /v2/scheduled-actions/:idinstance:readone action
GET /v2/scheduled-actions/batch/:batchIdinstance:reada batch, with a count per status
GET /v2/scheduled-actions/failuresinstance:readhow many actions failed in the last 7 days and nobody answered, and the latest
GET /v2/scheduled-actions/defaultsinstance:readthe project's publishing time zone, or null
PATCH /v2/scheduled-actions/:idinstance:publishreschedule one pending action
PATCH /v2/scheduled-actions/batch/:batchIdinstance:publishreschedule a batch's pending actions
POST /v2/scheduled-actions/:id/cancelinstance:publishcancel a pending, failed or dead action
POST /v2/scheduled-actions/batch/:batchId/cancelinstance:publishcancel a batch's pending actions
POST /v2/scheduled-actions/:id/retryinstance:publishretry 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"
  }'
  • 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

RoutePermissionDoes
POST /v2/model-instances/:instanceId/publishinstance:publishpublish an entry's current draft, or the versionId you send
GET /v2/model-instances/:instanceId/scheduledinstance:readthe entry's pending and running actions
POST /v2/model-instances/bulk-publishinstance:publishpublish many entries, by id or by a filter on one model
POST /v2/model-instances/bulk-unpublishinstance:publishthe 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.

RoutePermissionDoes
GET /v2/workspacesworkspace:readthe workspaces you can see
POST /v2/workspacesworkspace:createcreate one, optionally with a template and a tree
PUT /v2/workspaces/currentworkspace:readchoose the workspace you are looking at
PUT /v2/workspaces/:idworkspace:updaterename it or change its icon
POST /v2/workspaces/:id/duplicateworkspace:createcopy it
DELETE /v2/workspaces/:idworkspace:deletedelete it. Its records are untouched
GET /v2/workspaces/:id/treeworkspace:readits nodes
GET /v2/workspaces/:id/documentworkspace:readthe whole arrangement as one document
PUT /v2/workspaces/:id/treeworkspace:updateapply a document, with mode replace or merge
POST /v2/workspaces/:id/nodesworkspace:updateadd a node
PUT /v2/workspaces/:id/nodes/:nodeIdworkspace:updatechange a node
POST /v2/workspaces/:id/nodes/moveworkspace:updatemove nodes, in one step
POST /v2/workspaces/:id/nodes/groupworkspace:updateput nodes in a new folder
DELETE /v2/workspaces/:id/nodes/:nodeIdworkspace:updateremove a node. A folder's children move up a level unless you send withChildren=true
PUT /v2/workspaces/:id/defaultsettings:updatemake it the project's default. Owners and admins only, never a key
PUT /v2/workspaces/:id/assignsettings:updateset 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.

RoutePermissionDoes
GET /v2/models/:idmodel:readthe model, with its layout (or null) and embedByDefault
PUT /v2/models/:id/layoutmodel:updatesave a layout. { "layout": null } resets the model to the plain editor
DELETE /v2/models/:id/layoutmodel:updatereset 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.

RoutePermissionDoes
POST /v2/models/:id/field-changes/planmodel:updateplan a change: what happens to every value. Writes nothing
POST /v2/models/:id/field-changesmodel:updateapply a plan, with a decision per field
GET /v2/models/:id/field-changes/currentmodel:readthe change in progress, or 204
GET /v2/models/:id/field-changesmodel:readpast changes, newest first
GET /v2/field-changes/:idmodel:readone change, with the values that failed to convert
POST /v2/field-changes/:id/cancelmodel:updatecancel, until the model starts moving to the new shape
POST /v2/field-changes/:id/resumemodel:updateresume 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.

RoutePermissionDoes
POST /v2/tenants/api-keysapi_key:createmint a key
GET /v2/tenants/api-keysapi_key:readlist keys. Add ?includeStatus=true for active, allowedOrigins, name, keyPrefix, scopes, expiresAt and lastUsedAt
GET /v2/tenants/api-keys/grantableapi_key:readthe scopes and presets a key can be given, and which of them you hold
PATCH /v2/tenants/api-keys/:idapi_key:updatechange name, scopes or expiresAt
POST /v2/tenants/api-keys/:id/rotateapi_key:updatemint a successor. Both work for graceHours (0 to 168, default 24)
PATCH /v2/tenants/api-keys/:id/activeapi_key:update{ "active": false } revokes a key
PATCH /v2/tenants/api-keys/:id/allowed-originsapi_key:updateset the origins a key may be used from, up to 50
GET /v2/tenants/api-keys/:id/domains-seenapi_key:readthe domains a key was used from in the last 30 days
DELETE /v2/tenants/api-keys/:idapi_key:deletedelete 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

RoutePermissionDoes
GET /v2/projectsany signed-in personevery project you are a member of, with its id, name, slug, your role and when you last opened it
PATCH /v2/projects/:idthe owner, or an adminchange the project's slug, the name in its admin URLs
GET /v2/projects/resolvemodel:read, and instance:read with entryturn 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"}'