Every /v1 request authenticates with a bearer token, usually a workspace API key:
Authorization: Bearer kirora_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxKeys 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).
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). 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, ortransactional:send, soPOST /v1/campaigns/{id}/send,/trigger,/schedule,/ab-test/winner,/v1/journeys/{id}/activateand/v1/transactional/messages(with its batch and cancel) all return 403AGENT_KEY_REFUSED. - 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.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}/contactsand/contacts/deletereturn 403 for an agent key, and it may not holdevents:write. Nor may it holdevents: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 holdwebhooks: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
publishFormandunpublishForm, 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) |
| 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.