Scopes
The eleven scopes, their console labels, exactly which endpoints each one unlocks, and how to give an integration different scopes.
A scope is a capability you grant an API key. There are eleven values. You tick them under What this key can do when you create the key in Developers → API keys, and they cannot be changed afterwards: the console has no way to edit, widen, or narrow a key's scopes. Scopes are the only thing that partitions what a key can do. There is no per-user key, no per-agent key, and no per-deployment key — a key is workspace-wide, and its scopes are the whole story.
The set is deliberately short because scopes are named after things a customer would say out loud — "can it send messages?", "can it read our conversations?" — rather than after HTTP routes. That is why calls:write covers reading a call as well as placing one: from the outside, placing calls and checking on them are one capability.
The eleven scopes #
The create dialog shows a friendly label and a one-line description for seven of the scopes. The four campaign and appointment scopes appear under their raw names, with no description. Scopes that change things carry a Write badge.
| Scope | In the create dialog | Kind | What it allows |
|---|---|---|---|
messages:send |
Send messages | write | Send WhatsApp messages: free-form text and approved templates. |
calls:write |
Place calls | write | Place outbound AI calls, read a call and its outcome, and hang a call up. |
conversations:read |
Read conversations | read | List conversations, read one, and read its transcript. Ticked by default. |
contacts:read |
Read contacts | read | List contacts, look one up by email or phone, and read one. |
contacts:write |
Manage contacts | write | Create a contact, or match an existing one, by email or phone. It does not grant reads. |
tickets:write |
Manage tickets | write | Open a support ticket, read it back, and update its status, priority, category, and tags. There is no separate read scope. |
agents:read |
Read agents | read | List the agents in the workspace. Ticked by default. |
campaigns:read |
campaigns:read |
read | List campaigns, read one, dry-run its guardrails, read per-recipient results and the outcome report, and read Duesly subscribers and dues. |
campaigns:write |
campaigns:write |
write | Create, launch, pause, resume, and cancel campaigns, download a call campaign's outcomes CSV, add Duesly subscribers, and record Duesly payments. |
appointments:read |
appointments:read |
read | Nothing today: no /api/v1 route requires it. |
appointments:write |
appointments:write |
write | Nothing today: no /api/v1 route requires it. |
Those eleven strings are the complete vocabulary. The two appointment scopes appear in the create dialog, but they unlock no route today, so don't tick them. There is no tickets:read, no messages:read, no webhooks:*, no admin, and no wildcard.
What each scope unlocks #
messages:send #
POST /api/v1/messages
One endpoint, and the one most integrations exist for. It covers both free-form text and approved templates — there is no separate template scope, because from the workspace's point of view both are "sending a message to a customer".
calls:write #
POST /api/v1/callsGET /api/v1/calls/{call_id}POST /api/v1/calls/{call_id}/hangup
The whole call lifecycle, including the read. A key that can poll a call's outcome can also dial. If you want an integration that only watches calls, do not build it on a polled GET — subscribe to the call.ended webhook instead and give that service no calls:write scope at all.
conversations:read #
GET /api/v1/conversationsGET /api/v1/conversations/{conversation_id}GET /api/v1/conversations/{conversation_id}/messages
Read-only, and one of the two scopes on a new key. This is the scope an analytics exporter or a data-warehouse sync needs, and it is the only one it needs.
contacts:read #
GET /api/v1/contactsGET /api/v1/contacts/lookupGET /api/v1/contacts/{contact_id}
Read-only directory access. The response deliberately excludes private agent memory.
contacts:write #
POST /api/v1/contacts
This scope only permits the upsert. Grant contacts:read separately to list or look up records.
tickets:write #
POST /api/v1/ticketsGET /api/v1/tickets/{ticket_id}PATCH /api/v1/tickets/{ticket_id}
Both directions, on one scope. See the asymmetry below.
agents:read #
GET /api/v1/agents
Read-only, and the second scope on a new key. Lists the agents in the workspace with their name and status, so an integration can resolve an agent_id without a human copying one out of the console.
campaigns:read #
GET /api/v1/campaignsGET /api/v1/campaigns/{campaign_id}POST /api/v1/campaigns/{campaign_id}/evaluateGET /api/v1/campaigns/{campaign_id}/recipientsGET /api/v1/campaigns/{campaign_id}/outcomesGET /api/v1/apps/duesly/subscribersGET /api/v1/apps/duesly/dues
Read-only, including the dry-run — evaluate sends nothing, so a monitoring
integration can ask "would this launch?" without holding a scope that can send.
campaigns:write #
POST /api/v1/campaignsPOST /api/v1/campaigns/{campaign_id}/launchPOST /api/v1/campaigns/{campaign_id}/pausePOST /api/v1/campaigns/{campaign_id}/resumePOST /api/v1/campaigns/{campaign_id}/cancelGET /api/v1/campaigns/{campaign_id}/outcomes.csvPOST /api/v1/apps/duesly/subscribersPOST /api/v1/apps/duesly/dues/{due_id}/paid
outcomes.csv is a read that needs the write scope. The file carries every column of the list you uploaded, including emails, which the per-recipient read does not. A reporting key reads /outcomes and /recipients instead. See Call outcomes.
Campaigns are the one area where read and write are separate scopes, and the reason is scale: a write here reaches thousands of people at once. A reporting integration that only needs last month's numbers should not hold a credential that can start a new broadcast.
An app is part of Campaigns rather than a product of its own, so it shares these scopes. Minting a new pair for every app we ever ship is how scope lists become unreadable.
The matrix #
| Endpoint group | Required scope |
|---|---|
POST /messages |
messages:send |
POST /calls, GET /calls/{id}, POST /calls/{id}/hangup |
calls:write |
GET /conversations, GET /conversations/{id}, GET /conversations/{id}/messages |
conversations:read |
GET /contacts, GET /contacts/lookup, GET /contacts/{id} |
contacts:read |
POST /contacts |
contacts:write |
POST /tickets, GET /tickets/{id}, PATCH /tickets/{id} |
tickets:write |
GET /agents |
agents:read |
GET /campaigns, GET /campaigns/{id}, POST /campaigns/{id}/evaluate, GET /campaigns/{id}/recipients, GET /campaigns/{id}/outcomes |
campaigns:read |
POST /campaigns, POST /campaigns/{id}/launch, /pause, /resume, /cancel, GET /campaigns/{id}/outcomes.csv |
campaigns:write |
GET /apps/duesly/subscribers, GET /apps/duesly/dues |
campaigns:read |
POST /apps/duesly/subscribers, POST /apps/duesly/dues/{id}/paid |
campaigns:write |
No endpoint requires two scopes. No scope is implied by another: holding messages:send does not give you conversations:read, even though a message you sent appears in a conversation, and campaigns:write does not give you campaigns:read.
Endpoints that need no scope #
Three routes sit outside the matrix.
| Route | Requires |
|---|---|
GET /api/v1/openapi.json |
Nothing. No key at all. |
GET /api/v1/ping |
Any valid key, whatever its scopes. |
GET /api/v1/channels |
Any valid key, whatever its scopes. |
ping and channels are scope-free on purpose: they are the two calls you make before you know whether the rest of your configuration is right. If ping needed a scope, a misconfigured key would fail the diagnostic and the real request identically, and you would learn nothing from the difference. As it stands, a 200 from ping followed by a 403 from POST /messages tells you the key is fine and only its scopes are wrong.
openapi.json needs no key because a machine-readable contract is not a secret — it describes the shape of the API, not your workspace. Hand it to a client generator or to a coding agent before you have a key at all.
New keys are read-only #
The create dialog starts with exactly two scopes ticked, Read conversations and Read agents:
["conversations:read", "agents:read"]
That is deliberate: a read-only key that leaks can't message your customers. But because scopes can't be changed after creation, tick every scope the integration needs before you click Create key. A key made for order updates needs Send messages; a CRM dialer needs Place calls. Untick the two defaults if the integration doesn't read conversations or agents. Create key stays disabled until the key has a name and at least one scope.
Scope by system, not by convenience
Three keys with one scope each beat one key with six. When the CRM sync leaks, you revoke a key that could only write contacts. When the analytics exporter leaks, you revoke a key that could only read. Combine that with naming each key after its system and an incident becomes a lookup rather than an investigation.
Scopes are deduplicated and sorted when saved, so the order you tick them in does not matter and GET /ping always returns them alphabetically.
Changing what a key can do #
You can't edit a key's scopes, or its name, after it is created. To give an integration more or fewer scopes, replace the key:
- 1
Create a replacement key
In Developers → API keys, click Create key. Give it the same name as the old one and tick the scopes the integration needs now.
- 2
Deploy it
Copy the key from the Copy your API key dialog into your secret manager and deploy. Confirm with
GET /pingthat the new key holds the scopes you expect. - 3
Revoke the old key
Click Revoke on the old key's row, then Revoke key. The row stays in the list with a Revoked status.
Roll does not help here: the replacement it creates copies the old key's scopes exactly. Roll is for rotating a secret; see Authentication.
When a scope is missing #
{"error": {"code": "permission_denied","message": "This API key does not have the `messages:send` scope.","details": {"reason": "missing_scope","required_scope": "messages:send"},"request_id": "req_01JQRS4T8XZ0AB1CD2EF3GH4","correlation_id": "cor_01JQRS4T8XZ0AB1CD2EF3GH4"}}
details.required_scope is the machine-readable answer: it names the exact scope the key is missing. Branch on it rather than parsing the message, then replace the key with one that holds it.
A missing_scope is also a proof of everything upstream of it. Scope is the last check in the validation order, so it means your key is genuine, unrevoked, in the right environment, in a workspace in good standing, and under its rate limit. The credential is fine; only the grant is wrong. Rolling won't fix it, and neither will waiting.
The other 403 on the API is workspace_suspended, which has no required_scope. That one is about billing, not scopes; see Errors.
A 403 is not retryable
Nothing about waiting and trying again changes the scopes on a key. Treat permission_denied as terminal in your retry logic, log required_scope, and alert a human. See Errors for the full retryable/terminal split.
The tickets asymmetry #
Reading a ticket requires tickets:write:
/api/v1/tickets/{ticket_id}There is no tickets:read scope, so there is no way to grant a key the ability to check a ticket's status without also granting it the ability to open tickets.
This is worth planning around rather than working around. Two practical consequences:
- A status-checking integration can create tickets. If you build a dashboard that polls ticket status, its key can also open tickets. Scope that key to
tickets:writeand nothing else, so the worst case of a leak is spurious tickets in your queue rather than messages to your customers. - Prefer the webhook for status.
ticket.created,ticket.updatedandticket.repliedwebhook events carryticket_id,public_ref,subject,status,priority, andchannel— andticket.repliedadds whether the reply actually reached the customer. A consumer that receives those needs no API key and therefore no scope at all. PollingGET /tickets/{id}is the fallback for when you have missed an event, not the primary mechanism.
The same shape appears on calls:write: reading a call needs the scope that places calls. The rule of thumb for both: if you only need to observe, observe over webhooks (call.ended and call.outcome for calls). Contacts and campaigns have separate read scopes, so a reporting key can read them without being able to change anything.
Checking what a key holds #
curl https://services.amomic.in/api/v1/ping \-H "Authorization: Bearer $AMOMIC_API_KEY"
{"ok": true,"workspace_id": "ten_01JQRS4T8XZ0AB1CD2EF3GH4","key_name": "Order service","environment": "live","scopes": ["agents:read", "conversations:read", "messages:send"]}
Assert this in your integration's startup check. A service that fails fast at boot with "expected messages:send, key has only conversations:read" is far cheaper than one that discovers the same fact at 2 a.m. on the first order of the day.
Next #
Authentication
How keys work: format, rolling, revoking, and the six causes of a 401.
Errors
Every code and reason, including which are worth retrying.
Webhook events
The twenty events: how to observe without holding a key that can act.
Going live
The pre-launch checklist, including key and scope review.