# Authentication

Obtaining a key, the scope catalogue, and why an agent key returns 403s that look like bugs

Every `/v1` request authenticates with an organization API key as a bearer token:

```
Authorization: Bearer kirora_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```

Keys begin `kirora_` followed by 32 random bytes in base64url. There is no other
credential — no signing, no client id, no OAuth flow.

The key identifies the organization, and every query is scoped to it. Only a partner key
may act in another organization, one of its own tenants, by naming it in the
`Kirora-Workspace` header; any other key sending that header gets **403**, and so does a
partner key sent from a browser (any request with an `Origin` header). A resource belonging to another organization returns **404, never 403**, so
that existence is not leaked.

## Obtaining a key

Keys are created in the dashboard, not over the public API — key management is a
cookie-session surface deliberately, so a leaked key cannot mint more keys.

**The plaintext key is shown exactly once, at creation.** Only a hash is stored, so a lost
key cannot be recovered; issue a new one and revoke the old.

## Scopes

A key carries an explicit list of scopes, and almost every operation requires exactly one
(`/v1/me`, `/v1/onboarding` and `/v1/webhooks/events` require none; the Customer.io and
Braze compatibility routes require both `contacts:write` and `events:write`). The
reference shows which.

| Resource | Scopes |
| --- | --- |
| Campaigns | `campaigns:read`, `campaigns:write`, `campaigns:send` |
| Contacts | `contacts:read`, `contacts:write`, `contacts:delete` (erasure) |
| Events | `events:read` (names and their properties), `events:write` (track, cancel) |
| Attributes | `attributes:read` |
| Groups | `groups:read`, `groups:write` |
| Segments | `segments:read`, `segments:write` |
| Templates | `templates:read`, `templates:write` (templates, assets, shared blocks) |
| Forms | `forms:read`, `forms:write` |
| Journeys | `journeys:read`, `journeys:write`, `journeys:activate` |
| Sending domains | `domains:read`, `domains:write` |
| Suppressions | `suppressions:read`, `suppressions:write` (import, unblock) |
| Topics | `topics:read`, `topics:write` (a contact's topic choice needs `contacts:read` / `contacts:write`) |
| Messaging | `transactional:send`, `messages:read` (a sent message's status and content), `push:send`, `push:read` (push projects) |
| Reports | `reports:read` — the overview and per-campaign performance |
| Webhooks | `webhooks:write` |
| Partner workspaces | `workspaces:manage` (partner keys only) |

Sending is a separate scope from writing throughout: `campaigns:write` lets a key build a
campaign but not send it, and `journeys:write` lets it define a journey but not activate
one. Grant the narrowest set that does the job.

### Presets

For convenience a key can be created from a named preset instead of a scope list, and the
preset is expanded at creation. Only the resulting scopes are stored, so **a key issued
today can never gain a scope added tomorrow** — that is the reason presets are not stored
as wildcards.

| Preset | Scopes |
| --- | --- |
| `read_only` | Every `:read` scope |
| `ingestion` | `contacts:read`, `contacts:write`, `events:write`, `groups:read`, `groups:write` |
| `transactional` | `transactional:send`, `messages:read`, `templates:read` |
| `full_access` | Every scope, including send and activate |

## Key kinds

This is the distinction most likely to look like a bug, so handle it explicitly.

Keys are one of three kinds. A **`partner`** key belongs to a platform that resells us and
acts in the workspaces it manages for its own customers. An **`integration`** key is an
ordinary server-to-server credential and behaves exactly as its scopes say.

An **`agent`** key is issued to an AI agent acting on the organization's behalf. It reads,
creates, edits and deletes campaigns, segments, groups, templates, journeys and domains
like any other key — but it is gated on the send path:

- It cannot send, schedule or activate anything. It may not hold `campaigns:send`,
  `journeys:activate`, `transactional:send` or `push:send`, so
  `POST /v1/campaigns/{id}/send`, `/trigger`, `/schedule`, `/v1/journeys/{id}/activate`,
  `/v1/transactional/messages` and `/v1/push/messages` all return **403 `FORBIDDEN`**.
- It can stop a send. Unscheduling or cancelling a campaign needs `campaigns:write`, and
  pausing or ending a journey needs `journeys:write`; stopping never sends anything.
  Resuming a paused journey is activation, so it stays with a person.
- It cannot mutate contacts or consent: the `contacts:write` routes return 403 for an
  agent key even when it holds that scope, and it may not hold `contacts:delete`.
- It cannot add contacts to a group, remove them from one, or track events: either can
  enrol contacts in a journey that then sends. `POST /v1/groups/{id}/contacts` and
  `/contacts/remove` return 403 for an agent key, and it may not hold
  `events:write`.
- It cannot unblock or import suppressed addresses: it may not hold `suppressions:write`.
- It may not hold `webhooks:write`: a webhook subscription streams contact data to any
  address the key names.
- A campaign or journey an agent authored *or edited* is recorded and needs a human
  confirmation in the dashboard before it can be delivered. Until then, sending,
  scheduling or activating it — with any key — answers **409
  `AGENT_CONFIRMATION_REQUIRED`**.

So a 403 from an agent key on a send route is the system working, not a scope
misconfiguration. Surface it as such: an SDK that reports "permission denied — check your
scopes" sends people hunting for a problem that does not exist.

Note also that `full_access` cannot be used to create an agent key: the preset includes
scopes agent keys may not hold (the four send scopes, `contacts:delete`,
`suppressions:write`, `events:write` and `webhooks:write`), so creation
fails validation rather than producing the 403 you were trying to test. Pass explicit
scopes instead.

The boundary is enforced in the API, not in any client. It applies equally to `curl`.

## Failure modes

| Status | Code | Meaning |
| --- | --- | --- |
| 401 | `UNAUTHORIZED` | Header missing or malformed, or the key is unknown, revoked or expired |
| 403 | `FORBIDDEN` | Key is valid but lacks the operation's scope, or is an agent key on a gated route |
| 403 | `API_KEY_IP_NOT_ALLOWED` | The request came from an address outside the key's `allowedIps` |
| 404 | `NOT_FOUND` | Resource does not exist **or** belongs to another organization |

## Restricting a key to your servers

A key can carry `allowedIps`: up to 50 addresses or CIDR ranges (`203.0.113.7`,
`10.0.0.0/8`, `2001:db8::/32`). Requests from anywhere else are refused with 403
`API_KEY_IP_NOT_ALLOWED`. Set it when creating the key, or change it later from the
dashboard; an empty list allows any address. Rotation keeps the list.

## Handling keys

Treat a key as a password: environment or secret manager, never in source control, never
in a browser or mobile app. A key grants access to the whole organization within its
scopes. Rotate by issuing a new key, deploying it, then revoking the old one — both work
during the overlap.

## Next steps

- [Errors](/docs/api/errors) — every code, including 401 `UNAUTHORIZED` and 403 `FORBIDDEN`
- [Rate limits](/docs/api/rate-limits) — how much each key may call
- [Agents](/docs/api/agents) — keys for AI assistants and what they may not do
- [Quickstart](/docs/api/quickstart) — make your first requests with a key