Authentication
API keys, key scopes, permissions, agent access tokens and the dashboard session.
Every /v0 request and every /mcp request needs a credential. There are three kinds:
| Credential | Looks like | Where it works |
|---|---|---|
| API key | mm_live_ + 32 base62 characters | /v0/*, /mcp, /inbound/raw |
| Agent access token | a JWT (eyJ...) from POST /oauth2/token | /v0/*, /mcp |
| Dashboard session | the mm_session cookie | the dashboard; also accepted on /v0/*, never on /mcp |
Integrations should use API keys. The session cookie is set when a person signs in to the dashboard and exists for the dashboard’s own use.
Sending a credential
The server reads, in order:
Authorization: Bearer <token>(theBearerkeyword is case-insensitive).?token=<token>in the query string, only onGET /v0/events/streamand the/v0/wsWebSocket upgrade, for clients that cannot set headers.- The
mm_sessioncookie.
When a bearer or query token is present the cookie is ignored, so a bad token returns 401 even if a valid cookie is also sent. There is no X-API-Key header and no Basic auth.
A missing, unknown, revoked or expired credential returns:
The resource_metadata URL is the starting point for agents that onboard themselves with auth.md.
API keys
- Format:
mm_live_followed by 32 characters from0-9A-Za-z, 40 characters in all. - The first 12 characters (for example
mm_live_4f8K) are stored as the key’sprefixand shown in listings. - Only a SHA-256 hash of the key is stored. The full key is returned once, in the response to
POST /v0/api-keys. - A key can have an
expires_at. After that time it returns401. - Deleting a key (
DELETE /v0/api-keys/{api_key_id}) revokes it immediately.
Note
The key format reserves an mm_test_ prefix, and the OpenAPI document mentions it for a sandbox. The API does not issue mm_test_ keys: every key is mm_live_. The sandbox for agents is the
anonymous agent registration.
The first time a person without an organization signs in to the dashboard, MachineMail creates one, a Default Pod and an organization key named “Default key”. The dashboard fetches that key once with GET /auth/first-key within 10 minutes of sign-in.
Key scope
Every key belongs to one organization and has one of three scopes:
| Scope | Created with | Sees |
|---|---|---|
| Organization | POST /v0/api-keys | everything in the organization |
| Pod | POST /v0/pods/{pod_id}/api-keys | that pod’s inboxes, threads, drafts, webhooks, domains and lists |
| Inbox | POST /v0/inboxes/{inbox_id}/api-keys | that one inbox |
The path you create the key on sets its scope; the request body does not. A resource outside the key’s scope returns 404, as if it did not exist.
Rules for creating keys:
- Only organization-scoped credentials can create API keys (
403 Only organization-scoped credentials can create API keys). - A key with a permission list cannot create a key without one (or with
*), and cannot grant permissions it does not hold (403,param: permissions). The same check applies toPATCH /v0/api-keys/{api_key_id}. inbound_rawcan only be granted from the dashboard (a signed-in person) or by a key that itself hasinbound_raw. A full-access key cannot grant it, to another key or to itself.
Some actions also need a wider scope than the permission alone:
| Action | Requirement |
|---|---|
| Create or delete an inbox | not inbox-scoped |
| Add a domain | not inbox-scoped |
| Create a pod, update the organization, remove a suppression, list organization members | organization-scoped |
| List or revoke agent registrations | organization-scoped, not a sandbox token |
Permissions
A key with no permissions list can do everything its scope allows, except POST /inbound/raw. A key with a list can only call routes whose permission is on it; any other route returns 403 with API key lacks the <permission> permission.
* in a list stands for every permission except inbound_raw, so {"*": true, "inbound_raw": true} is full access plus raw injection.
inbound_raw is never implied: not by a key without a list, and not by *. It must be granted by name. Injected mail looks received, so keep it to test and CI keys and leave it off agent keys. Keys created before this rule that relied on full access to call POST /inbound/raw now get 403; create a key with inbound_raw for that.
permissions is sent as an object of booleans ({"message_read": true, "message_send": true}) or as an array of names. Responses always use the object form. An unknown name returns 400 Unknown permission: <name>.
| Permission | Grants |
|---|---|
inbox_read | GET inboxes |
inbox_create | POST /v0/inboxes |
inbox_update | PATCH /v0/inboxes/{inbox_id} |
inbox_delete | DELETE /v0/inboxes/{inbox_id} |
thread_read | GET threads |
thread_delete | DELETE threads |
message_read | GET messages, attachments, raw messages, trust reports, export; POST .../messages/batch-get |
message_send | POST .../messages/send, .../forward, .../reply, .../reply-all |
message_reply | POST .../reply and .../reply-all (either this or message_send) |
message_update | PATCH messages and threads; POST .../messages/batch-update |
message_delete | DELETE messages |
label_spam_read | see SPAM mail when you ask for it (include_spam or labels) |
label_blocked_read | see BLOCKED mail when you ask for it |
label_unauthenticated_read | see UNAUTHENTICATED mail when you ask for it |
label_trash_read | see TRASH mail when you ask for it |
draft_read | GET drafts |
draft_create | POST .../drafts |
draft_update | PATCH .../drafts/{draft_id} |
draft_delete | DELETE .../drafts/{draft_id} |
draft_send | POST .../drafts/{draft_id}/send |
webhook_read | GET webhooks and deliveries |
webhook_create | POST /v0/webhooks |
webhook_update | PATCH webhooks; POST .../test; POST .../deliveries/{delivery_id}/replay |
webhook_delete | DELETE webhooks |
domain_read | GET domains, zone files, Domain Connect |
domain_create | POST /v0/domains |
domain_update | PATCH domains; POST .../verify |
domain_delete | DELETE domains |
list_entry_read | GET allow/block lists and suppressions |
list_entry_create | POST list entries and suppressions |
list_entry_delete | DELETE list entries and suppressions |
metrics_read | GET metrics, the event log, GET /v0/events/stream |
api_key_read | GET API keys; GET agent registrations |
api_key_create | POST API keys |
api_key_update | PATCH API keys |
api_key_delete | DELETE API keys; DELETE agent registrations |
pod_read | GET pods |
pod_create | POST /v0/pods |
pod_update | PATCH /v0/pods/{pod_id} |
pod_delete | DELETE /v0/pods/{pod_id} |
inbound_raw | POST /inbound/raw (inject a raw RFC 5322 message; MachineMail only). Explicit grant only |
GET /v0/organizations needs no permission. The /v0/ws WebSocket needs any of message_read, inbox_read, domain_read, draft_read or webhook_read. Both event streams then send each event only when the credential holds its read permission: message.* needs message_read, inbox.* inbox_read, domain.* domain_read, draft.* draft_read, webhook.test webhook_read.
The label permissions never cause a 403. Without them, asking for spam, blocked, unauthenticated or trash mail is silently ignored and that mail stays hidden. QUARANTINE mail needs no extra permission.
Each endpoint page in the API reference lists the permission it needs. MCP tools use the same permissions as the matching /v0 route.
Agent access tokens
Agents can onboard themselves without a human creating a key first, using the auth.md protocol. The full flow is described at GET /auth.md and on the Agent Auth page. In short:
POST /agent/identityregisters the agent. Ananonymousregistration gets a sandbox inbox on the shared domain and anidentity_assertion.POST /oauth2/tokenwithgrant_type=urn:ietf:params:oauth:grant-type:jwt-bearerexchanges the assertion for an access token (a JWT, 1 hour by default).POST /agent/identity/claimstarts a claim: a person confirms a 6-digit code in the dashboard, which binds the agent to their organization.
Access tokens go in the same Authorization: Bearer header as API keys. Their OAuth scopes map to permissions:
| Scope | Permissions |
|---|---|
inboxes.read | inbox_read |
inboxes.write | inbox_create, inbox_update, inbox_delete |
messages.read | message_read, thread_read, label_spam_read, label_blocked_read, label_unauthenticated_read, label_trash_read |
messages.send | message_send, message_reply, draft_read, draft_create, draft_update, draft_delete, draft_send |
messages.write | message_update, message_delete, thread_delete |
webhooks.read | webhook_read |
webhooks.write | webhook_create, webhook_update, webhook_delete |
domains.read | domain_read |
domains.write | domain_create, domain_update, domain_delete |
lists.read | list_entry_read |
lists.write | list_entry_create, list_entry_delete |
metrics.read | metrics_read |
pods.read | pod_read |
pods.write | pod_create, pod_update, pod_delete |
api_keys.read | api_key_read |
api_keys.write | api_key_create, api_key_update, api_key_delete |
No scope grants inbound_raw.
| Registration state | Token scope | Scopes |
|---|---|---|
Unclaimed anonymous | its one sandbox inbox | inboxes.read, messages.read, messages.send, messages.write |
Unclaimed service_auth or identity_assertion | none: the token is rejected with 401 | |
| Claimed | the claiming organization | every scope |
An unclaimed sandbox inbox has caps on messages, different recipients and recipients per message (deployment defaults 10 and 10 per rolling 24 hours, 5 per message; see Rate limits). Claiming or revoking a registration invalidates the tokens issued before it.
Token lifetimes (deployment defaults):
| Item | Lifetime |
|---|---|
| Access token | 1 hour |
| Identity assertion | 24 hours, never past the registration’s claim window |
| Claim window for an unclaimed registration | 24 hours |
| Sandbox inbox | 24 hours |
| Claim user code | 10 minutes |
Dashboard session
mm_session is a cookie: HttpOnly, SameSite=Lax, Secure on HTTPS, path /, valid for 30 days. A session acts as the person’s current organization with no permission list. It is accepted on /v0/* and the /v0/ws upgrade (but cannot call POST /inbound/raw, which needs a key with inbound_raw), and required by /auth/me, /auth/first-key, /auth/logout and /auth/claim. /mcp never accepts it.
A session is bound to one organization and works only while the person is a member of it.
Signing in
People sign in on MachineMail’s own pages, /login and /signup, with a one-time code sent by email, a social provider or single sign-on. Signing up is the same as signing in: the account is created on the first sign-in.
The ways to sign in:
- Email code. The person enters their address on
/loginor/signup.POST /auth/email/startsends a sign-in code from the sign-in sender (MACHINEMAIL_AUTH_FROM, by defaultsign-in@<shared domain>). The person types the 6-digit code into one field, andPOST /auth/email/verifychecks it. A code lasts 10 minutes and works once. Sending again sends a new code. - Social providers. Each button goes straight to the provider (
GET /auth/social/{provider}), with no page in between.GET /auth/configlists the providers this server offers, fromgoogle,github,appleandmicrosoft. The provider returns the browser toGET /auth/callback. - SSO. After the email step, if the address’s domain is verified by an organization with an active SSO connection, the browser goes to that connection instead of getting a code. If an organization requires SSO, other methods are refused for its members, and MachineMail sends them on to the connection too.
Every redirect carries a single-use state, tied to the browser by a short-lived mm_oauth_state cookie (HttpOnly, path /auth/callback). next is kept through the round trip, and it is only ever a path on this server.
Sometimes a sign-in needs one more step. MachineMail holds that step on the server, behind an HttpOnly mm_signin cookie (path /auth, 10 minutes), and shows its own page:
/login/organization: the person belongs to several organizations, and has to pick the one to sign in to. Only an organization offered for that sign-in is accepted./login/verify: the provider did not vouch for the email (GitHub can do this). MachineMail emails a verification code to the address, and the person enters it.
GET /auth/pending returns the step in progress, for these pages.
Invitations
An invitation email links to <public URL>/invite/{token}. The page shows the organization and the invited address (masked), from GET /auth/invitations/{token}. An unknown token is 404 and the page says the link isn’t valid. When the lookup is rate limited or cannot be answered right now, the API answers 429 with Retry-After (never a 500), and the page says it couldn’t load the invitation, with a Try again button. The person then signs in with an email code, which goes to the invited address, or with a social provider. Either way, the invitation token goes with the sign-in, so signing in accepts the invitation. A link of the form /invite?invitation_token=... works too.
First sign-in and errors
On a person’s first sign-in:
- If a MachineMail account with the same email already exists and the email is verified, the sign-in is linked to it. An email code verifies the address. The account keeps its organizations and everything in them.
- If the email matches an existing account but is not verified, sign-in is refused with
/login?error=unverified. - A person with no organization gets one, with a Default Pod and a first API key. The key is shown once, from
GET /auth/first-key.
Failures end on /login?error= with one of these codes:
| Code | Meaning |
|---|---|
signin_failed | The sign-in was refused, or it failed. |
access_denied | The person cancelled at the provider. |
unverified | The email is not verified and belongs to an existing account. |
linked | The account is already linked to a different sign-in identity. |
mfa | The sign-in asked for multi-factor authentication. MachineMail does not support this yet. |
methods | The person’s organization does not allow this sign-in method. |
codes_paused | Too many codes for the address recently, so no verification code was sent. Try later. |
A wrong code is 400 on the code field. After 5 wrong codes for one code, the sign-in is gone (404) and the person sends a new code. A code that has expired or was already used also ends the sign-in with a 404 (“send a new code”), and so does an invitation that was revoked, expired or accepted elsewhere after the code was sent (“This invitation is no longer valid”). Sending a new code cancels the earlier one.
Wrong codes are also counted per email address and client IP (an IPv6 client by its /64), across every code, in the database (so every API machine and every restart sees them): after 20 wrong codes for an address from one IP in 24 hours, that IP gets no new code and no code check for the address (429, with Retry-After) until the oldest wrong code is a day old, and the address gets an email saying so. Someone guessing pauses only their own network: the person’s right code from anywhere else still works. New codes (email codes and email verification codes together) are limited in the database: 5 per 15 minutes per address and client IP, and 10 per rolling hour per address across every IP. That bounds guessing at 5 tries per code, so 50 an hour per address, however many IPs are used. These only delay new codes, never the check of a code already sent, and wrong codes alone do not hold back new codes for other networks; a verification that cannot get a code ends at /login?error=codes_paused. POST /auth/email/start answers the same way whether or not the address has an account. It only says something different for a domain that signs in with SSO. Codes and checks are rate limited per IP address and per email address; see Rate limits. The sign-in POST endpoints refuse requests whose Origin is another site.
POST /auth/logout ends the MachineMail session.
Magic links
A server without email-code sign-in (self-hosted or development) signs people in with a magic link instead. POST /auth/magic-link emails a link to GET /auth/magic, which sets mm_session. With the noop email transport, the response also carries the link as dev_link.
Where email codes are on, POST /auth/magic-link returns 404. Set MACHINEMAIL_MAGIC_LINK=true to turn magic links back on; the dashboard then offers “Email me a magic link” under the code form. A session from a magic link is not checked against an organization’s sign-in policy.
In development (noop transport), POST /auth/email/start returns the code as dev_code. In production (NODE_ENV=production) it does so only with an explicit MACHINEMAIL_DEV_MAGIC_LINK=true, and the server warns at boot.
GET /auth/config is public. It tells a client which sign-in methods to show:
email_code and providers say which methods to offer; magic_link says whether POST /auth/magic-link is on. The workos and authkit fields are kept for older clients: workos is true exactly when email_code is, and authkit is deprecated and always false. GET /auth/authkit is a deprecated path that redirects to /login.
Switching organizations
One person can belong to several organizations. The dashboard session works on one at a time:
| Method | Path | Does |
|---|---|---|
GET | /auth/organizations | Lists the person’s organizations, each with their role and a current flag |
POST | /auth/organizations/switch | Moves the session to { "organization_id": "..." } |
POST | /auth/organizations | Creates an organization from { "name": "..." }, as its owner, and switches to it |
A session belongs to the organization it signed in to. Switching to another organization checks the session against that organization’s own sign-in policy. When it does not allow how the person signed in, for example it requires single sign-on and they used an email code, the switch is refused with 403 and the person signs in again. The same check picks where a sign-in lands: never in an organization whose policy the sign-in does not meet. Leaving an organization (DELETE /v0/organizations/members/{your user_id}) moves the session the same way: to the first organization you belong to whose policy the session meets. When none does, the session ends, the mm_session cookie is cleared, and the dashboard sends you to /login.
The /auth/* endpoints are documented on the Dashboard Auth page. To manage who belongs to an organization, see Organizations and pods.