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

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 / list_meeting_types / create_meeting_type / set_meeting_type_active / set_availability tools
domainsThe MCP list_domains / add_domain / verify_domain tools
projects/api/v1/projects and the MCP list_projects tool
formsThe twelve MCP forms tools — form/submission reads, create_form, the builder writes, set_form_enabled, release_submission
newslettersThe eleven MCP newsletter tools — audience/subscriber/issue reads, create_audience, set_audience_enabled, create_draft, update_draft, send_issue
kanbanThe fifteen MCP kanban tools — board/card/comment reads, the column and card writes, the publish_kanban / set_kanban_public_editing / update_board configuration calls
contactsThe 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:

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, never an equality test.

Enforcement mapping

This is what's actually checked today:

SurfaceRequires
GET /api/v1/emailsinbox ≥ read
POST /api/v1/sendinbox ≥ write
GET /api/v1/projectsprojects ≥ read
POST /api/v1/projectsprojects ≥ write and an all-projects key (see below)
MCP list_projectsprojects ≥ read
MCP key_infonothing — any live key
MCP list_emails, get_emailinbox ≥ read
MCP send_email, reply_emailinbox ≥ write
MCP list_domainsdomains ≥ read
MCP verify_domaindomains ≥ write
MCP add_domaindomains ≥ admin
MCP list_bookings, list_meeting_typesbooking ≥ read
MCP create_meeting_type, set_availabilitybooking ≥ write
MCP set_meeting_type_activebooking ≥ admin
MCP get_board, list_cards, list_commentskanban ≥ 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_boardkanban ≥ admin
MCP list_contactscontacts ≥ read
MCP create_contact, update_contact, delete_contactcontacts ≥ write
MCP list_audiences, list_subscribers, list_issues, get_issue, get_issue_stats, preview_issuenewsletters ≥ read
MCP create_draft, update_draftnewsletters ≥ write
MCP create_audience, set_audience_enabled, send_issuenewsletters ≥ admin
MCP list_forms, get_form, list_submissions, get_submissionforms ≥ read
MCP create_form, update_form, set_form_fields, set_form_origins, archive_form, duplicate_formforms ≥ write
MCP set_form_enabled, release_submissionforms ≥ 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.

On this page