API tokens, scopes and capabilities
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 |
|---|---|
| An Atlas-minted key. This is what you normally get. |
| A locally issued personal access token — the fallback path. |
| 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/meSet 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. |
| Author. The write methods. |
| 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."
The recommended form
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
adminGET /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 |
|---|---|
| Required. Up to 80 characters. What this token is for, so a list of them is readable a year later. |
| Required. |
| Up to 3650. Omit for a token that never expires on its own. |
| Up to 50. Omit for everywhere its owner can go. |
| 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_:
| The old secret |
|---|---|
left out, or | Refused from the very next request, on every API server. The default. |
| 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 /patsEach 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 | No token, a malformed |
403 | Gate 1 ( |
A Cloudflare block page, not JSON | A default library |
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 |
|---|---|---|---|
| List device keys |
| |
| Create a device key |
| |
| Delete the device key |
|
Pat
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| List personal access tokens |
| |
| Create a personal access token |
| |
| List scopes |
| |
| Rename a token |
| |
| Delete the personal access token |
| |
| Rotate the personal access token |
|