Every public API request carries a bearer token:
GET /api/v1/content/article HTTP/1.1
Host: cms.example.com
Authorization: Bearer orthacms_…
X-Workspace-Id: 3f7a0c2e-5b1d-4e8a-9f3c-7d2e1a6b4c90The same token works on REST, GraphQL and — with
MCP_ENABLED=true — MCP. All request headers are listed in
the overview.
Minting a token
In the admin: API tokens → New token. Pick a name, the workspaces it covers,
Read-only or Full access, and an expiry (Never, 30 days,
90 days, 1 year — 30 days unless you change it). The token is
shown with Try it, a ready curl request with both headers to copy.
- The secret starts
orthacms_and is shown once. Only a hash is stored. Lose it, mint a new one. - The list shows a non-secret prefix, who created it and Last used for each token, and flags one that expires within 30 days as Expires soon.
The same thing over the admin API (session cookie, Admin role):
curl -X POST https://cms.example.com/api/api-tokens \
-b 'orthacms_session=…' \
-H 'Content-Type: application/json' \
-d '{
"name": "Marketing site build",
"workspaceIds": ["3f7a0c2e-5b1d-4e8a-9f3c-7d2e1a6b4c90"],
"scope": "read",
"expiresAt": "2027-01-01T00:00:00Z"
}'{
"id": "e1c9…",
"name": "Marketing site build",
"workspaceIds": ["3f7a0c2e-5b1d-4e8a-9f3c-7d2e1a6b4c90"],
"scope": "read",
"lookupPrefix": "orthacms_ab12cd",
"expiresAt": "2027-01-01T00:00:00.000Z",
"lastUsedAt": null,
"revokedAt": null,
"createdAt": "2026-09-30T10:00:00.000Z",
"secret": "orthacms_ab12cd…"
}| Route | Effect | Permission |
|---|---|---|
POST /api/api-tokens | Mint. name, workspaceIds (1–100), scope, optional ISO 8601 expiresAt. Returns the secret once. | tokens:create |
GET /api/api-tokens | One page of metadata, never a secret. ?workspaceId=, ?status=active|expired|revoked and ?q= narrow; each item names its createdBy, and a summary counts every status. | tokens:read |
DELETE /api/api-tokens/:id | Revoke. 204, even for an unknown or already revoked id. | tokens:delete |
- Only the Admin role holds these permissions. A token cannot mint a token.
- An
expiresAtin the past is a 400. Omit it for no expiry. - Revocation is immediate: every request looks the token up.
Scopes
| Scope | Grants | Use for |
|---|---|---|
read | content:read, media:read, segments:read | Site builds, apps, search indexers |
full | read plus content:create, content:update, content:publish, content:delete, media:create, segments:manage | Migrations, headless editors, authoring agents |
media:readon both: content reads hand out asset URLs that need the token.media:createonfull: writing a media field needs an uploaded asset id.- Neither scope can rename, move or delete assets. That is session-only.
segments:managesets an entry’s segments; it cannot edit segments themselves.
The scope is evaluated by the same permission policy as a signed-in user.
The workspace bucket
A token covers one or more workspaces. Each request targets one.
| Request | Result |
|---|---|
X-Workspace-Id in the bucket | That workspace. |
X-Workspace-Id not in the bucket | 403 This token does not cover that workspace. |
X-Workspace-Id malformed | 400 Malformed X-Workspace-Id header. |
| No header, one workspace in the bucket | That workspace. |
| No header, several workspaces | 400 This token covers 3 workspaces — name the one you want with the X-Workspace-Id header. |
| No header, bucket empty | 403. Every workspace it covered was deleted. Mint a new token. |
curl 'https://cms.example.com/api/v1/content/article' \
-H 'Authorization: Bearer orthacms_…' \
-H 'X-Workspace-Id: 3f7a0c2e-5b1d-4e8a-9f3c-7d2e1a6b4c90' const response = await fetch('https://cms.example.com/api/v1/content/article', {
headers: {
Authorization: 'Bearer ' + process.env.ORTHACMS_TOKEN,
'X-Workspace-Id': process.env.ORTHACMS_WORKSPACE_ID
}
}); curl -X POST 'https://cms.example.com/api/v1/graphql' \
-H 'Authorization: Bearer orthacms_…' \
-H 'X-Workspace-Id: 3f7a0c2e-5b1d-4e8a-9f3c-7d2e1a6b4c90' \
-H 'Content-Type: application/json' \
-d '{"query":"{ contentTypes { name } }"}' Reading drafts
A read token sees published content only. ?status=draft and ?status=any
need a full token:
curl 'https://cms.example.com/api/v1/content/article?status=any' \
-H 'Authorization: Bearer orthacms_…' # full scope{ "message": "status=any requires a token with write scope.", "error": "Forbidden", "statusCode": 403 }That 403 is what a read token gets. On a type that is not publishable,
status is ignored. See preview and drafts.
Failures
| Status | Message | Cause |
|---|---|---|
| 401 | Missing `Authorization: Bearer <token>` header. | No header, or not Bearer |
| 401 | Invalid API token. | Unknown, revoked or expired — one answer on purpose |
| 403 | This token’s scope does not allow that operation. | A read token calling a write route |
{ "message": "Invalid API token.", "error": "Unauthorized", "statusCode": 401 }A token is a workspace-wide credential
No per-type or per-entry scoping. A full token can create, publish and delete
every granted type in every workspace in its bucket. Mint one bucket per
consumer, read unless writing is required, and keep it server-side.
Only publication protection can hold a full token
back: a protected type refuses a token’s publish with a 409 unless its rule
allows token publishing.