Kontacts docs
Getting started

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):

ToolCovers
inbox/api/v1/emails, /api/v1/send, and the MCP list_emails / get_email / send_email / reply_email tools
bookingCalendar and booking pages, and the MCP list_bookings tool
domainsThe MCP list_domains tool
projects/api/v1/projects
formsForm endpoints and submissions
newsletters
kanbanThe 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:

LevelMeaning
noneNo access. This is also what a tool defaults to when it's absent from the key's permissions object.
readList and get operations.
writeCreate, update, and send operations.
adminDestructive 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:

SurfaceRequires
GET /api/v1/emailsinboxread
POST /api/v1/sendinboxwrite
GET /api/v1/projectsprojectsread
POST /api/v1/projectsprojectswrite and an all-projects key (see below)
MCP list_emails, get_emailinboxread
MCP send_email, reply_emailinboxwrite
MCP list_domainsdomainsread
MCP list_bookingsbookingread
MCP publish_kanban, set_kanban_public_editingkanbanadmin

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_at is stamped). A revoked key fails every endpoint with 401 API key has been revoked.
  • No expiry, no rotation. The api_keys table has created_at, last_used_at, and revoked_at — there is no expires_at column, 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.

On this page