---
title: "Errors"
description: "The MachineMail API error envelope, every error type and the messages each area returns, plus agent onboarding and MCP errors and how to handle them."
canonical: "https://machinemail.dev/docs/reference/errors"
---

# Errors

> The MachineMail API error envelope, every error type and the messages each area returns, plus agent onboarding and MCP errors and how to handle them.

Source: https://machinemail.dev/docs/reference/errors

## The envelope

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

```json title="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](#plan-limits).                                                                                                                                                                                                                                                                                                                                                                                                                        |

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](#plan-limits). |
| `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](https://machinemail.dev/docs/reference/rate-limits).                                                                                                                                              |
| `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.`                                                                                                                                                                             |

The OpenAPI description of inbox `status` says sends to a paused inbox return `403` with code `inbox_paused`. The API returns `403` with type `forbidden` and the message above; there is no `inbox_paused` code in the body.

### 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](https://machinemail.dev/docs/reference/limits#how-plan-limits-are-enforced)) refuse a request with `402 plan_limit_exceeded`. Paid plans are never refused, except past their [trusted domains](https://machinemail.dev/docs/concepts/trusted-domains#limits) 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.                                    |

```json title="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](https://machinemail.dev/docs/reference/limits#held-mail).

## Agent onboarding errors

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

```json title="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](https://machinemail.dev/docs/reference/mcp-tools) 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.
