Beta . Ortha CMS is in public beta. Every plan is free until it ends.
Features Ortha CMS on GitHub

API tokens

API tokens plugin @orthacms/api-tokens-admin

Bearer tokens, the workspace bucket they cover, and the two scopes they come in.

Documents 0.13.1 Updated Edit this page Report a problem

On this page

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-7d2e1a6b4c90

The 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…"
}
RouteEffectPermission
POST /api/api-tokensMint. name, workspaceIds (1–100), scope, optional ISO 8601 expiresAt. Returns the secret once.tokens:create
GET /api/api-tokensOne 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/:idRevoke. 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 expiresAt in the past is a 400. Omit it for no expiry.
  • Revocation is immediate: every request looks the token up.

Scopes

ScopeGrantsUse for
readcontent:read, media:read, segments:readSite builds, apps, search indexers
fullread plus content:create, content:update, content:publish, content:delete, media:create, segments:manageMigrations, headless editors, authoring agents
  • media:read on both: content reads hand out asset URLs that need the token.
  • media:create on full: writing a media field needs an uploaded asset id.
  • Neither scope can rename, move or delete assets. That is session-only.
  • segments:manage sets 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.

RequestResult
X-Workspace-Id in the bucketThat workspace.
X-Workspace-Id not in the bucket403 This token does not cover that workspace.
X-Workspace-Id malformed400 Malformed X-Workspace-Id header.
No header, one workspace in the bucketThat workspace.
No header, several workspaces400 This token covers 3 workspaces — name the one you want with the X-Workspace-Id header.
No header, bucket empty403. Every workspace it covered was deleted. Mint a new token.
Example language
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

StatusMessageCause
401Missing `Authorization: Bearer <token>` header.No header, or not Bearer
401Invalid API token.Unknown, revoked or expired — one answer on purpose
403This 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.