API tokens, scopes and capabilities

Embed
Preview of the card
<a href="https://forumulate.com/guides/api-tokens-and-scopes"><img src="https://forumulate.com/guides/_api/public/orgs/046a9bc0-d530-43ae-9bae-af7945008cda/spaces/36aa4df0-1e95-4cef-8ae9-795169441345/pages/ed4650a9-0f67-4fa6-ba8e-c8141ce098da/social-card.svg?v=3e7ae33eec8b" alt="API tokens, scopes and capabilities" width="600" height="315"></a>

A token is how anything that is not a browser talks to the API. This page says where tokens live, what the four narrowing fields actually do, and why a token can be refused by a rule you thought you had satisfied.

Where tokens live

Account → Tokens, at /account/tokens. It is on your account, not on an organization, because a token carries your reach — across every organization you belong to — narrowed by what you set on it.

Earlier pages called this screen Settings → Tokens, API keys, and the developer page. It is Account → Tokens.

What a token looks like

Prefix

What it is

ak_

An Atlas-minted key. This is what you normally get.

cft_

A locally issued personal access token — the fallback path.

cfo_

An OAuth access token, issued to an app you authorised.

POST /pats (and Account → Tokens) issues an ak_ for every account that signs in through Atlas — which is every person's account. You get a cft_ instead when the Atlas mint is unavailable at that moment (key creation never fails because of it), and always for a service account's token. Content delivery keys keep their own cfd_ / cfp_ / cfv_ prefixes. A rotated token keeps the prefix it had.

All three are sent the same way:

curl -H 'Authorization: Bearer ak_…' \
     -A 'my-app-integration/1.0' \
     https://forumulate.com/api/auth/me

Set a non-default User-Agent. The edge WAF challenges a request carrying a default curl or library User-Agent, and you get a Cloudflare block page rather than a JSON error.

The two gates

This is the part that catches people. A token passes two independent checks, and it needs both. Satisfying one does nothing on its own.

Gate 1 — scopes: which HTTP methods

scopes is a list drawn from exactly three values:

Value

What it permits

read

Read. GET, HEAD.

write

Author. The write methods.

admin

Issue further tokens.

This gate is coarse and it is about the method, not the resource. A token without write cannot POST anything, anywhere, whatever else is set on it.

Gate 2 — the catalog scopes and capabilities: which resources

scopeGrants, capabilities and constraints narrow what a token may touch. They do not grant a method.

So a token created like this cannot create a page:

// WRONG — this is the mistake to avoid
{
  "name": "docs bot",
  "scopes": ["read"],
  "scopeGrants": ["docs:write"],
}

docs:write named the right resource, but scopes says read, so every POST is refused. The working version:

{
  "name": "docs bot",
  "scopes": ["read", "write"],
  "scopeGrants": ["docs:read", "docs:write"],
}

Every operation in the API reference states its gate-1 requirement in the same sentence: "Requires token scope: write (the coarse method gate) … in addition to any catalog scope the route's capability needs."

Say what a token may do with capabilities and where with constraints, on top of the scopes gate:

{
  "name": "docs publisher",
  "scopes": ["read", "write"],
  "capabilities": ["docs.read", "docs.write", "docs.publish"],
  "constraints": { "orgIds": ["…"], "spaceIds": ["…"] },
}

That is the enforced model: the token can use those capabilities, in those places, and a request outside either answers 403. Both come back on the token exactly as you sent them.

scopeGrants and the top-level spaceIds are legacy. They still work and have no removal date, but they are second spellings of the same two things: scopeGrants expands into capabilities, and top-level spaceIds is constraints.spaceIds. The API reference marks both deprecated.

The four narrowing fields

scopes

Required. read, write, admin. Gate 1, above.

scopeGrants — named scopes from the published catalog (legacy)

Twenty-four names, each resolving to a bundle of capabilities:

docs:read        docs:write
forum:read       forum:write      forum:moderate
blog:read        blog:write
community:read   community:write
spaces:read      spaces:manage
standing:write
forms:read       forms:manage
email:manage
members:read     members:manage
webhooks:manage  settings:manage  billing:manage   sso:manage
audit:read       queries:run
admin

GET /pats/scopes returns the catalog with each scope's description and the capabilities it expands to. (That endpoint needs authentication, so read it with a token you already have, or from the API reference.)

Omit scopeGrants for a token that carries the whole of its owner's reach.

What is stored is the union of capabilities the names expand to, not the names themselves. The names come back on the token's summary for your benefit — scopeGrants there is descriptive. That is deliberate: re-pointing a scope name at a different bundle can never widen a token that already exists.

capabilities — the enforced list

The fine-grained capability enum, docs.write and the like. This is what is actually checked. Set it directly for least privilege, or let scopeGrants expand into it.

A token's reach is the intersection of its scope, its capabilities, and its owner's own grants. A token can never do something its owner cannot.

constraints — resource ids

"constraints": {
  "spaceIds":    ["…"],   // only these spaces
  "categoryIds": ["…"],   // only these forum categories
  "orgIds":      ["…"]    // only these organizations
}

A request targeting a resource outside the set is refused with 403 even when the scope, the capabilities and the owner's grants would all allow it.

spaceIds also exists as a top-level field on create (legacy — prefer constraints.spaceIds). A token confined to one space cannot even list the names of the others — which is what makes it safe to publish.

Create one over the API

POST /pats
{
  "name": "release notes bot",
  "scopes": ["read", "write"],
  "capabilities": ["docs.read", "docs.write"],
  "constraints": { "spaceIds": ["b0662f97-…"] },
  "expiresInDays": 90
}

Field

Notes

name

Required. Up to 80 characters. What this token is for, so a list of them is readable a year later.

scopes

Required.

expiresInDays

Up to 3650. Omit for a token that never expires on its own.

spaceIds

Up to 50. Omit for everywhere its owner can go.

capabilities, scopeGrants, constraints

As above. All optional.

The response carries the token once:

{
  "token": "ak_…",        // shown on this response and never again
  "pat": { "id": "…", "name": "…", "scopes": ["read","write"], "secret": "ak_…", … },
  "data": { … }           // the same object as `pat`
}

pat.secret is the same string as token, on the object you are likely to keep. It appears on the create and rotate answers only.

If you lose it, rotate or replace it. There is no endpoint that reveals it.

Device keys: one credential per app or device

A device key is a token that belongs to one app on one device, so a person can see "my devices" and revoke a lost phone without touching anything else. It is a token underneath: it authenticates through the same bearer path, carries the same narrowing fields and is revoked through the same row. What differs is that it has an applicationName and is listed separately. Creating one needs an admin-scoped token (or an ordinary signed-in session).

POST /device-keys
{
  "applicationName": "Release notes on my phone",
  "scopes": ["read"],
  "expiresInDays": 90,
  "spaceIds": ["b0662f97-…"]
}

applicationName (1 to 120 characters) and scopes (at least one) are required. expiresInDays (up to 3650), spaceIds (up to 50), capabilities, scopeGrants and constraints work exactly as for POST /pats. The response carries the key once; it is stored hashed, so a lost one is replaced, not recovered.

GET    /device-keys
DELETE /device-keys/{id}

GET lists only the caller's own device keys, as summaries with no secret, in the same paged envelope as GET /pats (limit, cursor). DELETE revokes one and answers { "ok": true }. Use GET /pats for tokens that are not tied to a device.

Service accounts and personas

A token for an integration should not be a person's: it dies when they leave, and the audit trail names them. A service account is an org-owned identity that holds tokens and nothing else — no password, no inbox:

POST /orgs/{orgId}/service-accounts
{"name": "CI publisher", "role": "MEMBER"}

POST /orgs/{orgId}/service-accounts/{id}/tokens
{"name": "ci", "scopes": ["read", "write"]}

"persona": true makes it a seeded author rather than a bot: it then counts as a person in the member directory and on leaderboards. Either kind may write without a verified email — its token, minted by an admin, is its credential. See Seeding a forum with real authors.

Tokens on the reader doors

The public reader doors (/public/orgs/{orgId}/spaces/{spaceId}/… — replies, new topics, reactions on forums, blogs, roadmaps and changelogs) accept a token too, and act as its user: a post made with one is that member's post, not a guest's. A write needs the write scope and, when the token is narrowed to spaces, this space. A bearer that is not a valid token is a 401 — it is never quietly treated as a signed-out reader. Send no Authorization header to post as a guest where the space allows it.

Scopes are immutable

PATCH /pats/{id} takes one field, name. There is no way to widen or narrow an existing token's scopes, capabilities, grants or constraints.

That is the intended design — a token's permissions are fixed at the moment it is minted, so a token you audited stays the token you audited — but it means a token whose reach is wrong has to be replaced rather than edited.

Rotate without an outage

POST /pats/{id}/rotate
  { "graceMinutes": 60 }

Rotation answers a new token: a new pat.id and a new token, with the same name, scopes, capabilities, spaces, constraints and expiry. The old id is retired — it no longer appears in GET /pats, and use the new id from now on.

What happens to the old secret is graceMinutes, and it means the same for an ak_ and a cft_:

graceMinutes

The old secret

left out, or 0

Refused from the very next request, on every API server. The default.

1 to 10080 (7 days)

Keeps working until the window ends, then is refused everywhere on the next request.

So a rotate with no body (or {}) is the answer to a leaked token, and a rotate with a window is the no-outage swap: deploy the new value during the window and the old one expires on its own. The new secret works at once either way.

The Rotate button in Account → Tokens asks for a 60-minute window, as its confirmation says.

To end a window early, revoke the old id: DELETE /pats/{oldId} works on a token still in its grace window and refuses its secret from the next request.

Revoking a token

DELETE /pats/{id} takes effect on the next request: once it has answered, that token is a 401 everywhere — for an ak_ and a cft_ alike. The same holds for a rotated token's old value once its grace window (if you asked for one) has ended, and for a privacy-executor key that has been minted again.

DELETE on an id that has already been revoked — or rotated with no grace window — is a 404 pat.not_found: there is nothing left to revoke.

Rotation preserves every other field, which is the other reason it is the answer to a leaked token rather than a delete-and-recreate.

A delivery key committed to a public repository

Delivery (cfd_) and preview (cfv_) keys are registered with GitHub secret scanning, so you configure nothing. When GitHub finds one in public code it posts the match to POST /secret-scanning/github, signed with GitHub's published key; a report whose signature does not verify is a 401. For a live key of ours, a preview key is revoked at once and a delivery key 24 hours later (an earlier revocation already scheduled is kept), delivery_key.leaked is written to the audit log, and the organization's owners and admins are notified. GitHub's alert then says whether the string was a real key. Publishable cfp_ keys are public by design and are not registered. Rotate the leaked key before its revocation lands.

Reading a token's own settings

GET /pats

Each summary carries id, name, scopes, createdAt, expiresAt, lastUsedAt, and — when set — spaceIds, capabilities, scopeGrants, constraints, plus kind (pat or device) and applicationName for a device key.

lastUsedAt is the field to check before deleting a token nobody admits to owning.

When a call is refused

Status

Likely cause

401 auth.unauthenticated

No token, a malformed Authorization header, or a token that has expired or been revoked.

403

Gate 1 (scopes lacks write for a write), gate 2 (no capability for this route), a constraints set that excludes the target, or the owner never had the grant.

A Cloudflare block page, not JSON

A default library User-Agent. Set your own.

A 403 with both gates in mind is quick to diagnose: if GET of the same resource works and POST does not, it is gate 1. If neither works, it is gate 2 or a constraint.

In the API

Every operation below is also reachable from the other surfaces without a dedicated command. forumulate api <Operation> calls it from the CLI, the JavaScript SDK has a typed method for each one (the last column names it; the Python and Go clients are generated from the same spec), and an agent reaches the organization-scoped ones through the MCP call_api tool. Where a CLI command or MCP tool exists too, the last column names it. Operation names link to the API reference.

Device Keys

Operation

Request

What it does

Shortcuts

DeviceKeys_list

GET /device-keys

List device keys

client.auth.deviceKeys.list()

DeviceKeys_create

POST /device-keys

Create a device key

client.auth.deviceKeys.create()

DeviceKeys_revoke

DELETE /device-keys/{id}

Delete the device key

client.auth.deviceKeys.revoke()

Pat

Operation

Request

What it does

Shortcuts

Pat_list

GET /pats

List personal access tokens

forumulate tokens list<br>client.tokens.list()

Pat_create

POST /pats

Create a personal access token

forumulate tokens create<br>client.tokens.create()<br>client.auth.createToken()

Pat_scopes

GET /pats/scopes

List scopes

forumulate tokens scopes<br>client.tokens.scopes()

Pat_rename

PATCH /pats/{id}

Rename a token

forumulate tokens rename<br>client.tokens.rename()

Pat_revoke

DELETE /pats/{id}

Delete the personal access token

forumulate tokens revoke<br>client.tokens.revoke()

Pat_rotate

POST /pats/{id}/rotate

Rotate the personal access token

forumulate tokens rotate<br>client.tokens.rotate()

The Forumulate teamUpdated