Skip to content
machinemail

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:

CredentialLooks likeWhere it works
API keymm_live_ + 32 base62 characters/v0/*, /mcp, /inbound/raw
Agent access tokena JWT (eyJ...) from POST /oauth2/token/v0/*, /mcp
Dashboard sessionthe mm_session cookiethe 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

shell
curl https://api.machinemail.dev/v0/inboxes \
  -H "Authorization: Bearer mm_live_4f8Kq2ZbX9mN3pR7tV1wY6cH0jL5sD8a"

The server reads, in order:

  1. Authorization: Bearer <token> (the Bearer keyword is case-insensitive).
  2. ?token=<token> in the query string, only on GET /v0/events/stream and the /v0/ws WebSocket upgrade, for clients that cannot set headers.
  3. The mm_session cookie.

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:

http
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://api.machinemail.dev/.well-known/oauth-protected-resource"
Content-Type: application/json

{"error":{"type":"unauthorized","message":"Missing or invalid API key"}}

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 from 0-9A-Za-z, 40 characters in all.
  • The first 12 characters (for example mm_live_4f8K) are stored as the key’s prefix and 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 returns 401.
  • 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:

ScopeCreated withSees
OrganizationPOST /v0/api-keyseverything in the organization
PodPOST /v0/pods/{pod_id}/api-keysthat pod’s inboxes, threads, drafts, webhooks, domains and lists
InboxPOST /v0/inboxes/{inbox_id}/api-keysthat 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 to PATCH /v0/api-keys/{api_key_id}.
  • inbound_raw can only be granted from the dashboard (a signed-in person) or by a key that itself has inbound_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:

ActionRequirement
Create or delete an inboxnot inbox-scoped
Add a domainnot inbox-scoped
Create a pod, update the organization, remove a suppression, list organization membersorganization-scoped
List or revoke agent registrationsorganization-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>.

PermissionGrants
inbox_readGET inboxes
inbox_createPOST /v0/inboxes
inbox_updatePATCH /v0/inboxes/{inbox_id}
inbox_deleteDELETE /v0/inboxes/{inbox_id}
thread_readGET threads
thread_deleteDELETE threads
message_readGET messages, attachments, raw messages, trust reports, export; POST .../messages/batch-get
message_sendPOST .../messages/send, .../forward, .../reply, .../reply-all
message_replyPOST .../reply and .../reply-all (either this or message_send)
message_updatePATCH messages and threads; POST .../messages/batch-update
message_deleteDELETE messages
label_spam_readsee SPAM mail when you ask for it (include_spam or labels)
label_blocked_readsee BLOCKED mail when you ask for it
label_unauthenticated_readsee UNAUTHENTICATED mail when you ask for it
label_trash_readsee TRASH mail when you ask for it
draft_readGET drafts
draft_createPOST .../drafts
draft_updatePATCH .../drafts/{draft_id}
draft_deleteDELETE .../drafts/{draft_id}
draft_sendPOST .../drafts/{draft_id}/send
webhook_readGET webhooks and deliveries
webhook_createPOST /v0/webhooks
webhook_updatePATCH webhooks; POST .../test; POST .../deliveries/{delivery_id}/replay
webhook_deleteDELETE webhooks
domain_readGET domains, zone files, Domain Connect
domain_createPOST /v0/domains
domain_updatePATCH domains; POST .../verify
domain_deleteDELETE domains
list_entry_readGET allow/block lists and suppressions
list_entry_createPOST list entries and suppressions
list_entry_deleteDELETE list entries and suppressions
metrics_readGET metrics, the event log, GET /v0/events/stream
api_key_readGET API keys; GET agent registrations
api_key_createPOST API keys
api_key_updatePATCH API keys
api_key_deleteDELETE API keys; DELETE agent registrations
pod_readGET pods
pod_createPOST /v0/pods
pod_updatePATCH /v0/pods/{pod_id}
pod_deleteDELETE /v0/pods/{pod_id}
inbound_rawPOST /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:

  1. POST /agent/identity registers the agent. An anonymous registration gets a sandbox inbox on the shared domain and an identity_assertion.
  2. POST /oauth2/token with grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer exchanges the assertion for an access token (a JWT, 1 hour by default).
  3. POST /agent/identity/claim starts 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:

ScopePermissions
inboxes.readinbox_read
inboxes.writeinbox_create, inbox_update, inbox_delete
messages.readmessage_read, thread_read, label_spam_read, label_blocked_read, label_unauthenticated_read, label_trash_read
messages.sendmessage_send, message_reply, draft_read, draft_create, draft_update, draft_delete, draft_send
messages.writemessage_update, message_delete, thread_delete
webhooks.readwebhook_read
webhooks.writewebhook_create, webhook_update, webhook_delete
domains.readdomain_read
domains.writedomain_create, domain_update, domain_delete
lists.readlist_entry_read
lists.writelist_entry_create, list_entry_delete
metrics.readmetrics_read
pods.readpod_read
pods.writepod_create, pod_update, pod_delete
api_keys.readapi_key_read
api_keys.writeapi_key_create, api_key_update, api_key_delete

No scope grants inbound_raw.

Registration stateToken scopeScopes
Unclaimed anonymousits one sandbox inboxinboxes.read, messages.read, messages.send, messages.write
Unclaimed service_auth or identity_assertionnone: the token is rejected with 401
Claimedthe claiming organizationevery 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):

ItemLifetime
Access token1 hour
Identity assertion24 hours, never past the registration’s claim window
Claim window for an unclaimed registration24 hours
Sandbox inbox24 hours
Claim user code10 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 /login or /signup. POST /auth/email/start sends a sign-in code from the sign-in sender (MACHINEMAIL_AUTH_FROM, by default sign-in@<shared domain>). The person types the 6-digit code into one field, and POST /auth/email/verify checks 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/config lists the providers this server offers, from google, github, apple and microsoft. The provider returns the browser to GET /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:

CodeMeaning
signin_failedThe sign-in was refused, or it failed.
access_deniedThe person cancelled at the provider.
unverifiedThe email is not verified and belongs to an existing account.
linkedThe account is already linked to a different sign-in identity.
mfaThe sign-in asked for multi-factor authentication. MachineMail does not support this yet.
methodsThe person’s organization does not allow this sign-in method.
codes_pausedToo 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.

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:

json
{ "workos": true, "email_code": true, "providers": ["google", "github"], "magic_link": false, "password": false, "authkit": false }

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:

MethodPathDoes
GET/auth/organizationsLists the person’s organizations, each with their role and a current flag
POST/auth/organizations/switchMoves the session to { "organization_id": "..." }
POST/auth/organizationsCreates 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.