# Authentication

Source: https://kirora.io/docs/api/reference/authentication

Create an API key, choose its scopes and understand the actions an agent key cannot perform.

Every `/v1` request authenticates with a bearer token, usually a workspace API key:

```
Authorization: Bearer kirora_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```

Keys begin `kirora_` followed by 32 random bytes in base64url. There is one format for
every environment: no `live` or `test` variant. Use a second workspace for staging (see
[Staging](https://kirora.io/docs/api/staging)).

The key identifies the workspace, and every query is scoped to it. A resource belonging
to another workspace returns **404, never 403**, so that existence is not leaked.

## Credentials

Besides API keys, `/v1` accepts two other bearer tokens. Each operation's reference
page shows which kinds of key can call it and the scope it needs.

| Credential | Used on | Rules |
| --- | --- | --- |
| API key, kind `integration` | `/v1` | Exactly the scopes it holds |
| API key, kind `agent` | `/v1` and `/mcp` | Its scopes, minus sending, scheduling and activating, contact and consent writes, events, suppressions and webhooks (see [Key kinds](#key-kinds)). What it drafts needs a person to confirm it |
| API key, kind `partner` | `/v1`, and `/v1` in a child workspace with the `Kirora-Workspace` header | For servers only: any request with an `Origin` header is refused with 403 |
| Connected-app OAuth access token | `/v1` and `/mcp` | Acts as an agent key, limited to what the member who approved it may do in the dashboard. A demotion narrows it at once |
| Embed session token `es_…` | A short list of `/v1` routes | Issued by a partner key, valid for 15 minutes. Used only from the Kirora builder embedded in a page the partner key allows |
| Client key plus user token | `/client/v1` (the web SDK) | Not a `/v1` credential |
| Dashboard session | The dashboard only | Not a `/v1` credential |

The `Kirora-Workspace` header names one of a partner's own workspaces by its external id.
Only a partner key may send it. Any other key sending it gets **403**, a partner key
sent from a browser gets **403**, and a workspace that is not one of the partner's gets
**404**. The partner's own workspace routes (`/v1/workspaces…`) always act in the key's
workspace and ignore the header.

An embed session can reach only the routes of the builder components it was issued for
(the segment builder and the email builder). A session lets the chosen builders read what
they need, render previews, count audiences, upload images and, when `testSendTo` lists
addresses, send test emails to those addresses only. It never saves, publishes or sends
anything else; your server saves what the builders return. `testSendTo` is empty by
default, which hides test sending, and takes at most 5 addresses. Every other route
refuses the session with 403, even one inside a scope the partner key holds.

## Obtaining a key

Keys are created in the dashboard (**Developer → API keys**), not over the public
API, so a leaked key cannot mint more keys. Only the workspace's admins and owner can create, rotate or revoke 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.

A key can expire. The dashboard sets new keys to expire in 90 days unless you choose
another date or none; after it, requests with the key return 401. Rotate a key before
it expires, from the same page.

## Scopes

A key carries an explicit list of scopes, and almost every operation requires exactly one
(`/v1/me` and `/v1/webhooks/events` require none). The reference shows
which.


| Resource | Scopes |
| --- | --- |
| Campaigns | `campaigns:read`, `campaigns:write`, `campaigns:send` |
| Contacts | `contacts:read`, `contacts:write`, `contacts:delete` (deleting contacts and their data) |
| Events | `events:read` (names and their properties), `events:write` (track, batch, define and describe names), `events:delete` (delete an event name and its events) |
| Attributes | `attributes:read`, `attributes:write` (describe keys) |
| Lists | `lists:read`, `lists: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` |
| Conversion goals | `conversion-goals:read`, `conversion-goals:write` — how a campaign or journey is measured |
| Sending domains | `domains:read`, `domains:write` (sending domains; `domains:read` also lists senders) |
| Suppressions | `suppressions:read`, `suppressions:write` (add, delete) |
| 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:read` (push projects) |
| Tags | `tags:read`, `tags:write` (create, rename, merge, delete) |
| Reports | `reports:read` — the overview, campaign, transactional, funnel and RFM reports |
| Webhooks | `webhooks:read` (webhooks and their deliveries), `webhooks:write` (create, change, delete, test, redeliver, rotate the secret) |
| Partner workspaces | `workspaces:manage` (create, change, suspend and delete them; partner keys only), `embed:write` (embed sessions; partner keys only) |


Every `:write` scope includes its `:read`: a key with `lists:write` can read lists too,
since every write answers with what it wrote. A read-only key is still possible. Delete
and send scopes include nothing: `contacts:delete` and `events:delete` are granted on
their own.

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.

A contact's sub-resource takes the scope of the data it returns:
`GET /v1/contacts/{id}/events` needs `events:read`, `/journeys` needs `journeys:read` and
`/messages` needs `messages:read`; a contact's lists, segments, topics and installations
need `contacts:read`.

### 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. A key is created with a scope list or a preset, exactly one: a key with
neither is refused (400, issue at `scopes`), so no key holds a scope nobody chose.

| Preset | Scopes |
| --- | --- |
| `read_only` | Every `:read` scope |
| `ingestion` | `contacts:read`, `contacts:write`, `events:write`, `lists:read`, `lists:write` |
| `transactional` | `transactional:send`, `messages:read`, `templates:read` |
| `automation` | What the Zapier, Make and n8n apps use: `attributes:read`, `contacts:read`, `contacts:write`, `events:read`, `events:write`, `lists:read`, `lists:write`, `transactional:send`, `webhooks:write` |
| `full_access` | Every scope except `workspaces:manage` and `embed:write`, 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 Kirora 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 workspace's behalf. It reads,
creates, edits and deletes campaigns, segments, lists, templates, journeys and domains
like any other key, but it cannot deliver anything on its own:

- It cannot send, schedule or activate anything. It may not hold `campaigns:send`,
  `journeys:activate`, or `transactional:send`, so
  `POST /v1/campaigns/{id}/send`, `/trigger`, `/schedule`, `/ab-test/winner`,
  `/v1/journeys/{id}/activate` and `/v1/transactional/messages` (with its batch and
  cancel) all return **403 `AGENT_KEY_REFUSED`**.
- 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`.
  `DELETE /v1/topics/{id}` returns 403 too, since it deletes every contact's choices
  on the topic.
- It cannot add contacts to a list, delete them from one, or track events: either can
  enroll contacts in a journey that then sends. `POST /v1/lists/{id}/contacts` and
  `/contacts/delete` return 403 for an agent key, and it may not hold
  `events:write`. Nor may it hold `events:delete`, which deletes recorded events.
- It cannot add or delete 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. It may hold `webhooks:read`.
- 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 — returns **409
  `AGENT_CONFIRMATION_REQUIRED`**.
- A template an agent writes, or a copy of one, stays a draft until a person publishes
  it with a confirmation. An agent key gets **403** on `POST /v1/templates/test-send`,
  on publishing and on restoring a version, and cannot change shared blocks.
- A form an agent creates stays a draft. An agent key gets **403** on `publishForm` and
  `unpublishForm`, on changing a published form, and on unarchiving one.
- When an agent changes a segment's `rules`, everything that uses the segment, directly
  or through other segments, goes back to waiting for a person. Its scheduled campaigns
  are unscheduled, its active journeys are paused, and the workspace's admins are
  notified. A change to the segment's name alone does none of this.

What an agent key can still do, because nothing in the list above stops it: delete
sending domains nothing sends from (`domains:write`) and create message exports that include recipient
addresses (`messages:read`). Grant those scopes only if the agent should have them.

Every refusal in this list is **403 `AGENT_KEY_REFUSED`**, the system working, not a
scope misconfiguration. Report it to users that way, not as "check your scopes": the
scopes are not the problem. A missing scope is a different code, `SCOPE_MISSING`.

Note also that `full_access` cannot be used to create an agent key: the preset includes
scopes agent keys may not hold (the three send scopes, `contacts:delete`,
`suppressions:write`, `events:write`, `events:delete` and `webhooks:write`), so creation
fails validation rather than producing the 403 you were trying to test: the error names
the scopes it refused, in `message` and `details.refusedScopes`. 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 (with `WWW-Authenticate: Bearer`) |
| 403 | `SCOPE_MISSING` | Key is valid but lacks the operation's scope; `details.requiredScope` names it |
| 403 | `AGENT_KEY_REFUSED` | An agent key on something agents may not do, whatever its scopes (see [Key kinds](#key-kinds)) |
| 403 | `FORBIDDEN` | Any other refusal: `Kirora-Workspace` from a non-partner key, a partner key used from a browser, an embed session off its routes |
| 403 | `API_KEY_IP_NOT_ALLOWED` | The request came from an address outside the key's `allowedIps` |
| 403 | `EMBED_ORIGIN_NOT_ALLOWED` | An embed session was used from a page its partner key's `allowedOrigins` does not list |
| 404 | `NOT_FOUND` | Resource does not exist **or** belongs to another workspace |

Every `/v1` answer carries `Cache-Control: no-store`: it holds your contacts' data, so no
shared cache or browser keeps it.

## 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 workspace within its
scopes.

Rotate from the dashboard, where keys are created. The new key
replaces the old one. You can keep the old key working for an overlap of up to 7 days
(`overlapHours`, 0 to 168); the default is no overlap, and the old key stops at once.
Only one previous key is kept. Rotating again with an overlap while one is running keeps
the older key and its original expiry; rotating with no overlap ends it.

## Next steps

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