Skip to content

API keys and access

The three key kinds, scopes, custom roles with per-content-type and per-field rules, personal access tokens, and how a key is checked on every request.

Every request to the API or the MCP server carries a bearer token:

curl -s "$API/auth/me" -H "Authorization: Bearer $TOKEN"

GET /auth/me returns who the token is and what it may do, which makes it the quickest check that a key works. A missing or unknown token is answered with 401; a valid token without the scope a route needs, or used against another space, with 403.

Kind Name Use it for
cma Management Integrations that write: imports, sync jobs, CI, admin tooling
cpa Preview Staging sites and previews that show drafts
cda Delivery Production sites and apps: published content only

Ship only Delivery keys in anything a visitor can inspect (a browser bundle, a mobile app); they can’t read drafts or change anything. Each kind gets default scopes; a key can be minted with a narrower list.

Create keys in Settings → API keys, or with a key that holds space:admin:

curl -s "$API/spaces/$SPACE/api-keys" \
-H "Authorization: Bearer $ADMIN_KEY" -H 'content-type: application/json' \
-d '{ "kind": "cda", "name": "Website (production)" }'

The raw token is in the response once. contentworker stores only a hash of it, so a lost token can’t be recovered: revoke it and mint another. Tokens start with cwk_ and the kind, so a leaked one is easy to recognize and scan for.

Scopes are what a key may do: content:write to edit drafts, content:publish to publish, content:manage to change the content model, preview:read and delivery:read to read, and so on. Every route checks one, against the space in its path. The full list, with what each allows, is in Permission scopes.

A role narrows a key further, per content type and per field. It has a set of scopes and a list of grants:

{
"name": "Blog writer",
"scopes": ["content:write", "preview:read"],
"contentGrants": [
{ "contentTypeApiId": "article", "actions": ["read", "write"], "readOnlyFields": ["slug"] },
{ "contentTypeApiId": "author", "actions": ["read"], "deniedFields": ["email"] }
]
}
  • A key bound to a role can only touch the content types its grants name (* matches any type; an exact grant wins over *). Types without a grant are invisible to it, in lists too.
  • Denied fields are removed from every response and refused on write. Read-only fields can be read but not written.
  • Roles are live: editing a role changes what every key bound to it can do on the next request.

Create roles in Settings → Roles or at POST /spaces/{space}/roles, then bind a key with { "kind": "cma", "roleId": "…" }.

Every space also has five built-in roles, Viewer, Approver, Editor, Manager and Administrator, which you can assign, edit and clone like your own; see Team and roles.

A personal access token acts for a person rather than an integration: it carries their identity in the audit log and can never do more than they can. Create one for a script or CI job at POST /spaces/{space}/personal-access-tokens with a name, scopes, and optionally an expiry of up to a year (expiresInDays). If the person’s role is later narrowed, the token narrows with it.

AI clients connecting over MCP get a token the same way through an approval screen; see MCP server.

Every error body has the same shape:

{ "error": { "code": "validation_failed", "message": "…", "issues": [{ "field": "title", "message": "Required" }] } }
Status When
401 No token, or one that isn’t valid
403 The token lacks the scope, the content-type grant, or the space
404 No such entry, type, release…
409 A conflict: a unique value already taken, a delete blocked by links
422 Validation failed; issues lists each field and why