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 migration that shipped it (supabase/migrations/0078_unified_api_keys.sql)
deletes every existing row in api_keys. 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 (apps/web/lib/api-keys.ts). 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 tool |
domains | The MCP list_domains tool |
projects | /api/v1/projects |
forms | Form endpoints and submissions |
newsletters | — |
kanban | The MCP publish_kanban / set_kanban_public_editing tools |
webhooks | — |
Three tools have no enforcement point yet
forms, newsletters, and webhooks are part of the permission
vocabulary and appear in the permissions grid when you mint a key, but no
route or MCP tool currently checks them — there is nothing under
apps/web/app/api or apps/web/lib/mcp.ts that calls
apiKeyAllows/apiKeyPermissionError for any of the three. Granting or
withholding one of these today has no effect. This is current behaviour,
not a promise about what ships next. (kanban used to be on this list;
it gained its enforcement point when the board tools shipped in #641 —
TOOL_PERMISSIONS in apps/web/lib/mcp.ts.)
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
(apiKeyAllows / permissionsAllow in apps/web/lib/api-keys.ts and
apps/web/lib/api-key-permissions.ts), never an equality test.
Enforcement mapping
This is what's actually checked today, read from the route handlers and
apps/web/lib/mcp.ts:
| 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_emails, get_email | inbox ≥ read |
MCP send_email, reply_email | inbox ≥ write |
MCP list_domains | domains ≥ read |
MCP list_bookings | booking ≥ read |
MCP publish_kanban, set_kanban_public_editing | kanban ≥ 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
(
revoked_atis stamped). A revoked key fails every endpoint with401 API key has been revoked. - No expiry, no rotation. The
api_keystable hascreated_at,last_used_at, andrevoked_at— there is noexpires_atcolumn, 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
A key's created_by user can be deleted independently of the key (the
column is set null, not cascaded). 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).
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.