Roles and permissions
Two independent axes: the workspace role a caller holds, and the scopes a key carries. Both must clear.
Authorisation here has two axes, checked separately, and a request has to clear both:
- The workspace role of the person the call acts as —
viewer,editor,adminorowner. Every guarded operation declares a floor. - The scopes on the credential, if it is an API key. Scopes decide which operations a key may reach; they never grant authority.
A key that holds * but belongs to a viewer still cannot create a ticket. A
person who is an owner still cannot use a key scoped only to tickets:read to
read customers. Neither axis substitutes for the other.
The four roles
They are a strict ladder. Each role can do everything the one below it can.
| Role | Rank | In practice |
|---|---|---|
viewer | 0 | Reads everything in the workspace. Changes nothing. |
editor | 1 | The working agent: creates and edits tickets, replies, applies macros, manages todos and time. |
admin | 2 | Configuration and people: deletes things, mints keys, manages members, reads audit and billing. |
owner | 3 | Everything an admin can do, plus one rule below. |
A floor means "this role or higher". The role is re-read from the membership record on every request — there is no cached role and no token claim to trust.
What an owner can do that an admin cannot
Exactly one rule, and it is worth knowing because it is not a list of routes:
Nobody may grant a role above their own.
An admin cannot make someone an owner, and cannot invite someone as an owner. Both attempts are refused identically:
{
"role": "admin",
"required_role": "owner",
"error": "insufficient_role",
"message": "role 'admin' cannot grant role 'owner'"
}There is no owner-only route. No billing gate, no workspace-delete operation — an admin reaches everything an owner reaches. The difference is only in what role they may confer.
Two related protections, both 409:
- The last owner cannot be demoted —
cannot demote the last owner. - The last owner cannot be removed —
cannot remove the last owner.
The three refusals, and how to tell them apart
| Response | Meaning |
|---|---|
401 identity_required | The operation needs a person and none was resolved. |
403 insufficient_role | A known member, below the floor. Carries role and required_role. |
404 user_not_found | Not a member of this workspace — or the workspace does not exist. The two are byte-identical on purpose. |
A read needs a workspace, but not necessarily a person
At the viewer floor no actor is required at all: a request that names a
workspace and no user is served. Every floor above viewer demands a
resolvable identity. This is why a broken user id gives you 404 on a read but
401 on a write.
The floors, by group
Every published operation and the floor it sits at.
Two floors in the generated reference are being corrected
POST /v1/tickets/bulk is enforced at editor, not admin, and
GET /v1/csat/aggregate is enforced at admin, not viewer. The table
below states the enforced floors. Where an operation page still disagrees,
trust this page — these were verified against the running runtime.
Tickets and the ticket rail
| Floor | Operations |
|---|---|
viewer | list_tickets, get_ticket, list_todos, list_time_logs |
editor | create_ticket, patch_ticket, add_event, merge_ticket, reopen_ticket, bulk_update, create_todo, patch_todo, delete_todo, create_time_log, delete_time_log |
Customers
| Floor | Operations |
|---|---|
viewer | list_customers, get_customer |
editor | create_customer, patch_customer |
admin | delete_customer |
Helpdesk operations — teams, macros, SLA, saved views, squads
| Floor | Operations |
|---|---|
viewer | every list_* and get_* |
editor | every create_* and patch_*, plus apply_macro and set_team_members |
admin | every delete_*, plus team_deletion_impact |
The pattern is consistent: read at viewer, write at editor, delete at admin.
Channels
| Floor | Operations |
|---|---|
viewer | list_channels, get_channel, get_inbound_address, get_mailbox, get_sender, get_sending_domain |
editor | upsert_channel_connection |
admin | everything that mints, rotates, deletes or verifies — addresses, mailboxes, sending domains, OAuth |
public | ingest_channel_message, google_oauth_callback |
Workspace administration
| Floor | Operations |
|---|---|
viewer | list_workspace_members, settings_overview, list_members, list_phone_numbers |
admin | audit events, billing, sessions, the security allowlist, member patch and remove, MCP access |
Invitations
| Floor | Operations |
|---|---|
public | preview_invitation, claim_invitation |
identity | accept_invitation |
admin | list_invitations, issue_invitation, resend_invitation, revoke_invitation |
Everything else
| Floor | Operations |
|---|---|
viewer | notifications (including mark_read), get_my_tenant, time_tracking_summary, all help-centre staff reads |
editor | patch_my_tenant_onboarding, patch_article, post_adopt_conflict, team_round_robin_assign |
admin | patch_my_tenant, put_brand, the four API-key operations, csat_aggregate |
public | all seven public help-centre reads, submit_csat, ingest_email, widget_create_ticket, receive_webhook |
Scopes
Scopes apply only to API keys. A session token has no scopes; it is bounded by role alone.
The grammar
A scope is derived from the operation's tag and an action:
<tag>:read <tag>:write <tag>:* *There is no fixed list of scope strings in the product — the set is derived from
the live route table, so it is <tag>:read, <tag>:write or <tag>:* for any
of the runtime's 67 tags, plus * for everything. That is a little over two
hundred valid strings.
The 17 tags in this reference are a subset. A key can be scoped to tags this reference does not document.
Which scope an operation needs
The action follows the role floor, not the HTTP method:
- Floor is
viewer→ the operation needs<tag>:read. - Floor is anything higher → the operation needs
<tag>:write.
An admin-floored GET needs a write scope
This catches people. GET /v1/workspace/audit-events sits at the admin floor,
so its scope is workspace-admin:write even though it only reads. A key
built to read audit events must hold a write scope. The same applies to the
API-key operations (admin-resources:write), invitations
(invitations:write) and team_deletion_impact (helpdesk-ops:write).
How grants match
*grants everything.<tag>:*grants both actions on that tag.<tag>:writeimplies<tag>:read— on the same tag only.- Nothing implies anything across tags.
# A key scoped ["tickets:write"] only:
GET /v1/tickets # 200 — write implies read
POST /v1/tickets # 200 — direct match
GET /v1/customers # 403 api_key_out_of_scope: needs 'customers:read'Scopes are case-sensitive, and a key may carry at most 64 of them.
A key with no scopes reaches nothing
Minting a key without a scopes array gives it [], and [] is refused at
every operation before routing even happens:
{
"error": "api_key_unscoped",
"message": "this api key carries no scopes and can reach no operation"
}That is fail-closed by design. It is not a misconfiguration you will discover slowly.
A key never outranks its owner
This is the most important property of the key model, and it is worth stating without hedging because it has been verified end to end:
A key borrows its owner's live workspace role. It holds no authority of its own.
Concretely, with one unchanged key holding *, as its owner's membership was
moved underneath it:
| Owner's state | The same request |
|---|---|
admin | GET /v1/api-keys → 200 |
demoted to editor | GET /v1/api-keys → 403 insufficient_role |
demoted to viewer | POST /v1/tickets → 403 insufficient_role |
| account suspended | any request → 403 user_inactive |
| membership removed | any request → 403 api_key_owner_not_member |
| membership restored | works again immediately |
There is no cache, no grace period, and nothing to revoke separately. Demoting a person demotes their keys on the very next request.
A key's identity cannot be overridden by headers
Presenting a key replaces any X-User-Id and X-Tenant-Id on the request
with the key's own owner and workspace. You cannot borrow a key and point it at
a different person, and pointing it at another workspace is refused with
api_key_workspace_mismatch.
The honest limits of an API key
Four things a key can never do, no matter how it is scoped.
It cannot reach an operation that answers "for a person"
Two published operations are unreachable by any key at any scope:
GET /v1/me/workspacesPOST /v1/invitations/{token}/accept
Both answer about a user across workspaces, and a key acts inside exactly one. The refusal is its own identifier, because no scope is the remedy:
{
"error": "api_key_identity_only",
"message": "this operation answers for a user, and an api key acts in one workspace"
}It cannot span workspaces
One key, one workspace, permanently. There is no workspace parameter that widens it and no scope that crosses the boundary.
It cannot exceed its owner
* is a grant over surface, not over authority. See the table above.
It cannot be recovered
The secret is returned exactly once, in the create response, and no operation ever returns it again. Lose it and you mint a new one.
Revoke against delete
Both stop the key immediately and neither is reversible. They differ in what they leave behind:
| Revoke | Delete | |
|---|---|---|
| Response | 200 {"revoked": true} | 204, no body |
| The holder sees | 401 api_key_revoked | 401 api_key_invalid |
| Row kept | Yes — name, prefix, who minted it, revoked_at | No |
| Visible afterwards | Only with ?include_revoked=true | Never |
Revoke when you want the audit trail — it keeps the record that the key existed and was deliberately killed. Delete when the key was a mistake you want gone.
`last_used_at` is always null
The API-key record carries a last_used_at field and nothing ever writes to
it. It is null for every key, including keys that have authenticated thousands
of requests. Do not use it to find unused or misconfigured keys — it cannot tell
you that. Use the audit log instead.
Refusal order
When a key is presented, checks run in this order. The first failure wins, so this tells you what a given refusal has already ruled out:
api_key_invalid (401) unknown prefix, wrong secret, or malformed
api_key_revoked (401)
api_key_expired (401)
user_inactive (403) the owner's account is switched off
api_key_unscoped (403) no scopes at all
api_key_workspace_mismatch (403)
api_key_owner_not_member (403)
--- routing happens here ---
api_key_identity_only (403)
api_key_out_of_scope (403)
insufficient_role (403) the owner's role is below the floorapi_key_invalid deliberately covers three different failures, so a caller
holding no valid key learns nothing from it. Everything below it is only
reachable after the secret has matched, so each of those refusals tells its
legitimate holder something they are entitled to know.