Skip to content

Scopes

The eleven scopes, their console labels, exactly which endpoints each one unlocks, and how to give an integration different scopes.

Open API keys

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/calls
  • GET /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/conversations
  • GET /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/contacts
  • GET /api/v1/contacts/lookup
  • GET /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/tickets
  • GET /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/campaigns
  • GET /api/v1/campaigns/{campaign_id}
  • POST /api/v1/campaigns/{campaign_id}/evaluate
  • GET /api/v1/campaigns/{campaign_id}/recipients
  • GET /api/v1/campaigns/{campaign_id}/outcomes
  • GET /api/v1/apps/duesly/subscribers
  • GET /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/campaigns
  • POST /api/v1/campaigns/{campaign_id}/launch
  • POST /api/v1/campaigns/{campaign_id}/pause
  • POST /api/v1/campaigns/{campaign_id}/resume
  • POST /api/v1/campaigns/{campaign_id}/cancel
  • GET /api/v1/campaigns/{campaign_id}/outcomes.csv
  • POST /api/v1/apps/duesly/subscribers
  • POST /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:

JSON
["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.

Tick every scope the integration needs here. You can't add one later.

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. 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. 2

    Deploy it

    Copy the key from the Copy your API key dialog into your secret manager and deploy. Confirm with GET /ping that the new key holds the scopes you expect.

  3. 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 #

403 Forbidden
{
"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:

GET/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:write and 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.updated and ticket.replied webhook events carry ticket_id, public_ref, subject, status, priority, and channel — and ticket.replied adds whether the reply actually reached the customer. A consumer that receives those needs no API key and therefore no scope at all. Polling GET /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 #

Shell
curl https://services.amomic.in/api/v1/ping \
-H "Authorization: Bearer $AMOMIC_API_KEY"
200 OK
{
"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.