Authentication
The key format, what it's scoped to, its per-tool permissions, and how a request is authorized.
Every request — REST or MCP — carries the same header:
Authorization: Bearer <key>
Keys minted before 2026-09 stopped working
Kontacts used to have two key concepts — a project key and a separate
org/scoped JWT key. Both were replaced by the single model on this page,
and the change that shipped it deleted every existing key. There is no
legacy read path and nothing was backfilled — if your integration was
using a key minted before this change, it now gets 401 invalid API key
and you need to mint a new one from Settings → API keys.
One key format
There is one token shape: gm_live_ followed by 43
base64url characters. It is opaque — looked up by a sha256 hash, never
decoded. The signed-JWT key format (jwt_…)
that used to exist alongside it is gone; scope and permissions now live
entirely in the database row behind the hash, not in the credential itself.
What a key is scoped to
A key always belongs to one workspace (organisation). From there, two independent things determine what it can do:
1. Project reach
A key reaches either:
- All projects in the workspace (
all_projects: true) — including projects created after the key was minted, and - A specific, chosen list of projects — exactly the projects picked at creation time, no more.
There is no wildcard scope smaller than "all projects" and larger than an explicit list — a key cannot be scoped to "every project in these two of my three workspaces," for example, because a key only ever belongs to one workspace.
2. Per-tool permissions
Independently of project reach, a key carries a permission level for each product surface (tool):
| Tool | Covers |
|---|---|
inbox | /api/v1/emails, /api/v1/send, and the MCP list_emails / get_email / send_email / reply_email tools |
booking | Calendar and booking pages, and the MCP list_bookings / list_meeting_types / create_meeting_type / set_meeting_type_active / set_availability tools |
domains | The MCP list_domains / add_domain / verify_domain tools |
projects | /api/v1/projects and the MCP list_projects tool |
forms | The twelve MCP forms tools — form/submission reads, create_form, the builder writes, set_form_enabled, release_submission |
newsletters | The eleven MCP newsletter tools — audience/subscriber/issue reads, create_audience, set_audience_enabled, create_draft, update_draft, send_issue |
kanban | The fifteen MCP kanban tools — board/card/comment reads, the column and card writes, the publish_kanban / set_kanban_public_editing / update_board configuration calls |
contacts | The MCP list_contacts / create_contact / update_contact / delete_contact tools |
webhooks | — |
The MCP key_info tool belongs to no row on purpose: any live key may
describe itself, whatever its permissions — that is the tool you call to see
this table's answer for your own key.
One tool has no enforcement point yet
webhooks is part of the permission vocabulary and appears in the
permissions grid when you mint a key, but no route or MCP tool currently
checks it. Granting or withholding it today has no effect. This is current
behaviour, not a promise about what ships next. (kanban, newsletters
and forms used to be on this list; each gained its enforcement point
when its tools shipped.)
Each tool's level is one of, from least to most privileged:
| Level | Meaning |
|---|---|
none | No access. This is also what a tool defaults to when it's absent from the key's permissions object. |
read | List and get operations. |
write | Create, update, and send operations. |
admin | Destructive or configuration changes — delete, rotate, change settings. |
Levels are ordered and each implies the ones below it. A key with
admin on a tool also satisfies a read or write check on that same
tool — the comparison a route makes is a rank comparison, never an
equality test.
Enforcement mapping
This is what's actually checked today:
| Surface | Requires |
|---|---|
GET /api/v1/emails | inbox ≥ read |
POST /api/v1/send | inbox ≥ write |
GET /api/v1/projects | projects ≥ read |
POST /api/v1/projects | projects ≥ write and an all-projects key (see below) |
MCP list_projects | projects ≥ read |
MCP key_info | nothing — any live key |
MCP list_emails, get_email | inbox ≥ read |
MCP send_email, reply_email | inbox ≥ write |
MCP list_domains | domains ≥ read |
MCP verify_domain | domains ≥ write |
MCP add_domain | domains ≥ admin |
MCP list_bookings, list_meeting_types | booking ≥ read |
MCP create_meeting_type, set_availability | booking ≥ write |
MCP set_meeting_type_active | booking ≥ admin |
MCP get_board, list_cards, list_comments | kanban ≥ read |
MCP column and card writes (add_column, rename_column, move_column, delete_column, create_card, update_card, move_card, delete_card, delete_comment) | kanban ≥ write |
MCP publish_kanban, set_kanban_public_editing, update_board | kanban ≥ admin |
MCP list_contacts | contacts ≥ read |
MCP create_contact, update_contact, delete_contact | contacts ≥ write |
MCP list_audiences, list_subscribers, list_issues, get_issue, get_issue_stats, preview_issue | newsletters ≥ read |
MCP create_draft, update_draft | newsletters ≥ write |
MCP create_audience, set_audience_enabled, send_issue | newsletters ≥ admin |
MCP list_forms, get_form, list_submissions, get_submission | forms ≥ read |
MCP create_form, update_form, set_form_fields, set_form_origins, archive_form, duplicate_form | forms ≥ write |
MCP set_form_enabled, release_submission | forms ≥ admin |
On top of the permission check, the project-scope check still applies:
a request naming a project_id outside the key's reach is refused
regardless of what the key's permissions say. See
Projects for why POST /api/v1/projects additionally
requires all_projects: true — a key pinned to a fixed project list has, by
construction, no say over a project that doesn't exist yet.
What a denied request looks like
A REST route refuses a missing permission with 403 and a body naming the
gap:
{ "error": "this key lacks inbox:write" }MCP reports a denial differently
An MCP tools/call for a tool the key isn't permitted to use does not
fail the request at the transport level — it comes back as a normal,
successful JSON-RPC result with isError: true and the same message in
structuredContent:
{ "isError": true, "structuredContent": { "error": "this key lacks inbox:write" } }tools/list still advertises every tool regardless of what the key is
actually permitted to call — the permission check happens on tools/call,
not before. Check isError in the result to know whether a call actually
worked; don't rely on the HTTP status or a JSON-RPC error object, since
neither is set here.
If a project scope with at least one project resolves for the key but the
project named in the request isn't in it, that's a separate 403:
"API key is not authorised for this project".
What's the same for every key
- Storage — only
sha256(token)is ever written to the database. The plaintext is shown exactly once, in the response to the mint request — copy it then, because the dashboard cannot show it again. - Revocation — a workspace admin can revoke a key at any time. A
revoked key fails every endpoint with
401 API key has been revoked. - No expiry, no rotation. A key never expires, and there is no rotation endpoint for an API key. If you need to rotate one, mint a new key and revoke the old one. Webhook signing secrets are a separate credential and do have a rotate action — see Webhooks.
Ownership matters
The user who created a key can be deleted independently of the key
itself. A key with no owner left is refused by
the send path — 403 API key has no owner; create a new one — because
nobody remains to charge the send's quota to. This applies to REST send and
to the MCP send_email / reply_email tools.
Naming a project on a request
Most endpoints resolve project scope automatically but still need to know
which project a request acts on. A key resolves a default project only
when it's scoped to exactly one specific project — in that case
project_id can be omitted and that project is assumed. An all_projects
key, or a key scoped to more than one project, has no such default: it must
be told which project with a project_id on every request that needs one —
see each endpoint's page for where that goes (query string, body, or MCP
tool argument). Over MCP, project_id accepts the project's UUID or its
slug, and the list_projects tool returns both for every project in the
key's scope; on the REST endpoints it is the UUID only.
Minting a key
Keys are minted from Settings → API keys
(/dashboard/settings/api-keys) in a workspace's settings. Choose the
project reach (all projects, or a specific list) and set a permission level
per tool — a key with every tool left at none can't be created, since it
couldn't do anything.
MCP uses the exact same keys and the exact same settings page
(/dashboard/settings/api-keys) — there is no separate MCP credential.