MOLTED EMAIL

Managing API keys programmatically

Mint, scope, list, rotate, and revoke API keys over the REST API -- for apps that provision keys for their own agents, workers, or customers.

If you're building on Molted, you'll often need to create and manage API keys without the portal -- for example, to hand each of your agents, workers, or end-customers a key scoped to just the mailboxes they should touch. Everything the portal does is available over the REST API.

This page focuses on key management. For the auth model and the portal/ session flow, see Authentication. For provisioning whole accounts programmatically, see Programmatic signup.

Prerequisites

You need one existing API key (mm_live_…) to authenticate the calls below. Create it at signup or in Settings → Access (/app/settings#access). That key's tenant is the tenant every key you mint will belong to -- you can never mint a key for another tenant.

The CLI and TypeScript SDK resolve your tenant ID and routing automatically. The raw REST examples here require you to pass tenantId explicitly (see below) -- that's the only difference.

Base URL, auth, and your tenant ID

All calls go to https://api.molted.email with a Bearer token:

Authorization: Bearer mm_live_your_key

Key endpoints are tenant-scoped, so they require your tenantId -- as a query param for GET/DELETE, or in the JSON body for POST/PATCH. If you don't know it, ask whoami (the one endpoint that derives the tenant from the key):

curl
curl -X POST https://api.molted.email/v1/agent/whoami \
  -H "Authorization: Bearer mm_live_your_key" \
  -H "Content-Type: application/json" -d '{}'
# → { "tenant_id": "tenant-…", ... }

Mint a scoped key

The minting endpoint is POST /v1/agent/keys. Scope a key three ways:

curl
curl -X POST https://api.molted.email/v1/agent/keys \
  -H "Authorization: Bearer mm_live_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "tenantId": "tenant-…",
    "label": "support-bot",
    "scopeAllMailboxes": false,
    "mailboxScopes": [
      { "mailboxId": "MAILBOX_UUID", "permissions": ["read", "send"] }
    ]
  }'
curl
curl -X POST https://api.molted.email/v1/agent/keys \
  -H "Authorization: Bearer mm_live_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "tenantId": "tenant-…",
    "label": "ops",
    "scopeAllMailboxes": false,
    "mailboxScopes": [
      { "mailboxId": "MBX_A", "permissions": ["read", "send"] },
      { "mailboxId": "MBX_B", "permissions": ["read"] }
    ]
  }'
curl
curl -X POST https://api.molted.email/v1/agent/keys \
  -H "Authorization: Bearer mm_live_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "tenantId": "tenant-…",
    "label": "admin-worker",
    "scopeAllMailboxes": true
  }'

Always send scopeAllMailboxes: false alongside mailboxScopes. The API infers it when omitted, but being explicit is the safe contract -- and it documents intent for anyone reading the call later.

Request fields

FieldTypeRequiredDescription
tenantIdstringYesYour tenant ID (from whoami).
labelstringNoHuman-readable name, max 64 chars. Helps you identify keys later.
scopeAllMailboxesbooleanNotrue mints a full-access (admin) key. Omit or false for a scoped key.
mailboxScopesarrayNoPer-mailbox grants: { mailboxId, permissions }. Up to 50 entries.
mailboxIdstringNoShorthand for a single mailbox with default ["read","send"] (instead of mailboxScopes).

Response -- the raw key is shown once

Response
{
  "id": "b2707074-…",
  "keyPrefix": "mm_live_b3a4aa6c31f6",
  "label": "support-bot",
  "status": "active",
  "scopeAllMailboxes": false,
  "createdAt": "2026-06-09T…",
  "rawKey": "mm_live_…"
}

Store rawKey immediately -- it is never retrievable again. Only the prefix is kept server-side.

Permission model

PermissionGrantsNote
readView threads, messages, contacts, and metadata on the mailbox.
sendSend, reply, and schedule follow-ups from the mailbox.Does not grant read.
manageRead, send, and modify rules, folders, and mailbox settings.Implies read + send.

A send-only key is ideal for outbound-only workers (notifications, campaigns) that should never read inbox contents.

Pattern: a key per worker (least privilege)

The common shape for apps built on Molted: provision one narrow key per agent or customer-facing worker, scoped to only what it needs. In TypeScript:

TypeScript
const API = "https://api.molted.email";
const ADMIN_KEY = process.env.MOLTED_API_KEY!; // your bootstrap key

async function whoami(): Promise<string> {
  const res = await fetch(`${API}/v1/agent/whoami`, {
    method: "POST",
    headers: { Authorization: `Bearer ${ADMIN_KEY}`, "Content-Type": "application/json" },
    body: "{}",
  });
  return (await res.json()).tenant_id;
}

async function mintScopedKey(tenantId: string, mailboxId: string) {
  const res = await fetch(`${API}/v1/agent/keys`, {
    method: "POST",
    headers: { Authorization: `Bearer ${ADMIN_KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify({
      tenantId,
      label: `worker:${mailboxId}`,
      scopeAllMailboxes: false,
      mailboxScopes: [{ mailboxId, permissions: ["read", "send"] }],
    }),
  });
  if (!res.ok) throw new Error(`mint failed: ${res.status} ${await res.text()}`);
  const key = await res.json();
  return key.rawKey; // hand this to the worker; store it securely
}

Each worker then authenticates with its own scoped key. If it ever tries to act on a mailbox outside its scope, the request is rejected with 403 mailbox_scope_denied -- your blast radius stays contained.

The same holds for event feeds and workspace-wide numbers. A scoped key's event stream and the webhooks it creates only receive events from its own mailboxes, and GET /v1/dashboard/delivery-summary counts only its mailboxes' sends. Routes whose data isn't tied to one mailbox refuse a scoped key with 403 mailbox_scope_denied: the dashboard summary, a list's send stats and send history, the dead letter queue, and outcome reporting.

A webhook a scoped key creates keeps the mailboxes it was created with. Narrowing or revoking that key later doesn't change or remove the webhook; manage it with a key that has access to every mailbox, or in the portal.

List, re-scope, and revoke

List
curl "https://api.molted.email/v1/agent/keys?tenantId=tenant-…" \
  -H "Authorization: Bearer mm_live_your_key"
# → [{ id, keyPrefix, label, status, scopeAllMailboxes, mailboxScopes, lastUsedAt, createdAt, createdByKeyId }]
Re-scope (PATCH)
curl -X PATCH https://api.molted.email/v1/agent/keys/KEY_ID \
  -H "Authorization: Bearer mm_live_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "tenantId": "tenant-…",
    "scopeAllMailboxes": false,
    "mailboxScopes": [{ "mailboxId": "MAILBOX_UUID", "permissions": ["manage"] }]
  }'
Revoke (DELETE)
curl -X DELETE "https://api.molted.email/v1/agent/keys/KEY_ID?tenantId=tenant-…" \
  -H "Authorization: Bearer mm_live_your_key"
# → { "revoked": true }

listApiKeys returns each key's mailboxScopes (with the mailbox address) so you can audit exactly what every key can reach. Mailbox IDs come from GET /v1/agent/mailboxes.

Rotation: raw keys can't be re-read, so to rotate you mint a fresh key, swap it into the consumer, then revoke the old one once traffic has moved over.

What a key may do to other keys

A key can only hand out what it has. From an agent key:

  • Mint keys within its own scope. A label-only request mints an all-mailbox (admin) key, so a scoped key must pass mailboxScopes within its own mailboxes and permissions (manage covers read and send).
  • Re-scope only itself or a key it minted, and only to narrow it.
  • Revoke only itself or a key it minted. Each key records its minting key as createdByKeyId in the list response (null for keys made in the portal). Revoking is always a tightening, so it is never gated (a session that belongs to an agent, e.g. a CLI login, may revoke any key its user owns, including one made before a key could record who minted it).

Anything else is a human decision: 403 human_decision_required with a decisionUrl (kind: "api-key", id the key id or new) and a loosens list, or a Molted-Deprecation: human-gate warning while your account is in warn. A person signed in to the portal manages keys as before. A session that belongs to an agent (for example a CLI signup) can still mint keys, and may revoke any key its user owns, but may narrow only a key it minted, and can't create or approve adoptions without a person. See Loosening a control.

Narrowing doesn't cascade. If you (or an agent) re-scope a key to fewer mailboxes or permissions, keys that key already minted keep the wider scope they were given -- narrowing the parent doesn't retroactively narrow its children. Audit createdByKeyId on each key in the list response and revoke or re-scope the children yourself if you need the narrowing to propagate.

Workspace-wide routes

Some routes act on the whole workspace rather than one mailbox, so no mailbox permission can cover them. A key scoped to specific mailboxes gets 403 mailbox_scope_denied on them, even with manage on its own mailbox:

  • domains: add, verify, delete, skip warmup, change rate limits or tracking;
  • provider failover policy and provider health checks;
  • billing: setup-link and activate-free;
  • alert destinations, including listing them and their deliveries;
  • inbound hooks, including listing them, their logs and dry-run tests;
  • account settings, humanizer and safety settings (/v1/agent/config/*, /v1/ops/*), resuming the account and updating incidents, and an agent's config;
  • contacts, lists and segments: every write (create, update, delete, subscribe, compute);
  • suppressions and suppressed domains: removing them, and the full lists;
  • consent: granting it;
  • agent coordination: registering an agent, leases (including listing them) and consensus requests and votes;
  • the event, outcome and deliverability-event ingest routes;
  • journeys, templates and experiments: every write, plus journey runs and experiment results.

Changes that only tighten are the exception, so a scoped agent that handles an unsubscribe reply can honour it: adding a suppression or a suppressed domain (including POST /v1/ops/suppressions and its bulk form), recording a consent withdrawal (POST /v1/consent with revokedAt), and unsubscribing an address from a list. A scoped key may make them when it holds send or manage on at least one mailbox; a read-only key gets 403 mailbox_scope_denied.

A key with access to all mailboxes also needs a write-level scope for the changes: manage or admin for the settings above, contacts, lists, segments and removing a suppression; automation, manage or admin for the rest, including the tightening changes. Otherwise it gets 403 insufficient_scope. Keys minted for all mailboxes carry admin, so this only affects older keys narrowed by hand.

Reads of workspace configuration stay open to a scoped key: domains, provider health and policy, billing status, contacts, lists, segments, templates, journeys, experiments, consent status, and checking one address or domain with GET /v1/suppressions/check. Agent analytics (send velocity, deliverability, policy decisions) count only the scoped key's own mailboxes; contact fatigue always counts every mailbox, so a scoped agent sees the contact's full load; suppression analytics need all-mailbox access.

This applies the same through the CLI, the MCP server and the mailbox service as directly against the API. Give an agent that manages shared data (contacts, lists, suppressions) or workspace settings an all-mailbox key.

Errors

StatusReasonMeaning
401missing_api_key / invalid_api_keyMissing Bearer token, or the key doesn't match the tenantId you passed.
403mailbox_not_ownedYou tried to scope a key to a mailbox your tenant doesn't own (create).
400Mailbox IDs do not belong to this tenantSame ownership failure on the update path.
403mailbox_scope_deniedA scoped key was used against a mailbox outside its scope, or on a workspace-wide route.
403insufficient_scopeAn all-mailbox key without a write-level scope tried to change workspace-wide state. See above.
403human_decision_requiredThe change would widen a key, or touch a key you didn't mint. See above.

See Errors for the full catalogue.

Security

  • Treat keys like passwords. Store them in a secrets manager or env vars; never commit them.
  • Prefer scoped keys over scopeAllMailboxes: true. Grant the minimum each consumer needs.
  • A tenantId you pass is always validated against the key -- you cannot mint or manage keys for another tenant.
  • Revoke any key you suspect is compromised; revocation takes effect immediately.

Managing everything else programmatically

Key management is one piece. Most of the platform is available over the same Bearer-authenticated API: