authentication.md


title: "Authentication" description: "How to authenticate with the MachineMail API: API keys, key scopes and permissions, agent access tokens, and the dashboard session, passkeys and invitations." canonical: "https://machinemail.dev/docs/reference/authentication"

Authentication

How to authenticate with the MachineMail API: API keys, key scopes and permissions, agent access tokens, and the dashboard session, passkeys and invitations.

Source: https://machinemail.dev/docs/reference/authentication

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

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/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

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:

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:

  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, valid for 1 hour).
  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:

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 can send 10 messages to 10 different recipients per rolling 24 hours, with at most 5 recipients per message (see Rate limits). Claiming or revoking a registration invalidates the tokens issued before it.

Token lifetimes:

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 passkey, 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:

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 MachineMail.

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:

GET /auth/pending returns the step in progress, for these pages.

Passkeys

A passkey signs a person in with their fingerprint, face or device PIN instead of an emailed code. Their password manager or device keeps it, and it only works on MachineMail. Where passkeys are on offer, GET /auth/config has "passkey": true, and /login shows a Passkey button next to the social sign-in buttons. Browsers that support passkey autofill also list the person's passkeys in the email field's suggestions.

Right after a sign-in, the dashboard may offer to create a passkey. Not now asks again in 14 days; Don't ask again stops asking.

Invitations

An invitation email links to https://machinemail.dev/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:

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.
address_not_allowed A new account for an address that receives mail through MachineMail. Sign up with an address outside MachineMail.
disposable_address A new account for a disposable email address. Sign up with an address you'll keep receiving mail at.
mailbox_has_account A new account for a mailbox that already has one. Sign in with the existing account's address, given masked in account. invited=1: an organization brought the person in; ask it to invite that address instead.

A wrong code is 400 on the code field. After 3 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: only the code in the newest email works (an earlier one is 400), from whichever browser asked for it. At most 4 right codes per address a minute are accepted; past that, and when sign-in for an address is briefly paused, the answer is 429 ("Too many sign-in attempts. Try again in N minutes.") with Retry-After, and the sign-in is kept so the same code works afterwards. Wrong codes never count toward this, so guessing cannot keep the person out.

Wrong codes are also counted per email address and client IP (an IPv6 client by its /64), across every code: 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: 5 per 15 minutes per address and client IP, and 10 per rolling hour per address across every IP. That bounds guessing at 3 tries per code, so 30 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, including for addresses that receive mail through MachineMail. 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.

Sign-in methods

MachineMail does not offer magic-link sign-in: POST /auth/magic-link returns 404. Sign in with an email code instead.

GET /auth/config is public. It tells a client which sign-in methods to show:

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

email_code, providers and passkey 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