Every public API request authenticates with a bearer token:
GET /api/v1/content/article HTTP/1.1
Authorization: Bearer apograph_…
X-Workspace-Id: 3f7a…Tokens are minted in the admin’s API Tokens page. The secret is shown once, at creation, and is stored hashed — there is no way to recover it later. Lose it and you mint a new one.
Scopes
| Scope | Grants |
|---|---|
read | content:read. Published entries only. |
full | The complete content CRUD set — create, update, publish, delete. |
The scope is turned into a permission set and evaluated by exactly the same policy that evaluates a logged-in user’s permissions. A route’s requirement means the same thing whichever kind of caller arrives, which is why adding a write route needed no change to the token guard.
Use read for anything that only renders content: a site build, a mobile app,
a search indexer. full is for a migration script, a headless editor, or an
external agent that authors.
The workspace bucket
A token is minted over one or more workspaces — its bucket. Each request targets exactly one of them.
| Situation | What happens |
|---|---|
X-Workspace-Id present, in the bucket | That workspace is used. |
X-Workspace-Id present, not in the bucket | 403. A token can never reach a workspace it was not minted for, whatever the header says. |
X-Workspace-Id malformed | 400. |
| Header absent, bucket has exactly one workspace | That workspace. The header is optional. |
| Header absent, bucket has several | 400, naming how many it covers. |
That last row is deliberate: guessing a workspace would silently pick one for you, and “why is this response empty?” is a far worse failure than an explicit error.
A single-workspace token needs no header at all, which is what makes the common case a one-line fetch.
Reading drafts
A read token cannot see drafts. ?status=draft and ?status=any require a
full token and are a 403 otherwise.
The widening is gated on the scope that could have published the row anyway, which is narrower than it first looks — and it exists because otherwise creating a draft over the API would produce a record the creator cannot read back: the write returns it once and it is then invisible forever.
# needs a full-scope token
curl 'https://cms.example.com/api/v1/content/article?status=any' \
-H 'Authorization: Bearer apograph_…'On a type that is not publishable, status is ignored — those rows have no
publish state and are all live.
Failures
Every authentication failure is one bare 401: no header, wrong scheme, unknown token, revoked token, expired token. They are indistinguishable on purpose, so the endpoint cannot be used to probe which tokens exist.
Authorization failures are 403 and do say what was insufficient.
Managing tokens
| Route | Effect |
|---|---|
POST /api/api-tokens | Mint a token. Returns the secret once. |
GET /api/api-tokens | List tokens — metadata only, never a secret. |
DELETE /api/api-tokens/:id | Revoke immediately. |
These are admin API routes: they take a session cookie and require the appropriate user permissions, not a bearer token. A token cannot mint another token.
Revocation takes effect immediately — verification is a database lookup on every request, not a signature check, so there is no window in which a revoked token still works.
A token is a workspace-wide credential
There is no per-type or per-entry scoping on a token, and no per-workspace role.
A full token can create, update, publish and delete every granted type in
every workspace in its bucket. Mint narrowly: one bucket per consumer, and
read unless writing is genuinely required.
The same token drives GraphQL and MCP
One credential, three protocols. The same bearer authenticates
POST /api/v1/graphql and — when the MCP plugin is enabled —
POST /api/v1/mcp, where a full token lets an external agent do content CRUD.
See the MCP endpoint.