How API key scopes work: the read and write scope per resource, convenience bundles, and how child keys stay within their parent's access.
Every API key carries a set of scopes that decide what it can do. Give each key the narrowest set its job needs: a sender that can only send, a webhook worker that can only read, an admin key that can do everything.
Scopes follow one pattern. Every resource has its own read and write pair, a read is
never gated behind a write, and no scope is borrowed across resources: listing inboxes
needs inboxes:read, creating one needs inboxes:write.
Check what any key can do with whoami:
The scope matrix
Scopes use a resource:action grammar: a read/write pair for each resource you can
change, plus a read-only scope for account data.
| Resource | Read scope | Write scope |
|---|---|---|
| Mailbox (messages, threads, attachments) | messages:read | messages:send |
| Letters (physical mail) | letters:read | letters:send |
| Dairo Phone (calls and numbers) | phone:read | phone:write |
| Inboxes | inboxes:read | inboxes:write |
| Contacts | contacts:read | contacts:write |
| Domains | domains:read | domains:write |
| Webhooks | webhooks:read | webhooks:write |
| API keys | keys:read | keys:write |
| Templates | templates:read | templates:write |
| Audiences | audiences:read | audiences:write |
| Storage (buckets and objects) | buckets:read | buckets:write |
| Budgets | budgets:read | budgets:write |
| Agents | agents:read | agents:write |
| Events | events:read | events:write |
| Compliance (erasure jobs) | compliance:read | compliance:write |
| Account and audit logs | account:read | account:write |
messages:read covers the mailbox only (messages, threads, attachments) and
messages:send sends messages only; everything else uses its own resource scope. Dairo
Phone adds a third action scope on top of its read/write pair: phone:call places and
ends live calls, separate from phone:read (list and inspect) and phone:write (buy,
bind, and release numbers).
What each scope unlocks
| Scope | Endpoints it unlocks |
|---|---|
messages:read | GET /v1/messages (incl. ?direction=outbound and ?channel=a2a), GET /v1/messages/{id}, GET /v1/messages/{id}/events, POST /v1/messages/batch-delete, GET /v1/threads[/{id}], GET /v1/attachments/{id}[/url\|/link\|/download] |
messages:send | POST /v1/messages, POST /v1/messages/{id}/cancel, POST /v1/messages/{id}/{edit\|react\|unreact}, POST /v1/audiences/{id}/send |
letters:read | GET /v1/letters[/{id}][/events], POST /v1/letters/price, GET /v1/letters/templates[/{id}], GET /v1/letters/batches/{id} |
letters:send | POST /v1/letters, POST /v1/letters/{id}/cancel, POST /v1/letters/batches, POST/PATCH /v1/letters/templates[/{id}], POST /v1/letters/templates/{id}/preview |
phone:read | GET /v1/phone/numbers[/available\|/{id}], GET /v1/phone/calls[/{id}][/transcript\|/recording] |
phone:write | POST /v1/phone/numbers, PATCH/DELETE /v1/phone/numbers/{id} |
phone:call | POST /v1/phone/calls, POST /v1/phone/calls/{id}/hangup |
inboxes:read | GET /v1/inboxes, GET /v1/inboxes/{id}/schema, GET /v1/inboxes/{id}/verification-waits[/{waitId}] |
inboxes:write | POST /v1/inboxes, PATCH/DELETE /v1/inboxes/{id}, PUT/DELETE /v1/inboxes/{id}/schema, POST/DELETE on verification-waits, and Slack app management (/v1/slack/*) |
contacts:read | GET /v1/contacts[/{id}], GET /v1/contacts/me, GET /v1/contacts/{id}/messages |
contacts:write | POST/PATCH/DELETE /v1/contacts[/{id}], POST /v1/contacts/{id}/handles, DELETE /v1/contacts/{id}/handles/{handleId} |
domains:read | GET /v1/domains |
domains:write | POST /v1/domains, DELETE /v1/domains/{domain}, POST /v1/domains/{domain}/verify |
webhooks:read | GET /v1/webhooks, GET /v1/webhooks/{id}/deliveries |
webhooks:write | POST /v1/webhooks, PATCH/DELETE /v1/webhooks/{id}, POST /v1/webhooks/{id}/{ping\|pause\|resume}, POST /v1/webhooks/{id}/deliveries/{deliveryId}/redrive |
keys:read | GET /v1/api-keys |
keys:write | POST /v1/api-keys, PATCH/DELETE /v1/api-keys/{id} (also required to mint child keys). A key revokes itself with DELETE /v1/api-keys/current, which needs no scope. |
templates:read | GET /v1/templates[/{id}], GET /v1/templates/{id}/versions[/{version}] |
templates:write | POST/PATCH/DELETE /v1/templates[/{id}], POST /v1/templates/{id}/versions |
audiences:read | GET /v1/audiences[/{id}] |
audiences:write | POST/DELETE /v1/audiences[/{id}], POST /v1/audiences/{id}/members (membership only; sending a broadcast needs messages:send) |
buckets:read | GET /v1/buckets[/{id}], GET /v1/buckets/{id}/objects[/{objectId}][/download], object share-links, GET /v1/share-links/{id}/opens |
buckets:write | POST/PATCH/DELETE /v1/buckets[/{id}], object create/finalize/delete/batch-delete, per-object and bundle share links, DELETE /v1/share-links/{id} |
budgets:read | GET /v1/budgets[/{scope}] |
budgets:write | PUT /v1/budgets, DELETE /v1/budgets/{scope} |
agents:read | GET /v1/agents[/{id}], GET /v1/agents/reputation, GET /v1/agents/{id}/reputation |
agents:write | POST /v1/agents, DELETE /v1/agents/{id}, POST /v1/agents/{id}/bindings, DELETE /v1/agents/{id}/reputation |
events:read | GET /v1/events (incl. long-poll and tail) |
events:write | POST /v1/events/replay |
account:read | GET /v1/audit-logs, GET /v1/audit-logs/export, GET /v1/account/residency, GET /v1/notifications/preferences |
account:write | PATCH /v1/notifications/preferences |
compliance:read | GET /v1/erasure-jobs[/{id}] |
compliance:write | POST /v1/erasure-jobs |
GET /v1/whoami and GET /v1/telegram/voices need no specific scope: any valid key
works. Agent verification and the public keys
(GET /v1/agents/verify, GET /v1/agents/jwks) are public and need no key at all.
Scopes gate the MCP server and CLI too. The MCP server
authenticates with the same key, so a tool call needs the same scope as the REST endpoint
behind it: send_message needs messages:send, list_contacts and manage_contacts
need contacts:read and contacts:write, and so on. A missing scope comes back as
scope_missing with the exact scope named, so an agent can tell
whether it needs a broader key.
Bundles for a quick start
If you would rather not enumerate fine-grained scopes, request a bundle. A bundle expands
to its member scopes when the key is created, so whoami reports the expanded set.
| Bundle | Expands to |
|---|---|
messages | messages:read, messages:send |
letters | letters:read, letters:send |
phone | phone:read, phone:write, phone:call |
admin | every scope in the matrix above |
admin grants everything, including keys:write (minting and revoking other keys) and
compliance:write (data erasure). Reserve it for trusted automation; use a narrow scope
list everywhere else.
Least privilege in practice
Give a key the narrowest set it actually needs:
- A webhook receiver that only reads inbound messages needs
messages:read. - A transactional sender needs
messages:send, plusmessages:readonly if it polls delivery events. - A broadcast send needs
messages:send; it runs the same outbound pipeline as a normal send.audiences:writecovers audience membership only. - A reputation dashboard needs
agents:read, notagents:write.
Narrow keys make revocation surgical and usage simple to attribute. Prefer one key per
workload over a single shared admin key.
Child keys cannot escalate
A key with keys:write can mint child keys, but a child's scopes must be a subset of the
key that creates it. A messages:send-only key cannot create a key with domains:write;
an admin key can create anything. A key can never grant more than it holds.
A key without keys:write cannot mint any key: that request is a 403 naming the
missing keys:write scope.
See API keys and authentication for creating, restricting, and storing keys.