Scopes
What a key is allowed to do.
The vocabulary
A closed set, resource:action. Small enough to show a human on a checkbox list, and stable enough that a stored grant still means the same thing a year later. The same vocabulary is what a workspace ROLE is written in and what gates MCP tools, so a read-only client cannot even see a sending tool. One alphabet, three surfaces.
| Scope | Grants |
|---|---|
| emails:send | Send email |
| emails:read | Read sent messages and their delivery status |
| drafts:read | Read drafts |
| drafts:write | Create and edit drafts |
| threads:read | Read threads and messages |
| threads:write | Label, read and archive threads |
| labels:read | Read labels |
| labels:write | Create and edit labels |
| contacts:read | Read contacts |
| contacts:write | Add, edit and remove contacts |
| audiences:read | Read audiences and who is in them |
| audiences:write | Create and edit audiences, and change who is in them |
| calendar:read | Read calendar events and invitations |
| calendar:write | Create, change and respond to calendar events |
| templates:read | Read email templates and preview them |
| templates:write | Create, edit and send with email templates |
| domains:read | Read domains and their DNS status |
| domains:write | Verify and configure domains |
| webhooks:read | Read webhook endpoints and deliveries |
| webhooks:write | Create, edit and test webhooks |
| rules:read | Read mail rules and test them |
| rules:write | Create, edit and reorder mail rules |
| connections:read | Read which mailboxes are connected |
| members:read | See who is in the workspace and what they hold |
| members:write | Add and remove people, and change what they can reach |
| roles:read | Read the roles this workspace defines |
| roles:write | Create, edit and delete roles |
| settings:read | Read mailbox settings, including the signature |
| settings:write | Change mailbox settings and the signature |
| keys:write | Replace its own secret without anyone opening the console |
A key created without a considered scope list gets emails:send and nothing else. The safe default for a credential is the narrowest thing that makes it useful.
A key is capped by the role behind it
A key can be issued against a ROLE, and a role is a ceiling rather than a second grant. What the key may actually do is its own scopes INTERSECTED with that role’s permissions (key.scopes ∩ role.permissions), computed once at the boundary, on every request, before any endpoint is reached. Nothing downstream knows roles exist: a scope the role does not hold is simply not in the list the scope checks read.
So the two lists are read together and neither wins on its own. A key carrying emails:send under a role that does not hold it may not send; a role holding emails:send gives nothing to a key that never asked for it. Ticking a scope is asking for authority, and the role decides how much of what you asked for you get.
A key with NO role has no ceiling, and is therefore as wide as the workspace it was issued against. That is what every key created before roles existed carries and what an owner still gets by leaving the field alone, so a null role is the WIDEST state a key can be in, not the narrowest. It is also why deleting a role makes you say where its keys should go: orphaning them would quietly promote every one of them.
The intersection is resolved per request rather than stamped on the key at issue time. That makes narrowing a role a live revocation, in force on the caller’s next call without the key having to be rotated, and widening one live in exactly the same way, which is the half worth remembering.
GET /ping and GET /keys/self report scopes beside grantedScopes and roleId for one failure in particular. scopes is the effective list and the only one that authorises anything; grantedScopes is what the key was issued with. Anything in the second and missing from the first was taken by the role, and that difference is the whole answer to “my key has emails:send and I am getting insufficient_scope”. The fix is a role change rather than another key.
curl "$OE/ping" -H "$AUTH" { "ok": true, "keyId": "4c1b257a66287fd113bd89d0", "mode": "live", "scopes": ["emails:read", "threads:read"], "roleId": "role_c40a95f21cc65d31c2a89e07", "grantedScopes": ["emails:send", "emails:read", "threads:read"], "workspaceId": "10417196-e324-4283-af98-66ec62167c47"}Five permissions can never reach a key at all: api-keys:read, api-keys:write, billing:read, billing:write and workspace:manage. They are permissions but not scopes, so no role however generous can put them on a token: minting another key, changing what another key may do, or moving the plan is something only a signed-in person does. The one thing a key may do to itself is replace its own secret, behind the keys:write scope. GET /roles/permissions marks the five scope: false, which is what lets one component render both the role matrix and the key-creation checkbox list.
roles:write is effectively the whole vocabulary, and pretending otherwise would be the more dangerous documentation. A key holding it can PATCH the very role that caps it and hand itself everything else, and because the ceiling is resolved per request the wider one applies on the very next call. That is not a hole to plug, since a role editor that cannot edit roles is not a role editor. It is a reason not to put roles:write on a key that only ever needed to read the members list.
Send scope
Separately from scopes, a key can be narrowed in what it may send as. It carries two lists. domainAllowlist holds whole domains, and a key holding a domain may send as any address on it, including addresses created after the key was. addressAllowlist holds single addresses. Leave both empty and the key is as wide as the workspace, never wider. GET /keys/self shows both lists and GET /addresses reports what a given key may actually use, which is the answer to an unexplained from_address_forbidden.
The same set narrows what the key reads. Sent mail, tracking and calendar answer only for addresses the key may send as, so a key scoped to one domain neither sends nor reads on behalf of another. A whole domain also lets the key set that domain’s tracking host, which a key limited to single addresses cannot do.
Three narrowings, then, and they compose rather than override: the scopes on the key, the permissions of the role above it, and the domains and addresses it may put in a From header. A send needs all three, and a refusal names only the first one it met.