Scopes

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:

curl https://api.dairo.app/v1/whoami \
  -H "Authorization: Bearer $DAIRO_API_KEY"

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.

ResourceRead scopeWrite scope
Mailbox (messages, threads, attachments)messages:readmessages:send
Letters (physical mail)letters:readletters:send
Dairo Phone (calls and numbers)phone:readphone:write
Inboxesinboxes:readinboxes:write
Contactscontacts:readcontacts:write
Domainsdomains:readdomains:write
Webhookswebhooks:readwebhooks:write
API keyskeys:readkeys:write
Templatestemplates:readtemplates:write
Audiencesaudiences:readaudiences:write
Storage (buckets and objects)buckets:readbuckets:write
Budgetsbudgets:readbudgets:write
Agentsagents:readagents:write
Eventsevents:readevents:write
Compliance (erasure jobs)compliance:readcompliance:write
Account and audit logsaccount:readaccount: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

ScopeEndpoints it unlocks
messages:readGET /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:sendPOST /v1/messages, POST /v1/messages/{id}/cancel, POST /v1/messages/{id}/{edit\|react\|unreact}, POST /v1/audiences/{id}/send
letters:readGET /v1/letters[/{id}][/events], POST /v1/letters/price, GET /v1/letters/templates[/{id}], GET /v1/letters/batches/{id}
letters:sendPOST /v1/letters, POST /v1/letters/{id}/cancel, POST /v1/letters/batches, POST/PATCH /v1/letters/templates[/{id}], POST /v1/letters/templates/{id}/preview
phone:readGET /v1/phone/numbers[/available\|/{id}], GET /v1/phone/calls[/{id}][/transcript\|/recording]
phone:writePOST /v1/phone/numbers, PATCH/DELETE /v1/phone/numbers/{id}
phone:callPOST /v1/phone/calls, POST /v1/phone/calls/{id}/hangup
inboxes:readGET /v1/inboxes, GET /v1/inboxes/{id}/schema, GET /v1/inboxes/{id}/verification-waits[/{waitId}]
inboxes:writePOST /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:readGET /v1/contacts[/{id}], GET /v1/contacts/me, GET /v1/contacts/{id}/messages
contacts:writePOST/PATCH/DELETE /v1/contacts[/{id}], POST /v1/contacts/{id}/handles, DELETE /v1/contacts/{id}/handles/{handleId}
domains:readGET /v1/domains
domains:writePOST /v1/domains, DELETE /v1/domains/{domain}, POST /v1/domains/{domain}/verify
webhooks:readGET /v1/webhooks, GET /v1/webhooks/{id}/deliveries
webhooks:writePOST /v1/webhooks, PATCH/DELETE /v1/webhooks/{id}, POST /v1/webhooks/{id}/{ping\|pause\|resume}, POST /v1/webhooks/{id}/deliveries/{deliveryId}/redrive
keys:readGET /v1/api-keys
keys:writePOST /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:readGET /v1/templates[/{id}], GET /v1/templates/{id}/versions[/{version}]
templates:writePOST/PATCH/DELETE /v1/templates[/{id}], POST /v1/templates/{id}/versions
audiences:readGET /v1/audiences[/{id}]
audiences:writePOST/DELETE /v1/audiences[/{id}], POST /v1/audiences/{id}/members (membership only; sending a broadcast needs messages:send)
buckets:readGET /v1/buckets[/{id}], GET /v1/buckets/{id}/objects[/{objectId}][/download], object share-links, GET /v1/share-links/{id}/opens
buckets:writePOST/PATCH/DELETE /v1/buckets[/{id}], object create/finalize/delete/batch-delete, per-object and bundle share links, DELETE /v1/share-links/{id}
budgets:readGET /v1/budgets[/{scope}]
budgets:writePUT /v1/budgets, DELETE /v1/budgets/{scope}
agents:readGET /v1/agents[/{id}], GET /v1/agents/reputation, GET /v1/agents/{id}/reputation
agents:writePOST /v1/agents, DELETE /v1/agents/{id}, POST /v1/agents/{id}/bindings, DELETE /v1/agents/{id}/reputation
events:readGET /v1/events (incl. long-poll and tail)
events:writePOST /v1/events/replay
account:readGET /v1/audit-logs, GET /v1/audit-logs/export, GET /v1/account/residency, GET /v1/notifications/preferences
account:writePATCH /v1/notifications/preferences
compliance:readGET /v1/erasure-jobs[/{id}]
compliance:writePOST /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.

BundleExpands to
messagesmessages:read, messages:send
lettersletters:read, letters:send
phonephone:read, phone:write, phone:call
adminevery scope in the matrix above
// A send-and-read mailbox key from the `messages` bundle.
const key = await dairo.apiKeys.create({
  name: "mailer",
  scopes: ["messages"],
});
// key.scopes === ["messages:read", "messages:send"]

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, plus messages:read only if it polls delivery events.
  • A broadcast send needs messages:send; it runs the same outbound pipeline as a normal send. audiences:write covers audience membership only.
  • A reputation dashboard needs agents:read, not agents: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 holding keys:write + messages:send cannot mint a domains:write key (403).
dairo api-key create --name escalate --scope domains:write
# error: Child API key scopes must be a subset of the caller scopes

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.

On this page