Authentication · MachineMail Docs
Sending a credential
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:
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:
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 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. From that time on it returns401with the messageThis API key expired at <time>. Create a new one.A key created with an expiring key never outlives it. See Expiry. - 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 issuemm_test_keys: every key ismm_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, valid for 1 hour).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 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:
- Email code. The person enters their address on
/loginor/signup.POST /auth/email/startsends a sign-in code fromsign-in@machinemail.dev. 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. - Passkey. On
/login, the Passkey button, or pick a passkey from the email field’s suggestions. Once the person has added one; see Passkeys. - Social providers. Each button goes straight to the provider (
GET /auth/social/{provider}), with no page in between.GET /auth/configlists the providers on offer, 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 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:
/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.
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.
- Adding one. In the dashboard, Settings → Security → Add a passkey (
POST /auth/passkeys/registration, thenPOST /auth/passkeyswith what the browser created). A passkey belongs to the person, not to an organization. Each one needs user verification (a fingerprint, face or PIN), and an account can have 20. Passkeys can be renamed there, and show when they were last used and whether the password manager syncs them. - Signing in.
POST /auth/passkey/optionsreturns the options for the browser’s WebAuthn API and sets anmm_passkeycookie (HttpOnly, path/auth) holding a challenge that works once, for 5 minutes.POST /auth/passkey/verifychecks the browser’s answer and replies likePOST /auth/email/verify: signed in, or the next step (picking an organization, single sign-on). Organizations’ sign-in policies apply as they do to an email code, so one that requires single sign-on still sends its members there. Passkey sign-ins are limited to 4 a minute per address, apart from email codes: asking for codes can’t hold up a passkey, and signing in with a passkey never uses up code tries. - A passkey removed from the account answers
400witherror.code``passkey_unknown. The dashboard asks the browser to forget it, where the browser supports that, and says to delete it from the password manager. Any other passkey that doesn’t check out is400witherror.code``passkey_invalid. - Confirming it’s you. Adding or removing a passkey and turning email codes off need a sign-in within the last 10 minutes. Otherwise the API answers
403witherror.code``step_up_requiredanderror.methods, and the dashboard asks the person to confirm with a passkey or with a 6-digit code emailed to them (10 minutes, 3 tries, 3 codes an hour). Confirming counts as a fresh sign-in for 10 minutes. - Turning email codes off. With at least one passkey, a person can turn email sign-in codes off (
PUT /auth/email-codes). A code asked for their address is then not sent: the address gets an email saying to sign in with a passkey instead, and/loginlooks the same either way. A code sent before codes were turned off stops working. While codes are off, the last passkey can’t be removed (409): turn codes back on or add another passkey first. Social sign-in and single sign-on keep working. - Removing a passkey signs out every session it signed in, on every device, this one too if it signed in here. Sign out other sessions (
POST /auth/sessions/revoke-others) ends all of the person’s sessions except the current one. - Emails. The address gets an email whenever a passkey is added or removed and whenever email codes are turned off or back on, with a link to Settings → Security.
- Lost every passkey with email codes off? Contact support@machinemail.dev. Once we’ve checked it’s you, we can turn email codes back on or remove a lost passkey.
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:
- 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. - A new account needs an email address outside MachineMail. An address that receives mail through MachineMail (an inbox on a MachineMail domain, or on a custom domain verified on MachineMail) can’t sign up. The code (or sign-in link) is sent to it as to any other address; once it is entered,
POST /auth/email/verifyanswers403witherror.code``signup_address_not_allowedinstead of creating the account, and a social or single sign-on sign-in ends at/login?error=address_not_allowed. People who already have an account sign in as before. Invitations to an address on a MachineMail domain are refused when they are sent, whether or not the address has an account. An organization that verified a custom domain can still invite people with addresses on it, and its single sign-on can create their accounts. Those people belong to the organizations that invite them but can’t create organizations of their own. - MachineMail doesn’t accept disposable email addresses (throwaway inboxes) for new accounts. Like the rule above, the refusal comes once the code is entered (
POST /auth/email/verifyanswers403witherror.code``disposable_email_not_allowed) or the link is opened, and a social or single sign-on sign-in ends at/login?error=disposable_address. Privacy relays and alias services, which forward to a real mailbox, are accepted. People who already have an account sign in as before, and invitations and agent claims are not refused for this. - One account per mailbox. Gmail addresses with the dots moved (or on
googlemail.com) and+aliases on any domain (you+test@example.com) are the same mailbox asyou@example.com. A new account for a mailbox that already has one is refused once the code is entered (POST /auth/email/verifyanswers403witherror.code``mailbox_has_account) or the link is opened, and a social or single sign-on sign-in ends at/login?error=mailbox_has_account. The message names the existing account’s address, masked (k•••@gmail.com), to sign in with. An invitation is for the address it was sent to, so when the invited address is another spelling of a mailbox that has an account, ask the organization to invite the existing account’s address instead. Before that, every address gets the same answer. Accounts already on such an address sign in as before, and limits per email address count per mailbox.
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. |
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 |
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.