# Managing API keys programmatically

Source: https://molted.email/docs/api-keys

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](https://molted.email/docs/authentication.md). For provisioning whole
accounts programmatically, see [Programmatic signup](https://molted.email/docs/authentication.md#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.

> **Note:** The CLI (`molted auth keys ...`) resolves your credentials and routing automatically; the
> raw REST examples below do the same calls by hand.
> The MCP server has no key-management tools.

## Base URL and auth

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

```bash
Authorization: Bearer mm_live_your_key
```

The mailbox host reads your tenant from the key, so the examples carry no
`tenantId`. On `https://api.molted.email` the key endpoints need it, as a query
param for `GET`/`DELETE` or in the JSON body for `POST`/`PATCH`; `whoami`
returns it:

```bash title="curl"
curl -X POST https://mailbox.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:

**Single mailbox**

```bash title="curl"
curl -X POST https://mailbox.molted.email/v1/agent/keys \
  -H "Authorization: Bearer mm_live_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "label": "support-bot",
    "scopeAllMailboxes": false,
    "mailboxScopes": [
      { "mailboxId": "MAILBOX_UUID", "permissions": ["read", "send"] }
    ]
  }'
```

**Multiple mailboxes**

```bash title="curl"
curl -X POST https://mailbox.molted.email/v1/agent/keys \
  -H "Authorization: Bearer mm_live_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "label": "ops",
    "scopeAllMailboxes": false,
    "mailboxScopes": [
      { "mailboxId": "MBX_A", "permissions": ["read", "send"] },
      { "mailboxId": "MBX_B", "permissions": ["read"] }
    ]
  }'
```

**All mailboxes**

```bash title="curl"
curl -X POST https://mailbox.molted.email/v1/agent/keys \
  -H "Authorization: Bearer mm_live_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "label": "admin-worker",
    "scopeAllMailboxes": true
  }'
```

> **Warning:** 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. A request with neither
> `mailboxScopes` nor `mailboxId` mints an all-mailbox key.

### Request fields

| Field | Type | Required | Description |
| - | - | - | - |
| `tenantId` | `string` | No | Only on `api.molted.email`, where it is required; `mailbox.molted.email` fills it in from your key. |
| `label` | `string` | No | Human-readable name (default `default`). Keep it to 64 characters: `POST /v1/me/keys` refuses longer. |
| `scopeAllMailboxes` | `boolean` | No | `true` mints a full-access (`admin`) key. When omitted it is `false` if you send `mailboxScopes` or `mailboxId`, and `true` otherwise: a label-only request mints an all-mailbox key. |
| `mailboxScopes` | `array` | No | Per-mailbox grants: `{ mailboxId, permissions }`, permissions from `read`, `send`, `manage`. Up to 50 entries on `POST /v1/me/keys`. |
| `mailboxId` | `string` | No | Agent route only: shorthand for a single mailbox with default `["read","send"]` (instead of `mailboxScopes`). |

### Response -- the raw key is shown once

```json title="Response"
{
  "id": "b2707074-…",
  "keyPrefix": "mm_live_xxxxxxxxxxxx",
  "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

| Permission | Grants | Note |
| - | - | - |
| `read` | View threads, messages, contacts, and metadata on the mailbox. | |
| `send` | Send, reply, and schedule follow-ups from the mailbox. | Does **not** grant `read`. |
| `manage` | Read, 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:

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

async function mintScopedKey(mailboxId: string) {
  const res = await fetch(`${API}/v1/agent/keys`, {
    method: "POST",
    headers: { Authorization: `Bearer ${ADMIN_KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify({
      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](https://molted.email/docs/events.md#mailbox-scoped-keys) and the
[webhooks](https://molted.email/docs/webhooks.md#mailbox-scoped-keys) 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

```bash title="List"
curl "https://mailbox.molted.email/v1/agent/keys" \
  -H "Authorization: Bearer mm_live_your_key"
# → [{ id, keyPrefix, label, status, scopeAllMailboxes, mailboxScopes, lastUsedAt, createdAt, createdByKeyId }]
```

Listing needs an all-mailbox key: a mailbox-scoped key gets `403 mailbox_scope_denied`.

```bash title="Re-scope (PATCH)"
curl -X PATCH https://mailbox.molted.email/v1/agent/keys/KEY_ID \
  -H "Authorization: Bearer mm_live_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "scopeAllMailboxes": false,
    "mailboxScopes": [{ "mailboxId": "MAILBOX_UUID", "permissions": ["manage"] }]
  }'
# → { "updated": true }
```

```bash title="Revoke (DELETE)"
curl -X DELETE "https://mailbox.molted.email/v1/agent/keys/KEY_ID" \
  -H "Authorization: Bearer mm_live_your_key"
# → { "revoked": true }
```

The list returns each key's `mailboxScopes` (`{ mailboxId, address, permissions }`) so
you can audit exactly what every key can reach. Re-scoping an unknown or revoked key
answers `404 API key not found or revoked`; revoking one twice, `404 API key not found or already revoked`.
Revoking a key that an OAuth consent minted also revokes that app's grant.

CLI: `molted auth keys list`, `molted auth keys update <id> --mailbox <id> --permissions <perms>`
(or `--scope <mailboxId>[:<perms>]`, repeatable, or `--scope-all`), `molted auth keys revoke <id>`. Mailbox IDs come from
[`GET /v1/agent/mailboxes`](https://molted.email/docs/mailboxes.md).

**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. An all-mailbox key may mint any key. 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: the new
  scope must fit within the key's current scope and the caller's own.
- **Revoke** itself or a key it minted, ungated. Each key records its minting
  key as `createdByKeyId` in the list response (`null` for keys made in the
  portal). Revoking any other key is a human decision (`loosens: ["another key"]`). A session that belongs to an agent (e.g. a CLI login)
  may revoke any key its user owns, ungated, including one made before a key
  could record who minted it.

Anything else is a human decision: `403 human_decision_required` with a
`decisionUrl` (`<portal>/app/decide/api-key/<id>`, `kind: "api-key"`, `id` the
key id or `new`) and a `loosens` list naming what the change would widen, or a
`Molted-Deprecation: human-gate` warning header while your account is in
`warn` (new accounts start in `enforce`). Hand the `decisionUrl` to a person.
A person signed in to the portal manages keys as before: the workspace owner
any key, another member only keys they created (anything else is `403 Only the
tenant owner or this key's creator can perform this action`). 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](https://molted.email/docs/errors.md#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

| Status | Reason | Meaning |
| - | - | - |
| `401` | `missing_api_key` / `invalid_api_key` | Missing Bearer token, or the key doesn't match the `tenantId` you passed. |
| `401` | `missing_tenant_id` | On `api.molted.email`: no `tenantId` in the query or body. |
| `403` | `mailbox_not_owned: <ids>` | You tried to scope a key to a mailbox your tenant doesn't own (`POST /v1/agent/keys`). |
| `400` | `mailbox_scope_invalid` | The same ownership failure (or a deleted mailbox) when a key is created through `POST /v1/me/keys`, the admin route or an adoption claim; the body lists `mailboxIds`. The update path answers `Mailbox IDs do not belong to this tenant: <ids>`. |
| `404` | `API key not found or revoked` | Re-scoping or revoking a key that doesn't exist or is already revoked. |
| `403` | `mailbox_scope_denied` | A scoped key was used against a mailbox outside its scope, or on a [workspace-wide route](#workspace-wide-routes). |
| `403` | `insufficient_scope` | An all-mailbox key without a write-level scope tried to change workspace-wide state. See [above](#workspace-wide-routes). |
| `403` | `human_decision_required` | The change would widen a key, or touch a key you didn't mint. See [above](#what-a-key-may-do-to-other-keys). |

See [Errors](https://molted.email/docs/errors.md) 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 key only ever acts on its own tenant: the mailbox host takes the tenant from the key, and a `tenantId` passed on `api.molted.email` is validated against it, so you cannot mint or manage keys for another tenant.
- Revoke any key you suspect is compromised; revocation takes effect immediately.

### Undo what a key changed

Revoking stops a key, but leaves what it already changed. The workspace owner
can do both in one step from **Settings > Access > Undo changes…**: Molted stops
the key and every key it created (revokes them, or suspends them until a person
reactivates them), then puts back every settings change they made since a time
you pick, except suppressions, which it lists for review. A suspended key
answers `401` everywhere, like a revoked one, and is listed in `GET /v1/me/keys`
and `GET /v1/agent/keys` with `status: "suspended"`. No agent key can revoke
or rescope a suspended key, not even the key that created it
(`403 key_suspended`): only a person reactivates or revokes it. Keys minting
keys can go at most 20 generations deep (`422 key_chain_too_deep`).
See
[Undo what an agent changed](https://molted.email/docs/undo-agent-changes.md).

## Managing everything else programmatically

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

- **Send & reply** -- [Sending email](https://molted.email/docs/sending-email.md), [Outbound](https://molted.email/docs/outbound.md), [Batch operations](https://molted.email/docs/batch-operations.md)
- **Mailboxes & routing** -- [Mailboxes](https://molted.email/docs/mailboxes.md), [Rules](https://molted.email/docs/rules.md), [Threads](https://molted.email/docs/threads.md)
- **Domains & deliverability** -- [Domains](https://molted.email/docs/domains.md), [Sender addresses](https://molted.email/docs/sender-addresses.md), [Analytics](https://molted.email/docs/analytics.md)
- **Contacts & compliance** -- [Contacts](https://molted.email/docs/contacts.md), [Suppressions](https://molted.email/docs/suppressions.md), [Lists](https://molted.email/docs/lists.md), [Segmentation](https://molted.email/docs/segmentation.md)
- **Automation** -- [Journeys](https://molted.email/docs/journeys.md), [Experimentation](https://molted.email/docs/experimentation.md)
- **Real-time** -- [Events (SSE)](https://molted.email/docs/events.md), [Webhooks](https://molted.email/docs/webhooks.md), [Alerts](https://molted.email/docs/alerts.md)
- **Higher-level clients** -- the [CLI](https://molted.email/docs/cli.md), the [MCP server](https://molted.email/docs/mcp-server.md), and [framework integrations](https://molted.email/docs/reactive-agent-guide.md)
