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:sendorpush:send, soPOST /v1/campaigns/{id}/send,/trigger,/schedule,/v1/journeys/{id}/activate,/v1/transactional/messagesand/v1/push/messagesall return 403FORBIDDEN. - It can stop a send. Unscheduling or cancelling a campaign needs
campaigns:write, and pausing or ending a journey needsjourneys: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:writeroutes return 403 for an agent key even when it holds that scope, and it may not holdcontacts: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}/contactsand/contacts/removereturn 403 for an agent key, and it may not holdevents: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.