Reference

Error codes, and what actually caused them

26 codes. Each page states the condition the implementation checks, not a restatement of the code name.

The error envelope

Every failure from the REST API and from the MCP endpoint returns the same JSON shape. The HTTP status is derived from the code, never set independently, so a given code always carries the same status.

{
  "error": {
    "code": "permission_denied",
    "message": "This credential cannot perform that action.",
    "request_id": "req_...",
    "retryable": false,
    "details": {}
  }
}

Branch on code, not on message. The code is a stable contract value and the message is written for a human reading a log. request_id mirrors the x-request-id response header and is the handle to quote when asking about one specific attempt.

retryable is true for exactly three codes, and the rest are false by construction rather than by judgement: rate_limited , provider_unavailable and temporarily_unavailable . A retry on anything else produces the same answer, because nothing about the condition changes between attempts.

details is populated on the two codes where a machine can use it. A schema failure returns details.fields naming the paths that failed, and a policy refusal returns details.reason_codes naming the rules that fired. Everywhere else it is an empty object.

400 — Bad request

The credential was fine and the request was not. Nothing was written, and retrying the same body produces the same answer.

401 — Unauthenticated

No usable credential was presented. The server stopped before looking at what was asked for, so nothing here depends on the route.

402 — Payment required

The credential is permitted and the workspace lacks the entitlement or the allowance. These are the only two codes where the fix is an account change rather than a code change.

403 — Forbidden

The principal is known and its current authority, or the inbox's policy, does not cover this action. Authority is recomputed per request, so a 403 can appear on a call that worked an hour ago.

404 — Not found

Nothing matching those identifiers is visible to this credential. A resource outside the credential's scope is deliberately indistinguishable from one that does not exist.

  • not_found the resource does not exist, or is outside this credential's scope

409 — Conflict

The request collided with state that already exists. Retrying unchanged cannot resolve any of these.

410 — Gone

The thing you were resuming from existed and has been trimmed. Start again from the current position rather than the one you were holding.

412 — Precondition failed

The version or content hash you sent no longer matches. Re-read, re-apply, and send the version you just read.

413 — Payload too large

A size limit was exceeded. Body size is counted as it streams rather than read from Content-Length.

422 — Unprocessable

The request is well-formed but the resource it names is in a state that cannot satisfy it. Usually something is still provisioning, still scanning, or reserved.

429 — Rate limited

A request-rate limit or a per-operation cooldown refused the call. Retry-After is set to 60 on every one of these.

  • rate_limited too many requests, or too many of one specific operation

503 — Service unavailable

This service or a dependency could not complete the call. These three are the only codes that default to retryable: true.

Enforced in the database

These two strings are raised by Postgres triggers, not by a route. The contract assigns them a status, and no code path converts the exception into the JSON error envelope, so you meet them in logs and in direct database errors rather than in an API response.

Where these come from

Codes are declared in one place and the HTTP status is a lookup off the code, so a code cannot carry two statuses depending on which route raised it.

The REST base is https://api.emailforagents.ai/v1 and the remote MCP endpoint is https://mcp.emailforagents.ai/mcp. Both return the same envelope. The MCP endpoint adds one behaviour the REST API has no equivalent for: a tool the connection's grant does not cover is not listed by tools/list at all, so a read-and-draft grant produces a session in which send_message does not exist. A call naming it anyway gets permission_denied, because the same filter re-runs on tools/call.

Three codes are declared in the contract and never thrown by any code path, so they have no page here. daily_limit_exceeded is the notable one: the daily recipient cap is real and enforced, and the code it produces is policy_denied, which is where that behaviour is documented. source_unavailable and send_already_submitting name paths that are not built.

If you are here before something breaks rather than after, the quickstart is the shorter route into the API, the sending controls page covers the policy layer behind the 403s, and events and webhooks covers the cursor semantics behind the 400 and the 410.