# Scopes

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

Source: https://docs.amomic.in/api/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/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](/webhooks/events) 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](#the-tickets-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](/api/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](/apps/overview) 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](/agents-setup) 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.

![The Create an API key dialog with a name filled in and only Send messages ticked; Write badges mark the scopes that change things](https://docs.amomic.in/screens/developers/create-key.light.webp)

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

::: tip 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](/api/authentication) 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:

::: steps

::: step 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.
:::

::: step 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.
:::

::: step 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](/api/authentication#rolling-a-key-without-downtime).

## When a scope is missing

```json title="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](#changing-what-a-key-can-do) 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](/api/authentication#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](/api/errors).

::: warning 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](/api/errors) for the full retryable/terminal split.
:::

## The tickets asymmetry

Reading a ticket requires `tickets:write`:

```endpoint
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](/webhooks/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

::: codegroup

```bash cURL
curl https://services.amomic.in/api/v1/ping \
  -H "Authorization: Bearer $AMOMIC_API_KEY"
```

```javascript Node.js
const response = await fetch("https://services.amomic.in/api/v1/ping", {
  headers: { Authorization: `Bearer ${process.env.AMOMIC_API_KEY}` },
});
const { scopes } = await response.json();
if (!scopes.includes("messages:send")) {
  throw new Error(`AMOMIC_API_KEY lacks messages:send (has: ${scopes.join(", ")})`);
}
```

```python Python
import os, requests

response = requests.get(
    "https://services.amomic.in/api/v1/ping",
    headers={"Authorization": f"Bearer {os.environ['AMOMIC_API_KEY']}"},
    timeout=10,
)
scopes = response.json()["scopes"]
if "messages:send" not in scopes:
    raise RuntimeError(f"AMOMIC_API_KEY lacks messages:send (has: {', '.join(scopes)})")
```

:::

```json title="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

::: cards

::: card Authentication
[How keys work](/api/authentication): format, rolling, revoking, and the six causes of a 401.
:::

::: card Errors
[Every code and reason](/api/errors), including which are worth retrying.
:::

::: card Webhook events
[The twenty events](/webhooks/events): how to observe without holding a key that can act.
:::

::: card Going live
[The pre-launch checklist](/operate/going-live), including key and scope review.
:::

:::
