## Base URL

MachineMail is an email API for AI agents. The `/v0` HTTP API is wire-compatible with AgentMail’s `/v0` API: the same paths, snake_case field names, enums and pagination. MachineMail adds trust reports, ephemeral and catch-all inboxes, delivery replay, an event log and live event streams. See [AgentMail compatibility](/content/docs/reference/agentmail-compatibility/index.html).

The [endpoint reference](/content/docs/reference/api/index.html) is generated from the OpenAPI document, served at `GET /v0/openapi.json`.

```
https://api.machinemail.dev
```

| Surface | URL |
| --- | --- |
| REST API | `https://api.machinemail.dev/v0/...` |
| Remote MCP server | `https://mcp.machinemail.dev/mcp` ( [MCP tools](/content/docs/reference/mcp-tools/index.html)) |
| Server-sent events | `https://api.machinemail.dev/v0/events/stream` |
| WebSocket | `wss://api.machinemail.dev/v0/ws` |
| Agent onboarding (auth.md) | `https://api.machinemail.dev/auth.md` |

Agent inboxes on the shared domain have addresses like `agent@machinemail.dev`.

## Versioning

Every resource endpoint sits under `/v0`. There is no version header. Health checks (`/health`), the MCP endpoint (`/mcp`), the agent onboarding endpoints (`/agent/*`, `/oauth2/*`, `/.well-known/*`) and the dashboard session endpoints (`/auth/*`) are unversioned.

## Authentication

Send an API key as a bearer token:

```
curl https://api.machinemail.dev/v0/inboxes \
  -H "Authorization: Bearer $MACHINEMAIL_API_KEY"
```

Keys look like `mm_live_` followed by 32 base62 characters. A key belongs to an organization and is scoped to the whole organization, one pod, or one inbox. It can carry a permission allowlist; without one it can do everything its scope allows. Agent access tokens issued through the auth.md flow are accepted in the same header. See [Authentication](/content/docs/reference/authentication/index.html).

A missing or invalid credential returns `401` with a `WWW-Authenticate: Bearer resource_metadata="https://api.machinemail.dev/.well-known/oauth-protected-resource"` header. A resource outside the key’s scope returns `404`, not `403`.

## Requests

- Request bodies are JSON objects. The server parses the body as JSON whatever the `Content-Type`; send `Content-Type: application/json` anyway. An empty body counts as `{}`. Anything that is not a JSON object returns `400` with `Request body must be a JSON object`.
- Field names are snake_case. Body fields are also accepted in camelCase (`display_name` or `displayName`).
- Timestamps are ISO 8601 strings in UTC, for example `2026-10-01T12:00:00.000Z`.
- Boolean query parameters are true when the value is empty, `true` or `1`, and false otherwise.
- List query parameters (such as `labels` or `event_types`) can be repeated (`labels=A&labels=B`), comma-separated (`labels=A,B`) or a JSON array (`labels=["A","B"]`). The `name[]` form also works.
- `inbox_id` is the inbox’s email address. Other IDs are prefixed: `org_`, `pod_`, `thd_`, `msg_`, `drf_`, `whk_`, `dom_`, `key_`, `dlv_`, `evt_`, `att_`. IDs sort by creation time.

## Responses

- Successful responses are JSON with snake_case fields, except raw message, attachment, zone file, export and stream endpoints.
- Creating an inbox, draft, webhook, domain, pod, API key or suppression returns `201`. When a `client_id` matches an existing resource the existing one comes back with `200` (see [Idempotency](/content/docs/reference#idempotency/index.html)). Adding a list entry returns `200`.
- Sending (send, reply, reply-all, forward, draft send) returns `201` with `message_id`, `thread_id` and `timestamp`.
- Deleting returns `200` with `{\"ok\": true}`. Revoking an agent registration returns the registration.
- Optional fields with no value are left out of the JSON.
- Errors use one envelope: `{\"error\": {\"type\": "...", \"message\": "...", \"param\": "..."}}`. See [Errors](/content/docs/reference/errors/index.html).

## Pagination

List endpoints take `limit` (default 50, max 500), `page_token` and `ascending`, and return `count`, `limit` and `next_page_token`. See [Pagination](/content/docs/reference/pagination/index.html).

## Idempotency

There is no `Idempotency-Key` header. Instead, creates accept a `client_id` field:

| Resource | Endpoint | `client_id` behaviour |
| --- | --- | --- |
| Inbox | `POST /v0/inboxes` | dedupes |
| Draft | `POST /v0/inboxes/{inbox_id}/drafts` | dedupes |
| Webhook | `POST /v0/webhooks` | dedupes |
| Domain | `POST /v0/domains` | dedupes |
| Pod | `POST /v0/pods` | dedupes |
| API key | `POST /v0/api-keys` | stored, but not deduped: every call creates a key |

When a resource in the organization already has the `client_id`, the API returns that resource with `200` instead of creating a new one (`201`). The request body is not compared, so a retry with different fields still returns the original resource. `client_id` values are unique per organization, not per pod or inbox. A request limited to a pod or an inbox only gets back a resource it could read: when the `client_id` belongs to a resource outside that pod or inbox, the API returns `409 conflict` without that resource. A request is limited by a pod- or inbox-scoped API key, and also by a route under `/v0/pods/{pod_id}` or `/v0/inboxes/{inbox_id}` even with an organization key.

Sending mail is not idempotent: retrying `messages/send` after a timeout can send twice. To make a send safe to retry, create a draft with a `client_id`, then send the draft.

## Request IDs

The API does not set or read a request ID header. When you report a problem, include the time, the method and path, and any resource IDs.

## CORS

The API does not allow cross-origin browser requests: call it from your server, never from a web page that holds your API key.

## Scoped routes

Every `/v0` resource route is also served under `/v0/pods/{pod_id}/...`, which narrows the request to that pod. Webhook, list, metric, event and API key routes are also served under `/v0/inboxes/{inbox_id}/...`, narrowed to that inbox. The OpenAPI document describes the unprefixed form (and the prefixed form for lists). Creating an API key under a prefix scopes the new key to that pod or inbox.
