MOLTED EMAIL

Authentication

Session auth for the portal UI and Bearer API keys for the sending API.

Molted Email uses two authentication methods depending on the context.

Session cookies (Portal UI)

When you sign in at molted.email, a session cookie is set. This cookie authenticates all portal requests: Home, mailboxes, and Settings (domains, plan and API keys).

No action is required on your part. The session is managed automatically by the portal.

Email verification

When you sign up in the portal, we email you a link to verify your address. Opening it confirms your email and signs you in. Verification is never needed to log in, and accounts created by an agent (the CLI, MCP or the API) don't get a verification email.

Links work for 24 hours. To get a new one, use Send verification email in Settings. You can ask for one a minute, up to 5 a day.

Forgot or reset your password

  1. On the sign-in page choose Forgot password? (or open molted.email/forgot-password), type the email address of your account and press Send reset link. The page gives the same answer whether or not the address has an account: it says a link was sent if an account exists.
  2. Open the email titled "Reset your Molted Email password". The link works for 1 hour.
  3. Type the new password twice on the Set new password page. It must be at least 8 characters.
  4. When the page says Password updated, press Sign in.

A link that is missing, expired or already used is refused; ask for a new one on the forgot-password page. A reset signs the account out of every other browser and out of the phone app, so sign in again wherever you need to. Your API keys are not touched and keep working. Apps connected through OAuth keep their access until you revoke them in Settings.

Workspaces and X-Molted-Tenant

One person can belong to several workspaces: their own, and any they claimed from an agent or were added to. A workspace's API keys are scoped to that workspace alone, so the switching below applies to people, not keys.

  • In the portal, open the ⌄ next to the workspace name in the top bar, which lists your workspaces, and pick one. The portal reloads Home in that workspace. It remembers your choice in a functional cookie, molted_tid, which is only ever a preference: it is checked against your memberships on every request, and a stale or foreign value falls back to the workspace you own (the oldest, if you own several).
  • In the phone app, there is no remembered workspace. The app names its workspace on every request with the X-Molted-Tenant header, set to a workspace id. GET /v1/me/tenants lists the workspaces a person can use, as { id, name, status, role } plus an opaque pushRef.
StatusErrorCause
400tenant_header_requiredA phone-app request without X-Molted-Tenant.
400invalid_tenant_headerThe header is not a single workspace id.
403not_a_memberThe person is not a member of exactly that workspace. There is no fallback to their own.

Workspace roles apply inside each workspace: some actions, such as renaming the workspace or deleting it, are for the owner only.

Bearer API keys (Sending API)

Every agent API request carries a Bearer token in the Authorization header:

Authorization: Bearer YOUR_API_KEY

Which host to call

https://mailbox.molted.email is the default for agents, and the examples in these docs use it. It serves every agent route, including the routes only the mailbox serves (/v1/agent/spam-filter/rules, the /v1/agent/contacts and /v1/agent/folders aliases, the deprecated intent-correction alias and /v1/agent/events/inbound), and fills in your tenantId from the key, so you never send one. The CLI and the MCP server go through it too.

https://api.molted.email serves the same agent routes except the mailbox-only ones, but needs your tenantId on every call (query string for a GET/DELETE, JSON body for a POST/PUT/PATCH). It stays the host for signing in and the portal-session routes (/api/auth/*, /v1/me/*), for the links in emails (unsubscribe, list confirmation) and inbound hook URLs (/v1/hooks/:token), and for the operator-only /v1/admin/* routes.

On the mailbox host, a POST that has no other fields still sends {} as its JSON body, so the service has a body to put your tenantId in.

Managing API keys

  1. Go to Settings > Access in the portal (/app/settings#access).
  2. Click Create key, give it a label, and choose all mailboxes or specific ones with their own permissions.
  3. Copy the key immediately -- it is displayed only once and cannot be retrieved later.
  4. To revoke a key, click Revoke next to the key in the list.

Mailbox-scoped keys

API keys can be scoped to specific mailboxes with granular permissions:

curl
curl -X POST https://api.molted.email/v1/me/keys \
  -H "Cookie: YOUR_SESSION_COOKIE" \
  -H "Content-Type: application/json" \
  -d '{
    "label": "support-inbox-only",
    "scopeAllMailboxes": false,
    "mailboxScopes": [
      { "mailboxId": "MAILBOX_UUID", "permissions": ["read", "send"] }
    ]
  }'

Set scopeAllMailboxes: true for a key with full access to all mailboxes (an admin key). Leaving it out means true when the request names no mailboxes, and false when it sends mailboxScopes.

A key also carries top-level scopes, derived from what it was minted with: an all-mailbox key gets admin; a mailbox-scoped key gets read for any permission, automation when it holds send or manage somewhere, and manage when it holds manage. Workspace-wide writes check these (see Workspace-wide routes). In the portal, the workspace owner's session acts as admin; any other member's session is read for workspace-wide changes.

Per-mailbox permissions:

PermissionGrants
readRead threads, messages, contacts, and metadata on the mailbox.
sendSend new emails, reply in threads, and schedule follow-ups from the mailbox. Does not grant read.
manageFull control: read, send, and modify rules, folders, and mailbox settings. Implies read + send.

A key with send only can send from the scoped mailbox but cannot list or fetch its threads -- useful for outbound-only agents (notifications, campaigns, alerts). Requests to other mailboxes return 403 mailbox_scope_denied.

Routes that act on the whole workspace (domains, providers, billing, alert destinations, inbound hooks, account and safety settings, writes to contacts, lists and segments, removing suppressions, granting consent, agent coordination, the ingest routes, and writes to journeys, templates and experiments) also return 403 mailbox_scope_denied to a mailbox-scoped key, whatever its permissions. Tightening changes (adding a suppression, withdrawing consent, unsubscribing) stay open to a scoped key with send or manage somewhere. See Workspace-wide routes.

The scope is the same whichever way the key reaches us: directly against the API (including the mailbox, thread, outbound, rule, override and alert routes under /v1/agent/*, which the API serves from the mailbox service -- the bulk thread actions archive, unarchive, trash, restore and permanent delete resolve each thread's mailbox and check it too), or through the CLI and MCP server (which go through the mailbox service). A send that names no mailboxId goes out from the key's own mailbox, and the approval queue (GET /v1/send/approvals) lists only the key's mailboxes.

Minting scoped keys as an agent

An existing agent can mint further scoped keys with its own Bearer token (no session cookie needed) -- useful for handing a sub-agent a narrow key. The agent endpoint POST /v1/agent/keys takes the same shape (on https://api.molted.email it also needs your tenantId in the body; the mailbox host and the CLI fill it in):

curl
curl -X POST https://mailbox.molted.email/v1/agent/keys \
  -H "Authorization: Bearer mm_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "label": "support-inbox-only",
    "scopeAllMailboxes": false,
    "mailboxScopes": [
      { "mailboxId": "MAILBOX_UUID", "permissions": ["read", "send"] }
    ]
  }'
CLI
molted auth keys create --label support-inbox-only \
  --mailbox MAILBOX_UUID --permissions read,send
molted auth keys create --label triage --scope MBX_A:read,send --scope MBX_B:read
molted auth keys create --label ops --scope-all
molted auth keys update <id> --mailbox MAILBOX_UUID --permissions manage
molted auth keys list
molted auth keys revoke <id>

--permissions defaults to read,send; --scope <mailboxId>[:<perms>] is repeatable, for several mailboxes at once. --scope-all can't be combined with the mailbox flags, and update changes the scope only (it has no --label). There is no MCP tool for key management.

A token can only scope a key to mailboxes its own tenant owns; scoping to a mailbox owned by another tenant is rejected (403 mailbox_not_owned: <ids> on create, 400 Mailbox IDs do not belong to this tenant: <ids> on update, and 400 mailbox_scope_invalid on POST /v1/me/keys). The raw key is returned once in the rawKey field -- store it immediately. A key may only hand out what it has: widening a key, or touching a key it didn't mint, is a human decision (403 human_decision_required, see What a key may do to other keys).

For the full programmatic key-management guide (listing, re-scoping, rotating, revoking, and the least-privilege pattern for apps), see Managing API keys programmatically.

Programmatic signup

Signup is open. If it's ever paused, POST /api/auth/sign-up/email returns 403 { "code": "SIGNUPS_CLOSED", "waitlistUrl": "..." }: hand a person the waitlistUrl.

Agents can create accounts and provision API keys without the portal UI. To join a workspace someone already runs, don't sign up: adopt into it with an invite token or the device flow (molted adopt claim / molted adopt request).

Step 1: Create an account

curl
curl -X POST https://api.molted.email/api/auth/sign-up/email \
  -H "Content-Type: application/json" \
  -c cookies.txt \
  -d '{
    "name": "Ada Agent",
    "email": "agent@example.com",
    "password": "secure-password-here"
  }'

This creates a user account and automatically provisions a tenant (named after the first word of name: Ada's workspace), a default mailbox on the shared agent.molted.email domain, and the default _default transactional template. The session cookie is saved to cookies.txt. An account created through the API, CLI or MCP is agent-owned: no person owns the workspace until a human claims it (see Links for your human), and the Free plan stays closed to it until then. In production, sign-up allows 3 attempts an hour per IP address. The CLI does steps 1 to 3 in one command: molted auth signup.

Step 2: Get your tenant ID

curl
curl https://api.molted.email/v1/me/tenant \
  -b cookies.txt
Response (trimmed)
{
  "id": "tenant-agent-a1b2c3d4",
  "name": "Ada's workspace",
  "status": "active",
  "billing_plan": "trial",
  "created_at": "2026-10-21T09:00:00.000Z"
}

The ID is tenant-<email local part>-<8 random characters>, so it is not anonymous. status is the account's state (active while it can be used); the plan is billing_plan, which starts at trial with sends blocked until a plan is activated (see Billing & Plans). The owner also sees billing and settings fields such as trial_ends_at, has_payment_method, human_gate_mode and cooldown_minutes.

Step 3: Create an API key

curl
curl -X POST https://api.molted.email/v1/me/keys \
  -H "Content-Type: application/json" \
  -b cookies.txt \
  -d '{
    "label": "default",
    "scopeAllMailboxes": true
  }'
Response
{
  "id": "key-uuid",
  "keyPrefix": "mm_live_...",
  "label": "default",
  "status": "active",
  "scopeAllMailboxes": true,
  "createdAt": "2026-10-21T09:00:00.000Z",
  "rawKey": "mm_live_..."
}

Save rawKey immediately -- it is only returned once. Use it as your Authorization: Bearer token for all subsequent API calls.

When your agent needs a person (to pick a plan, approve a send, manage domains, or get the Free plan), it runs one command: molted auth login-link. Molted picks the path from what it knows. Nothing it returns signs anyone in: the person always uses their own password.

  • No person owns the workspace yet, and the agent's account email is an outside inbox (the agent signed up with its person's own address, say): Molted emails that address a take-over link and returns no link at all. Your human opens the email, sets their own password, and the account is theirs. See Taking over an account.
  • No person owns the workspace yet, and the account email is one of the agent's Molted mailboxes: the link is a claim link. Your human opens it, signs in or creates an account with their own email, and claims the workspace. They become its owner; the agent keeps working with its keys.
  • A person owns it: the link is the plain portal URL. Your human signs in with their own login.

For the Free plan, an agent runs molted auth login-link --next "/app/settings#plan" and follows what it returns; afterwards molted billing activate-free works with the agent's existing key.

CLI

CLI
molted auth login-link
molted auth login-link --next "/app/settings#plan"

The command uses your active profile credentials. --next is where your human lands afterwards (a path in the app). The CLI adds a note to the API's answer explaining what to do with the link; the API itself returns kind, url and expiresAt. There is no MCP tool for this: MCP clients follow setup_status, whose next.request names this call when a human is needed.

Response (no person owns the workspace, the account email is an outside inbox)
{
  "kind": "takeover",
  "url": null,
  "sentTo": "m•••@gmail.com",
  "expiresAt": "2026-10-24T14:15:00.000Z",
  "alreadySent": false,
  "note": "Molted emailed a take-over link to m•••@gmail.com. There is no url for you on purpose: ..."
}
Response (no person owns the workspace, the account email is a Molted mailbox)
{
  "kind": "claim",
  "url": "https://molted.email/claim/AbC123...",
  "expiresAt": "2026-10-16T14:15:00.000Z",
  "note": "A claim link: give the url to your human. ..."
}
Response (a person owns the workspace)
{
  "kind": "portal",
  "url": "https://molted.email/app",
  "expiresAt": null,
  "note": "The portal: a person already owns this workspace. ..."
}

API

POST https://mailbox.molted.email/v1/agent/login-token

Requires Bearer authentication with an admin, all-mailbox key: a claim makes a person the owner of the whole workspace, so the calling key must already have that reach. A key scoped to one or more specific mailboxes gets 403 { "message": "mailbox_scope_denied" }; a key without admin scope gets 403 { "message": "insufficient_scope" }.

FieldTypeRequiredDescription
tenantIdstringNoOnly on api.molted.email, where it is required; mailbox.molted.email fills it in from your key.
nextstringNoWhere your human lands afterwards: a path in the app (/app or /app/...), or a URL on the portal whose path is /app or /app/... (it is reduced to the path, query and fragment). At most 512 characters; control characters and backslashes are refused. Anything else is 400 { "error": "invalid_next", "message": "..." }. Default /app.
curl
curl -X POST https://mailbox.molted.email/v1/agent/login-token \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"next": "/app/settings#plan"}'

Response fields

FieldTypeDescription
kind"takeover", "claim" or "portal"What happened: a take-over email went out, or the link is a claim link or the plain portal URL.
urlstring or nullThe link to give your human. It carries no sign-in token. null for takeover: the emailed link grants the account, so it never comes back to the agent.
expiresAtstring or nullWhen the take-over link (24 hours) or claim link (7 days) stops working. null for a portal link, which doesn't expire.
sentTostringtakeover only: the address the email went to, masked (m•••@gmail.com).
alreadySentbooleantakeover only: true when an email from the last 15 minutes answered and no new one went out.

Taking over an account

An agent that signed up with its person's own email holds an account the person can neither claim (the address is taken) nor use (the agent chose the password). The take-over email fixes both:

  1. login-token (or the person, signed in to the portal with that account, choosing I'm a person, or Take over this account on a claim page) sends a take-over link to the account's address.
  2. Your human opens it in their browser and sets their own password.
  3. In one step: the password the agent chose stops working, the account becomes a verified person's, every session and phone signed in to it is signed out, and every older take-over or password-reset link of the account stops working. The agent's API keys keep working.
  4. They are signed in with the new password and land on next (default Settings > Plan, where Free is one click).

Afterwards login-token answers kind: "portal". The take-over link works once, for 24 hours, and only in a browser. It is never sent to an address Molted receives mail for (anything on the shared agent.molted.email domain, a custom domain receiving through Molted, a Molted mailbox address, plus-tags and letter case included) or to a disposable address; those get a claim link instead, and the rule is checked again when the link is used. A repeat request within 15 minutes sends nothing and answers alreadySent: true; an account gets at most 5 take-over emails a day.

RouteAuthWhat it does
POST /v1/me/principal/upgradePortal session of an agent accountI'm a person: emails the take-over link to the account's address. { sent, sentTo, expiresAt, alreadySent }. 409 already_human, 403 email_not_allowed or disposable_email_not_allowed.
POST /v1/takeover/inspectNone (the token){ token }: whether the link still works, { sentTo } (masked). 400 invalid_token, 410 token_used or token_expired, 403 email_not_allowed. Changes nothing.
POST /v1/takeover/confirmNone (the token), browser only, from the portal{ token, password, next? }: sets the password, makes the account the person's, and signs them in. { next, signedIn }. The errors above plus 400 password_too_short / password_too_long, 403 browser_required / invalid_origin.

The old I'm a person confirm link (/v1/principal/upgrade/confirm, from before take-over links) no longer does anything: it shows a page asking for a new link, because it marked the account as a person's without replacing the agent's password.

Claiming a workspace

  1. Your human opens the claim link. The page shows the workspace's name, which agent key asked, and what claiming means.
  2. They sign in, or create an account with their own email. Creating an account through a claim link works even while sign-ups are on the waitlist, and it creates no extra workspace. It asks for no password: we email a link to set one, so the password is always chosen by whoever reads that inbox. Setting it signs them in.
  3. They click Claim. This needs a verified email address and a password sign-in from the last 10 minutes; an older session is asked to sign in again. The account must use an email address of their own: an address that receives mail through Molted (anything on the shared agent.molted.email domain or its subdomains, any workspace's verified custom domain with receiving on, or any Molted mailbox address) can't be used to claim, plus-tags included.
  4. They land where the agent asked (next), with the claimed workspace open, and the workspace switcher shows it next to any workspace of their own.

After the claim, login-token answers kind: "portal".

Behavior and limits

  • A claim link works for 7 days and once, and it can create one account. A new link doesn't cancel older ones, but once a person owns the workspace every outstanding link stops working.
  • Revoking the agent key that asked for a link also kills the link.
  • A workspace gets at most 5 claim links an hour (a rolling hour); after that the call answers 429 { "error": "rate_limited", "message": "...", "retryAfterSeconds": ... } with a Retry-After header. Reuse a link you already have.
  • An expired, used or unknown link shows its own page, and nothing happens.

Claim API

The claim page in the portal drives these routes; they are listed for completeness, since an agent only ever calls login-token.

RouteAuthWhat it does
GET /v1/claim/:codeNone{ workspaceName, requestedBy } for the claim page. 404 claim_not_found, 410 claim_expired, 410 claim_used.
POST /v1/me/claim/:codePortal session (from the portal origin)Claims the workspace: { tenantId, workspaceName, next }. Refused with 403 human_required, email_verification_required, fresh_sign_in_required (no password sign-in in the last 10 minutes), read_only_device (the phone app) or an address Molted receives mail for, plus the 404/410 above.
POST /v1/me/claim/:code/verification-emailPortal sessionResends the verification email (once a minute, 5 a day).
POST /v1/claim/:code/set-password-linkNone{ email }: emails a set-password link to an account created through this claim link. Always answers { "sent": true }. The link works for 24 hours; at most 3 an hour.
POST /v1/claim/:code/set-passwordNone{ token, password }: sets the password and signs in. Answers { next, workspaceName, signedIn }. 400 invalid_token, password_too_short or password_too_long.

An account created through a claim link (the portal sends the code with POST /api/auth/sign-up/email) gets no session and no password of its own: any password sent is replaced, and the set-password email is how its owner gets in. Only one account can be created per link.

  • A claim link grants nothing on its own. Only a person (not an agent's account) can claim, with a verified email address Molted doesn't receive mail for, and only right after typing their password, so an agent that opens its own link gets neither a session nor ownership.
  • What this can't stop: an agent that controls an email account outside Molted could sign up "as a person" with it. Passkeys or a registered device are the real fix, and are planned.
  • Still share it only with the person who should own the workspace: whoever claims it first becomes an owner.
  • Older login links (/auth/token-login?token=..., from before claim links) no longer sign anyone in: they open the login page, and the sessions they created were signed out when claim links shipped.
  • A password reset signs the account out of every other browser and the phone app. Apps connected through OAuth keep their access until you revoke them in Settings.

Security notes

  • An all-mailbox key can do anything in your tenant, and a scoped key everything its mailboxes allow. Treat keys like passwords.
  • Store keys in environment variables or a secrets manager. Never commit them to source control.
  • Rotate keys periodically. Revoke any key you suspect has been compromised.
  • Each key is scoped to a single tenant.
  • A suspended account cannot sign in, and any existing session is rejected with 403 { "error": "USER_SUSPENDED" } on its next request. API keys are tenant-scoped, so suspending a user does not revoke that user's tenant's API keys.