---
title: "API keys"
description: "How MachineMail API keys work, how to scope them to a pod or a single inbox, and how to limit them to specific permissions."
canonical: "https://machinemail.dev/docs/concepts/api-keys"
---

# API keys

> How MachineMail API keys work, how to scope them to a pod or a single inbox, and how to limit them to specific permissions.

Source: https://machinemail.dev/docs/concepts/api-keys

Every request to `/v0` and `/mcp` authenticates with a Bearer token:

```http
Authorization: Bearer mm_live_5RdovJbvKguM5moYSUoEos8S1BiSk1S9
```

Keys start with `mm_live_` followed by 32 base62 characters. MachineMail stores only a SHA-256 hash of each key, so the full secret is shown once, when the key is created. The first 12 characters are kept as `prefix` so you can recognise a key in lists.

Your first key is created when you first sign in and shown once in the dashboard. Create more at [machinemail.dev/app/api-keys](https://machinemail.dev/app/api-keys) or through the API. [Create a key for an agent](https://machinemail.dev/docs/guides/api-key-for-an-agent) walks through both.

A request without a valid key gets `401 unauthorized` with a `WWW-Authenticate: Bearer resource_metadata="..."` header. Agents use that header to discover [auth.md self-registration](https://machinemail.dev/docs/agents/auth-md).

## Scope

A key has one of three scopes. The scope is fixed when the key is minted and decided by the route you mint it on.

| Scope        | Minted at                              | Can reach                                                                      |
| ------------ | -------------------------------------- | ------------------------------------------------------------------------------ |
| Organization | `POST /v0/api-keys`                    | Everything in the organization.                                                |
| Pod          | `POST /v0/pods/{pod_id}/api-keys`      | Resources in one pod.                                                          |
| Inbox        | `POST /v0/inboxes/{inbox_id}/api-keys` | One inbox: its messages, threads, drafts, and webhooks and lists scoped to it. |

Out-of-scope resources return `404`, as if they did not exist. Some operations need a wider scope and return `403`:

- Only organization keys can mint, and so only they can create pod and inbox keys. Pod and inbox keys cannot create any keys.
- Only organization keys can create pods and remove suppressions.
- Inbox keys cannot create or delete inboxes, or add domains.

Use an inbox key when you hand credentials to a single agent: a leaked key exposes one mailbox, not the account.

## Creating a key

**curl**

```bash title="Terminal"
curl https://api.machinemail.dev/v0/inboxes/support-agent@machinemail.dev/api-keys \
  -H "Authorization: Bearer $MACHINEMAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "support agent",
    "permissions": { "message_read": true, "thread_read": true, "message_reply": true },
    "expires_at": "2027-01-01T00:00:00Z"
  }'
```

**TypeScript**

```ts title="create-key.ts"
const inbox = encodeURIComponent('support-agent@machinemail.dev');
const res = await fetch(`https://api.machinemail.dev/v0/inboxes/${inbox}/api-keys`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.MACHINEMAIL_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    name: 'support agent',
    permissions: { message_read: true, thread_read: true, message_reply: true },
    expires_at: '2027-01-01T00:00:00Z',
  }),
});
const { api_key } = await res.json(); // shown once
```

**Python**

```python title="create_key.py"
import os, requests

key = requests.post(
    "https://api.machinemail.dev/v0/inboxes/support-agent@machinemail.dev/api-keys",
    headers={"Authorization": f"Bearer {os.environ['MACHINEMAIL_API_KEY']}"},
    json={
        "name": "support agent",
        "permissions": {"message_read": True, "thread_read": True, "message_reply": True},
        "expires_at": "2027-01-01T00:00:00Z",
    },
).json()
print(key["api_key"])  # shown once
```

```json title="Response"
{
  "api_key_id": "key_01m4fy8hmg0000dgevarwczcf7",
  "api_key": "mm_live_5RdovJbvKguM5moYSUoEos8S1BiSk1S9",
  "prefix": "mm_live_5Rdo",
  "name": "support agent",
  "inbox_id": "support-agent@machinemail.dev",
  "pod_id": "pod_01m4fy8h150000w4xty1ehfnqa",
  "permissions": { "message_read": true, "thread_read": true, "message_reply": true },
  "created_at": "2026-10-09T09:00:16.400Z",
  "expires_at": "2027-01-01T00:00:00.000Z"
}
```

| Body field    | Notes                                                                                                                                                                                                                        |
| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name`        | Label for the key. Defaults to "API key".                                                                                                                                                                                    |
| `permissions` | Optional allowlist. An object of booleans (`{ "message_read": true }`) or an array of names. Omit it for full access within the scope.                                                                                       |
| `expires_at`  | Optional RFC 3339 time, at least 5 minutes ahead. The key stops working at that moment. Omit it or send `null` for no expiry (a key created with an expiring API key gets that key's expiry instead). See [Expiry](#expiry). |

## Expiry

A key never expires unless you give it an `expires_at` when you create it. Use one for anything that only needs a key for a while: a CI job, a contractor, an agent that runs for a week.

- `expires_at` is an RFC 3339 time (`2027-01-01T00:00:00Z`, or with an offset such as `+02:00`). A date alone (`2027-01-01`) or a time without a zone is read as UTC. It must be at least 5 minutes from now; a time in the past, a closer one, a date that does not exist (`2027-02-30`) or another format is `400` with `param: "expires_at"`. There is no maximum.
- The expiry is fixed. `PATCH` cannot change it: create a new key, switch over, then revoke the old one.
- Every response that returns the key includes `expires_at` when it has one. `GET /v0/auth/me` returns it as `api_key_expires_at` for the key you call with.

In the dashboard, pick **Expires** when you create a key: Never, 7 days, 30 days, 90 days, 1 year or a custom date (the end of that day, in your time zone). The keys list shows how long each key has left, and expired keys move to the bottom. With the CLI, use `--expires-in 30d` (or `90m`, `12h`, `2w`, `1y`) or `--expires-at 2027-01-01T00:00:00Z` on `machinemail api-keys create`.

MachineMail emails a reminder about 7 days before a key expires, and another about a day before, with a link to the API keys page. They go to the person who created the key, or to the organization's owners when that person is no longer a member. A reminder is skipped when the key was created with little time left (a 7-day key gets only the second one, a 1-day key none). Revoked keys get none, and there is no email when a key expires.

### When a key expires

From its `expires_at` on, every request with the key gets `401` on the REST API, the MCP server, the WebSocket and the event stream:

```json title="Response"
{ "error": { "type": "unauthorized", "message": "This API key expired at 2027-01-01T00:00:00.000Z. Create a new one." } }
```

A WebSocket or event stream that is open when the key expires is closed at that moment, with the same message. Expired keys stay in lists, with their `expires_at`, until you revoke them.

Things the key set up stay in place after it expires, as they do when a key is revoked: webhooks it created keep delivering, and attachment download links it was given work until their own expiry. A request already in progress (such as an MCP `wait_for_message`) can finish. When access must end, review and delete what the key created.

### Keys created with an expiring key

A key created with another API key cannot outlive it:

- Without `expires_at` (or with `null`), the new key gets the creating key's expiry, even if that is less than 5 minutes away. The response shows it.
- An `expires_at` later than the creating key's is `403`.
- An expiring key cannot widen the permissions of a key that outlives it with `PATCH` (`403`). It can still rename keys and narrow their permissions.

Keys created in the dashboard are not limited this way.

### Recommendation

Give short-lived keys to CI and to agents, and narrow them to the inbox and permissions they need. A key that expires on its own cannot be forgotten in a config file. For an agent that keeps running, its next key has to come from whoever rotates keys: the dashboard, a key that does not expire, or the CLI after `machinemail login`. A key created with the expiring key itself expires with it.

## Permissions

Without `permissions`, a key can do everything its scope allows. With `permissions`, every request is checked against the list and refused with `403 forbidden` ("API key lacks the ... permission") if the needed permission is missing. The names are the same as AgentMail's.

| Area                   | Permissions                                                                               |
| ---------------------- | ----------------------------------------------------------------------------------------- |
| Inboxes                | `inbox_read`, `inbox_create`, `inbox_update`, `inbox_delete`                              |
| Messages               | `message_read`, `message_send`, `message_reply`, `message_update`, `message_delete`       |
| Threads                | `thread_read`, `thread_delete` (label changes on threads need `message_update`)           |
| Hidden labels          | `label_spam_read`, `label_blocked_read`, `label_unauthenticated_read`, `label_trash_read` |
| Drafts                 | `draft_read`, `draft_create`, `draft_update`, `draft_delete`, `draft_send`                |
| Webhooks               | `webhook_read`, `webhook_create`, `webhook_update`, `webhook_delete`                      |
| Domains                | `domain_read`, `domain_create`, `domain_update`, `domain_delete`                          |
| Lists and suppressions | `list_entry_read`, `list_entry_create`, `list_entry_delete`                               |
| Metrics and events     | `metrics_read`                                                                            |
| API keys               | `api_key_read`, `api_key_create`, `api_key_update`, `api_key_delete`                      |
| Pods                   | `pod_read`, `pod_create`, `pod_update`, `pod_delete`                                      |
| Raw injection          | `inbound_raw` (MachineMail only: `POST /inbound/raw`). Never implied by full access       |

Some details worth knowing:

- Replies accept either `message_reply` or `message_send`. Forwarding and new messages need `message_send`.
- Attachments, raw MIME, `batch-get` and inbox export need `message_read`.
- Webhook test and delivery replay need `webhook_update`. Domain verify needs `domain_update`.
- The event log and the SSE stream need `metrics_read`. The WebSocket needs any of `message_read`, `inbox_read`, `domain_read`, `draft_read` or `webhook_read`. Both streams send each event only when the key holds its read permission (`message.*` needs `message_read`, and so on; see [Events](https://machinemail.dev/docs/concepts/events#which-events-a-key-receives)).
- Without the matching `label_*_read` permission, a key never sees messages labelled `SPAM`, `BLOCKED`, `UNAUTHENTICATED` or `TRASH`, even if it asks for them with `include_spam` and friends.
- A key with limited permissions can only mint keys with a subset of its own permissions, and cannot mint an unrestricted key.
- `inbound_raw` is opt-in: full-access keys do not have it, and only the dashboard or a key that already has it can grant it. `{"*": true, "inbound_raw": true}` means full access plus raw injection.

## Managing keys

| Method   | Path                        | Permission       |
| -------- | --------------------------- | ---------------- |
| `GET`    | `/v0/api-keys`              | `api_key_read`   |
| `POST`   | `/v0/api-keys`              | `api_key_create` |
| `GET`    | `/v0/api-keys/{api_key_id}` | `api_key_read`   |
| `PATCH`  | `/v0/api-keys/{api_key_id}` | `api_key_update` |
| `DELETE` | `/v0/api-keys/{api_key_id}` | `api_key_delete` |

The same routes exist under `/v0/pods/{pod_id}/api-keys` and `/v0/inboxes/{inbox_id}/api-keys`, where listing shows only the keys of that pod or inbox. Revoked keys are left out of lists.

`PATCH` changes `name` and `permissions`. Scope and expiry cannot be changed; mint a new key instead.

`DELETE` revokes the key. It stops working immediately. The record is kept with `revoked_at` set.

A listed key looks like this. The secret is never returned again.

```json title="Response"
{
  "type": "bearer",
  "api_key_id": "key_01m4fy8hmg0000dgevarwczcf7",
  "organization_id": "org_01m4fy8h140000efvagszh6rpx",
  "prefix": "mm_live_5Rdo",
  "name": "support agent",
  "pod_id": "pod_01m4fy8h150000w4xty1ehfnqa",
  "inbox_id": "support-agent@machinemail.dev",
  "permissions": { "message_read": true, "thread_read": true, "message_reply": true },
  "used_at": "2026-10-09T10:12:40.118Z",
  "expires_at": "2027-01-01T00:00:00.000Z",
  "created_at": "2026-10-09T09:00:16.400Z",
  "updated_at": "2026-10-09T09:00:16.400Z"
}
```

`used_at` is updated at most once a minute, not on every request.

## Agent access tokens

Agents that register through [auth.md](https://machinemail.dev/docs/agents/auth-md) do not get an `mm_live_` key. They exchange an assertion for a short-lived JWT access token and send it the same way, as `Authorization: Bearer <token>`. Before the agent is claimed, the token reaches only its sandbox inbox. See [Claiming agents](https://machinemail.dev/docs/agents/claiming).

## Next

- [Organizations and pods](https://machinemail.dev/docs/concepts/organizations-and-pods)
- [Authentication reference](https://machinemail.dev/docs/reference/authentication)
