How access is decided
Three checks on every request, all failing closed — and the exact error each one produces.
API keys and OAuth tokens go through one door. Whichever you send, the server resolves the same principal a signed-in session carries — a user, their business, and their rights — so every Staff boundary, location allow-list and role check downstream engages identically.
The three checks, in order
| # | Check | Fails with |
|---|---|---|
| 1 | The credential is real: it exists, is active, has not expired and has not been revoked. | `401 unauthorized` (no bearer) or `401 invalid_token` |
| 2 | The credential was **granted** this feature and this verb at issuance. | `403 insufficient_scope`, or `403 insufficient_permissions` when the feature is held but not the verb |
| 3 | The user the credential is bound to **still holds** that feature and verb right now, on a live subscription. | `403 insufficient_permissions` — "no longer holds" |
Check 3 is the one that surprises people. A credential is a narrowing of one person's rights, not a right of its own. Rights shrink after issuance — a role change, a module dropped from the plan, a lapsed subscription — and the credential must die with the demotion, not with its expiry.
The Super Administrator who minted it. An OAuth token is bound to the user who granted consent. Either way, that person's rights on the day of the call are the ceiling.
What a credential can never do
- Reach another business. Ids from another business are indistinguishable from ids that do not exist: both answer
404 not_found. Cross-tenant existence is never confirmed. - Escape a location allow-list. If the bound user is restricted to certain locations, so is the credential. Naming a location outside the allow-list is refused, not silently widened.
- Cross the Staff boundary. A Staff user is self-service by design. A credential bound to one carries the same hard limits — no reporting, no other employees' data, no ledger.
- Turn a bad id into a server error. A
5xxyou can trigger by changing an id or a field is a bug on our side. Report it.
The two capability grants
Two things a key can carry are not data scopes at all. They decide where the key works; the scopes still decide what it may touch.
| Grant | Minted with | Opens | Refused with |
|---|---|---|---|
| assistant | Allow AI assistants (MCP) | `POST /mcp` — the assistant tools | `403 insufficient_scope` — "not minted for AI assistants" |
| webhooks | Allow webhook subscriptions | `/api/v1/webhooks/*` — subscribe, unsubscribe, ping | `403 insufficient_scope` — "not minted with the Webhooks option" |
Both are API-key only. An OAuth token cannot hold either; an OAuth app manages its own webhook subscriptions from the developer portal instead.
Related
- Permission scopes — the catalogue
- Rate limits & errors — every error code in one table
- Mint an API key
- The authorization flow