API keys · MachineMail Docs

Scope

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

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. Create a key for an agent walks through both.

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.

Scope Minted at Can reach
Organization POST /v0/api-keys Everything in the organization.
Pod POST /v0/pods/{pod_id}/api-keys Resources in one pod.
Inbox POST /v0/inboxes/{inbox_id}/api-keys One 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:

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

Creating a key

Example Requests

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"
  }'

TypeScript Example

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

Python Example

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 Structure

{
  "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 field Notes
name Label for the key. Defaults to “API key”.
permissions Optional allowlist. An object of booleans ({ "message_read": true }) or an array of names. Omit it for full access within the scope.
expires_at Optional RFC 3339 time, at least 5 minutes ahead. The key stops working at that moment. Omit it or send null for no expiry (a key created with an expiring API key gets that key’s expiry instead).

Expiry

A key never expires unless you give it an expires_at when you create it. Use one for anything that only needs a key for a while: a CI job, a contractor, an agent that runs for a week.

In the dashboard, pick Expires when you create a key: Never, 7 days, 30 days, 90 days, 1 year or a custom date (the end of that day, in your time zone). The keys list shows how long each key has left, and expired keys move to the bottom. With the CLI, use --expires-in 30d (or 90m, 12h, 2w, 1y) or --expires-at 2027-01-01T00:00:00Z on machinemail api-keys create.

When a key expires

From its expires_at on, every request with the key gets 401 on the REST API, the MCP server, the WebSocket and the event stream:

{ "error": { "type": "unauthorized", "message": "This API key expired at 2027-01-01T00:00:00.000Z. Create a new one." } }

A WebSocket or event stream that is open when the key expires is closed at that moment, with the same message. Expired keys stay in lists, with their expires_at, until you revoke them.

Things the key set up stay in place after it expires, as they do when a key is revoked: webhooks it created keep delivering, and attachment download links it was given work until their own expiry. A request already in progress (such as an MCP wait_for_message) can finish. When access must end, review and delete what the key created.

Keys created with an expiring key

A key created with another API key cannot outlive it:

Keys created in the dashboard are not limited this way.

Recommendation

Give short-lived keys to CI and to agents, and narrow them to the inbox and permissions they need. A key that expires on its own cannot be forgotten in a config file. For an agent that keeps running, its next key has to come from whoever rotates keys: the dashboard, a key that does not expire, or the CLI after machinemail login. A key created with the expiring key itself expires with it.

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.

Area Permissions
Inboxes inbox_read, inbox_create, inbox_update, inbox_delete
Messages message_read, message_send, message_reply, message_update, message_delete
Threads thread_read, thread_delete (label changes on threads need message_update)
Hidden labels label_spam_read, label_blocked_read, label_unauthenticated_read, label_trash_read
Drafts draft_read, draft_create, draft_update, draft_delete, draft_send
Webhooks webhook_read, webhook_create, webhook_update, webhook_delete
Domains domain_read, domain_create, domain_update, domain_delete
Lists and suppressions list_entry_read, list_entry_create, list_entry_delete
Metrics and events metrics_read
API keys api_key_read, api_key_create, api_key_update, api_key_delete
Pods pod_read, pod_create, pod_update, pod_delete
Raw injection inbound_raw (MachineMail only: POST /inbound/raw). Never implied by full access

Some details worth knowing:

Managing keys

Method Path Permission
GET /v0/api-keys api_key_read
POST /v0/api-keys api_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.

{
  "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.