## The envelope

Every `/v0` error, and errors from `/health`, `/inbound/*` and `/auth/*`, has this body:

Error body

```
{
  "error": {
    "type": "validation",
    "message": "username may contain a-z, 0-9 and . _ + - (1-64 characters)",
    "param": "username"
  }
}
```

| Field | Type | Meaning |
| --- | --- | --- |
| `error.type` | string | One of the types below. The HTTP status always matches the type. |
| `error.message` | string | A human-readable sentence. Do not parse it; it can change. |
| `error.param` | string, optional | The request field or parameter at fault, when there is one. |
| `error.code` | string, optional | A more specific reason, on some errors. On a `403`: `signup_address_not_allowed` (new accounts need an email address outside MachineMail: sign-up, invitations, and creating an organization), `disposable_email_not_allowed` (MachineMail doesn’t accept disposable email addresses for new accounts, or as recipients of an unclaimed sandbox inbox’s sends) or `mailbox_has_account` (the mailbox already has an account: Gmail dots and `+` aliases count as the same mailbox). |
| `error.limit_key`, `error.limit`, `error.used`, `error.resets_at` | optional | `plan_limit_exceeded` only. See [Plan limits](/content/docs/reference/errors#plan-limits/index.html). |

There is no list of field errors: a validation error reports the first problem found.

## Types

| `type` | Status | Meaning |
| --- | --- | --- |
| `validation` | `400` | The request is malformed or a field is invalid. |
| `unauthorized` | `401` | No credential, or the credential is unknown, revoked or expired. Comes with a `WWW-Authenticate` header. |
| `plan_limit_exceeded` | `402` | The organization’s plan does not allow this until the allowance resets or the organization upgrades. Comes with the limit, the usage and, when waiting helps, `resets_at` and a `Retry-After` header. See [Plan limits](/content/docs/reference/errors#plan-limits/index.html). |
| `forbidden` | `403` | The credential lacks a permission or scope, or a policy forbids the action (paused inbox, suppressed recipient, blocked recipient, inbox cap). |
| `account_suspended` | `403` | MachineMail suspended the organization (writes refused; reads, exports and inbound still work) or its sending (no outbound; everything else works). `message` says which and how to appeal. Do not retry. |
| `not_found` | `404` | The resource does not exist, or exists outside the credential’s scope. Unknown routes too. |
| `conflict` | `409` | A uniqueness or state conflict (address taken, domain registered, pod not empty). |
| `rate_limited` | `429` | Too many requests. See [Rate limits](/content/docs/reference/rate-limits/index.html). |
| `internal` | `500` | An unexpected server error. The message is always `Internal server error`. |
| `not_implemented` | `501` | Sending is not available. Contact [support@machinemail.dev](mailto:support@machinemail.dev). |
| `unavailable` | `503` | A part of MachineMail the request needs is briefly unavailable (for example the scheduler, for a draft with `send_at`). Nothing was changed: retry with backoff. |

`not_implemented` is not in the OpenAPI `ErrorResponse` enum, but the API returns it.

A `401` always carries:

http

```
WWW-Authenticate: Bearer resource_metadata="https://api.machinemail.dev/.well-known/oauth-protected-resource"
```

An unknown route returns `404` with `Route not found: <METHOD> <path>`.

## Messages by area

These are the messages the API returns today. `param` is shown where it is set.

### Requests

| Status | Message | `param` |
| --- | --- | --- |
| `400` | `Request body must be a JSON object` |  |
| `400` | `<name> must be a number` | the parameter |
| `400` | `<name> must be an ISO 8601 timestamp` | the parameter |
| `400` | `<name> must be a list` | the parameter |
| `400` | `q is required` | `q` |
| `400` | `format must be mbox or jsonl` | `format` |
| `400` | `message_ids must be a list` |  |

### Authentication and scope

| Status | Message |
| --- | --- |
| `401` | `Missing or invalid API key` (`Not signed in` on cookie-only `/auth/*` routes) |
| `403` | `API key lacks the <permission> permission` |
| `403` | `Only organization-scoped credentials can create API keys` |
| `403` | `A key with limited permissions cannot create an unrestricted key` (`param: permissions`) |
| `403` | `Requested permissions exceed the current key` (`param: permissions`) |
| `403` | `Only organization-scoped credentials can create pods` |
| `403` | `Only organization-scoped credentials can update the organization` |
| `403` | `Only organization-scoped credentials can remove suppressions` |
| `403` | `Complaint suppressions can only be removed by MachineMail support; email support@machinemail.dev` (`param: email`) |
| `403` | `Only organization-scoped credentials can manage agent registrations` |
| `403` | `Organization scope required` |
| `403` | `Inbox-scoped credentials cannot create inboxes` |
| `403` | `Inbox-scoped credentials cannot delete their inbox` |
| `403` | `Inbox-scoped credentials cannot add domains` |
| `404` | `Pod not found: <pod_id>` |
| `404` | `Inbox not found: <inbox_id>` |

### Inboxes

| Status | Message | `param` |
| --- | --- | --- |
| `400` | `username may contain a-z, 0-9 and . _ + - (1-64 characters)` | `username` |
| `400` | `ttl_seconds must be a positive number` | `ttl_seconds` |
| `400` | `status must be active or paused` | `status` |
| `403` | `Inbox limit reached for <domain> (<cap> per organization). Add your own domain for more.` | `domain` |
| `404` | `Domain not found: <domain>` | `domain` |
| `409` | `Inbox with email <email> already exists` | `username` |

### Sending

| Status | Message |
| --- | --- |
| `400` | `At least one recipient is required` |
| `400` | `Invalid recipient: <address>` |
| `400` | `Too many recipients: <n> across to, cc and bcc. A message can have at most <max> recipients; send separate messages instead.` (`param: to`; `<max>` is 50) |
| `400` | `Too many recipients: <n> across to, cc and bcc. Sandbox inboxes can have at most <max> recipients per message. Have your operator claim this inbox to lift sandbox limits (see /auth.md).` (`param: to`; unclaimed sandbox inboxes, `<max>` is 5) |
| `400` | `attachments[<i>] needs content (base64) or url` |
| `400` | `attachments[<i>].url is larger than 10 MB`, or another reason the URL could not be fetched |
| `403` | `Inbox <inbox_id> is paused` |
| `403` | `Recipient <address> is suppressed (<reason>)` |
| `403` | `Sending to <address> is blocked by a send list` |
| `403` | `Sandbox inboxes can't send to disposable email addresses. Claim this agent to remove sandbox limits.` (`param``to`, `cc` or `bcc`: the field holding it; `code: disposable_email_not_allowed`; unclaimed sandbox inboxes only) |
| `429` | `Sandbox inboxes can send <n> messages per 24 hours. Have your operator claim this inbox to lift sandbox limits (see /auth.md).` (with `Retry-After`; `<n>` is 10) |
| `429` | `Sandbox inboxes can email <n> different recipients per 24 hours; this inbox has used <used>, and this message adds <new> new. Have your operator claim this inbox to lift sandbox limits (see /auth.md).` (with `Retry-After`; `<n>` is 10) |
| `501` | `Sending is not available right now. Contact support@machinemail.dev.` |

### URLs (webhooks and attachment URLs)

`400` when a URL is not `http(s)`, contains credentials, points at a private or loopback address, or does not resolve. Examples: `url must be an http(s) URL`, `url must point at a public host`.

### Other resources

| Status | Message | `param` |
| --- | --- | --- |
| `400` | `send_at must be an ISO timestamp` | `send_at` |
| `400` | `Unknown event type: <type>` | `event_types` |
| `400` | `domain must be a valid hostname` | `domain` |
| `400` | `catch_all_ttl_seconds must be positive` | `catch_all_ttl_seconds` |
| `400` | `name cannot be empty` | `name` |
| `400` | `slug cannot be changed: it is fixed when the organization is created` | `slug` |
| `400` | `direction must be send, receive or reply` | `direction` |
| `400` | `type must be allow or block` | `type` |
| `400` | `entry is required` | `entry` |
| `400` | `email is required` | `email` |
| `400` | `period must be between 1 and 86400 seconds` | `period` |
| `400` | `start must be before end` |  |
| `400` | `window must be a multiple of period` | `window` |
| `400` | `Unknown permission: <name>` | `permissions` |
| `400` | `expires_at must be an ISO timestamp` | `expires_at` |
| `404` | `<Resource> not found` or `<Resource> not found: <id>` for threads, messages, drafts, webhooks, deliveries, domains, events, API keys, list entries, suppressions, trust reports, attachments and raw messages |  |
| `409` | `<domain> is a shared domain` | `domain` |
| `409` | `Domain <domain> is already registered` | `domain` |
| `409` | `The default pod cannot be deleted` |  |
| `409` | `Pod still has inboxes; delete them first` |  |
| `409` | `<collection> conflicts with an existing record` |  |

### Raw injection (`POST /inbound/raw`)

| Status | Message |
| --- | --- |
| `400` | `Message too large` (over 40 MB) |
| `400` | `No recipient: set X-Recipient or a To header` |
| `404` | `No inbox found for recipients: <addresses>` (`param: recipient`) |
| `403` | `Inbox <inbox_id> is paused` |
| `403` | `API key lacks the inbound_raw permission` (full-access keys too) |

### Plan limits

Free plan limits ( [how they are enforced](/content/docs/reference/limits#how-plan-limits-are-enforced/index.html)) refuse a request with `402 plan_limit_exceeded`. Paid plans are never refused, except past their [trusted domains](/content/docs/concepts/trusted-domains#limits/index.html) limit. The body has four extra fields:

| Field | Type | Meaning |
| --- | --- | --- |
| `error.limit_key` | string | `messages_per_month`, `messages_per_day`, `storage_bytes`, `custom_domains`, `seats`, `held_messages`, `held_bytes`, `spam_storage_bytes`, `free_organizations` or `trusted_domains`. |
| `error.limit` | integer | The value of that limit for your organization (bytes for `storage_bytes`, `held_bytes` and `spam_storage_bytes`). |
| `error.used` | integer | How much of it was used before this request. |
| `error.resets_at` | string, optional | When the limit resets (ISO 8601, UTC). Absent when waiting does not help: custom domains, seats, trusted domains, or a send larger than the limit. |

402 on a send

```
{
  "error": {
    "type": "plan_limit_exceeded",
    "message": "The Free plan allows 100 messages a day (inbound messages and outbound recipients together); this organization has used 98 and this send needs 3. The allowance resets 2026-10-15T00:00:00Z. Upgrade at https://machinemail.dev/app/billing.",
    "param": "to",
    "limit_key": "messages_per_day",
    "limit": 100,
    "used": 98,
    "resets_at": "2026-10-15T00:00:00.000Z"
  }
}
```

| Status | Message | `param` |
| --- | --- | --- |
| `402` | `The Free plan allows 3,000 messages a month (inbound messages and outbound recipients together); this organization has used <used> and this send needs <n>. The allowance resets <resets_at>. Upgrade at https://machinemail.dev/app/billing.` (with `Retry-After`) | `to` |
| `402` | The same with `100 messages a day`, resetting at the next 00:00 UTC (with `Retry-After`) | `to` |
| `402` | `The Free plan allows <limit> messages a month (inbound messages and outbound recipients together) and this send has <n> recipients, so it can never be sent on this plan. Send it to fewer recipients, or upgrade at https://machinemail.dev/app/billing.` | `to` |
| `402` | `The Free plan allows 1 custom domain and this organization has <n>. Upgrade at https://machinemail.dev/app/billing.` | `domain` |
| `402` | `The Free plan allows 1 member and this organization has <n> (including pending invitations). Upgrade at https://machinemail.dev/app/billing.` | `email` |
| `402` | `This organization reached its Free plan limit and already holds <n> undelivered messages, the most it may hold; this message was not stored. Upgrade at https://machinemail.dev/app/billing.` (`POST /inbound/raw` only) |  |
| `402` | `This organization reached its Free plan limit and already holds <n> MB of undelivered mail; with this message it would pass the <max> MB it may hold, so this message was not stored. Upgrade at https://machinemail.dev/app/billing.` (`held_bytes`; `POST /inbound/raw` only) |  |
| `402` | `The Free plan includes 1 GB of storage and this organization uses <used> GB (messages, drafts and held mail). Delete mail or drafts to make room, or upgrade at https://machinemail.dev/app/billing.` (creating or growing a draft; removing `SPAM` or `QUARANTINE`) | `attachments`, `add_attachments` or `remove_labels` |
| `402` | The messages-a-month or messages-a-day error (`this message needs 1`), when removing `SPAM` or `QUARANTINE` from a message would take the organization past it |  |
| `402` | `The Free plan allows 3,000 messages a month (inbound messages and outbound recipients together); this organization has used <used> and the agent's inbox holds <n> received messages, so it cannot be claimed into this organization this month. Upgrade at https://machinemail.dev/app/billing.` (`POST /auth/claim`) |  |
| `402` | `Each account can own 1 organization on the Free plan and you own <n>. Upgrade at https://machinemail.dev/app/billing.` (`POST /auth/organizations`) |  |

Sends, replies, reply-all, forwards and draft sends return the send errors; MCP send tools return the same envelope in `structuredContent`. A scheduled draft that reaches a limit stays scheduled and is sent after the reset. Inbound mail past a Free limit is never refused to the sender: it is [held](/content/docs/reference/limits#held-mail/index.html).

## Agent onboarding errors

`/agent/*` and `/oauth2/*` follow OAuth conventions instead of the envelope. Their bodies look like this and carry `Cache-Control: no-store`:

OAuth error body

```
{
  "error": "authorization_pending",
  "error_description": "The claim has not been completed yet",
  "message": "The claim has not been completed yet"
}
```

| Endpoint | `error` values (status `400` unless shown) |
| --- | --- |
| `POST /agent/identity` | `invalid_request`, `anonymous_not_enabled`, `service_auth_not_enabled`, `identity_assertion_not_enabled`, `invalid_issuer`, `invalid_signature`, `invalid_audience`, `expired`, `invalid_client_id`, `missing_verified_email`, `replay_detected`, `rate_limited` (`429`, with `Retry-After`), `login_required` (`401`, with `max_age`), `interaction_required` (`401`, with `claim_token` and `claim`), `disposable_email_not_allowed` (`403`: an `identity_assertion` whose verified email is a disposable address and has no account) |
| `POST /agent/identity/claim` | `invalid_request`, `invalid_claim_token` (`404`, or `401` with `WWW-Authenticate: Bearer error="invalid_token"`), `claimed_or_in_flight` (`409`), `claim_expired` (`410`) |
| `POST /oauth2/token` | `invalid_request`, `unsupported_grant_type`, `invalid_grant`, `invalid_client` (`401`), `invalid_target`, `authorization_pending`, `slow_down`, `expired_token`, `access_denied` |
| `POST /agent/event/notify` | `{"err": "...", "description": "..."}` with `invalid_request`, `invalid_issuer`, `invalid_key` or `invalid_audience` |

Human claim lookups in the dashboard (`GET` and `POST /auth/claim`) use the standard envelope: `400 user_code must be 6 digits`, `404 Claim code (unknown or expired) not found`, `403 This agent can only be claimed by <masked email>`, `429 Too many claim attempts...`.

## MCP errors

[MCP tools](/content/docs/reference/mcp-tools/index.html) report API errors as a tool result with `isError: true`. The text content is `<type>: <message>` and `structuredContent` is the error envelope above. Transport errors (wrong method, server failure) are JSON-RPC errors with code `-32000`.

## Handling errors

- Retry `429` after the number of seconds in `Retry-After`, when present.
- Do not retry `402` before `resets_at` (also in `Retry-After`). Without `resets_at`, waiting does not help: change the request or upgrade.
- Retry `500` with backoff. Do not blindly retry sends: a send that timed out may have gone out. Use drafts with a `client_id` when you need a retry-safe send.
- Do not retry `400`, `403`, `404` or `409` without changing the request.
