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:
- 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
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.
expires_atis an RFC 3339 time (2027-01-01T00:00:00Z, or with an offset such as+02:00). A date alone (2027-01-01) or a time without a zone is read as UTC. It must be at least 5 minutes from now; a time in the past, a closer one, a date that does not exist (2027-02-30) or another format is400withparam: "expires_at". There is no maximum.- The expiry is fixed.
PATCHcannot change it: create a new key, switch over, then revoke the old one. - Every response that returns the key includes
expires_atwhen it has one.GET /v0/auth/mereturns it asapi_key_expires_atfor the key you call with.
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:
- Without
expires_at(or withnull), the new key gets the creating key’s expiry, even if that is less than 5 minutes away. The response shows it. - An
expires_atlater than the creating key’s is403. - An expiring key cannot widen the permissions of a key that outlives it with
PATCH(403). It can still rename keys and narrow their permissions.
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:
- Replies accept either
message_replyormessage_send. Forwarding and new messages needmessage_send. - Attachments, raw MIME,
batch-getand inbox export needmessage_read. - Webhook test and delivery replay need
webhook_update. Domain verify needsdomain_update. - The event log and the SSE stream need
metrics_read. The WebSocket needs any ofmessage_read,inbox_read,domain_read,draft_readorwebhook_read. Both streams send each event only when the key holds its read permission (message.*needsmessage_read, and so on; see Events). - Without the matching
label_*_readpermission, a key never sees messages labelledSPAM,BLOCKED,UNAUTHENTICATEDorTRASH, even if it asks for them withinclude_spamand friends. - A key with limited permissions can only mint keys with a subset of its own permissions, and cannot mint an unrestricted key.
inbound_rawis 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
| 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.