Skip to content
machinemail

API keys

How MachineMail API keys work, how to scope them to a pod or a single inbox, and how to limit them to specific permissions.

Every request to /v0 and /mcp authenticates with a Bearer token:

http
Authorization: Bearer mm_live_5RdovJbvKguM5moYSUoEos8S1BiSk1S9

Keys start with mm_live_ followed by 32 base62 characters. MachineMail stores only a SHA-256 hash of each key, so the full secret is shown once, when the key is created. The first 12 characters are kept as prefix so you can recognise a key in lists.

Your first key is created when you first sign in and shown once in the dashboard. Create more at machinemail.dev/app/api-keys or through the API.

A request without a valid key gets 401 unauthorized with a WWW-Authenticate: Bearer resource_metadata="..." header. Agents use that header to discover auth.md self-registration.

Scope

A key has one of three scopes. The scope is fixed when the key is minted and decided by the route you mint it on.

ScopeMinted atCan reach
OrganizationPOST /v0/api-keysEverything in the organization.
PodPOST /v0/pods/{pod_id}/api-keysResources in one pod.
InboxPOST /v0/inboxes/{inbox_id}/api-keysOne inbox: its messages, threads, drafts, and webhooks and lists scoped to it.

Out-of-scope resources return 404, as if they did not exist. Some operations need a wider scope and return 403:

  • Only organization keys can mint, and so only they can create pod and inbox keys. Pod and inbox keys cannot create any keys.
  • Only organization keys can create pods and remove suppressions.
  • Inbox keys cannot create or delete inboxes, or add domains.

Use an inbox key when you hand credentials to a single agent: a leaked key exposes one mailbox, not the account.

Creating a key

Terminal
curl https://api.machinemail.dev/v0/inboxes/support-agent@machinemail.dev/api-keys \
  -H "Authorization: Bearer $MACHINEMAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "support agent",
    "permissions": { "message_read": true, "thread_read": true, "message_reply": true },
    "expires_at": "2027-01-01T00:00:00Z"
  }'
create-key.ts
const inbox = encodeURIComponent('support-agent@machinemail.dev');
const res = await fetch(`https://api.machinemail.dev/v0/inboxes/${inbox}/api-keys`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.MACHINEMAIL_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    name: 'support agent',
    permissions: { message_read: true, thread_read: true, message_reply: true },
    expires_at: '2027-01-01T00:00:00Z',
  }),
});
const { api_key } = await res.json(); // shown once
create_key.py
import os, requests

key = requests.post(
    "https://api.machinemail.dev/v0/inboxes/support-agent@machinemail.dev/api-keys",
    headers={"Authorization": f"Bearer {os.environ['MACHINEMAIL_API_KEY']}"},
    json={
        "name": "support agent",
        "permissions": {"message_read": True, "thread_read": True, "message_reply": True},
        "expires_at": "2027-01-01T00:00:00Z",
    },
).json()
print(key["api_key"])  # shown once
Response
{
  "api_key_id": "key_01m4fy8hmg0000dgevarwczcf7",
  "api_key": "mm_live_5RdovJbvKguM5moYSUoEos8S1BiSk1S9",
  "prefix": "mm_live_5Rdo",
  "name": "support agent",
  "inbox_id": "support-agent@machinemail.dev",
  "pod_id": "pod_01m4fy8h150000w4xty1ehfnqa",
  "permissions": { "message_read": true, "thread_read": true, "message_reply": true },
  "created_at": "2026-10-09T09:00:16.400Z",
  "expires_at": "2027-01-01T00:00:00.000Z"
}
Body fieldNotes
nameLabel for the key. Defaults to “API key”.
permissionsOptional allowlist. An object of booleans ({ "message_read": true }) or an array of names. Omit it for full access within the scope.
expires_atOptional ISO 8601 time. The key stops working at that moment.

Permissions

Without permissions, a key can do everything its scope allows. With permissions, every request is checked against the list and refused with 403 forbidden (“API key lacks the … permission”) if the needed permission is missing. The names are the same as AgentMail’s.

AreaPermissions
Inboxesinbox_read, inbox_create, inbox_update, inbox_delete
Messagesmessage_read, message_send, message_reply, message_update, message_delete
Threadsthread_read, thread_delete (label changes on threads need message_update)
Hidden labelslabel_spam_read, label_blocked_read, label_unauthenticated_read, label_trash_read
Draftsdraft_read, draft_create, draft_update, draft_delete, draft_send
Webhookswebhook_read, webhook_create, webhook_update, webhook_delete
Domainsdomain_read, domain_create, domain_update, domain_delete
Lists and suppressionslist_entry_read, list_entry_create, list_entry_delete
Metrics and eventsmetrics_read
API keysapi_key_read, api_key_create, api_key_update, api_key_delete
Podspod_read, pod_create, pod_update, pod_delete
Raw injectioninbound_raw (MachineMail only: POST /inbound/raw). Never implied by full access

Some details worth knowing:

  • Replies accept either message_reply or message_send. Forwarding and new messages need message_send.
  • Attachments, raw MIME, batch-get and inbox export need message_read.
  • Webhook test and delivery replay need webhook_update. Domain verify needs domain_update.
  • The event log and the SSE stream need metrics_read. The WebSocket needs any of message_read, inbox_read, domain_read, draft_read or webhook_read. Both streams send each event only when the key holds its read permission (message.* needs message_read, and so on; see Events).
  • Without the matching label_*_read permission, a key never sees messages labelled SPAM, BLOCKED, UNAUTHENTICATED or TRASH, even if it asks for them with include_spam and friends.
  • A key with limited permissions can only mint keys with a subset of its own permissions, and cannot mint an unrestricted key.
  • inbound_raw is opt-in: full-access keys do not have it, and only the dashboard or a key that already has it can grant it. {"*": true, "inbound_raw": true} means full access plus raw injection.

Managing keys

MethodPathPermission
GET/v0/api-keysapi_key_read
POST/v0/api-keysapi_key_create
GET/v0/api-keys/{api_key_id}api_key_read
PATCH/v0/api-keys/{api_key_id}api_key_update
DELETE/v0/api-keys/{api_key_id}api_key_delete

The same routes exist under /v0/pods/{pod_id}/api-keys and /v0/inboxes/{inbox_id}/api-keys, where listing shows only the keys of that pod or inbox. Revoked keys are left out of lists.

PATCH changes name and permissions. Scope and expiry cannot be changed; mint a new key instead.

DELETE revokes the key. It stops working immediately. The record is kept with revoked_at set.

A listed key looks like this. The secret is never returned again.

Response
{
  "type": "bearer",
  "api_key_id": "key_01m4fy8hmg0000dgevarwczcf7",
  "organization_id": "org_01m4fy8h140000efvagszh6rpx",
  "prefix": "mm_live_5Rdo",
  "name": "support agent",
  "pod_id": "pod_01m4fy8h150000w4xty1ehfnqa",
  "inbox_id": "support-agent@machinemail.dev",
  "permissions": { "message_read": true, "thread_read": true, "message_reply": true },
  "used_at": "2026-10-09T10:12:40.118Z",
  "expires_at": "2027-01-01T00:00:00.000Z",
  "created_at": "2026-10-09T09:00:16.400Z",
  "updated_at": "2026-10-09T09:00:16.400Z"
}

used_at is updated at most once a minute, not on every request.

Agent access tokens

Agents that register through auth.md do not get an mm_live_ key. They exchange an assertion for a short-lived JWT access token and send it the same way, as Authorization: Bearer <token>. Before the agent is claimed, the token reaches only its sandbox inbox. See Claiming agents.

Next