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.
-
invalid_requestthe request body or a parameter failed validation -
invalid_cursorthe pagination cursor is malformed or belongs to a different query
401 — Unauthenticated
No usable credential was presented. The server stopped before looking at what was asked for, so nothing here depends on the route.
-
unauthenticatedno bearer credential, or one that does not resolve
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.
-
payment_requiredthe action needs an entitlement the workspace does not hold -
quota_exceededa metered allowance for the period is exhausted
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.
-
permission_deniedthe credential is real but its authority does not cover this -
policy_deniedworkspace or inbox policy refused the send -
test_mode_requireda simulation endpoint was called against live resources -
test_limit_exceededa test-mode cap on inboxes, messages or bytes would be exceeded
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_foundthe 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.
-
idempotency_conflictthe Idempotency-Key was reused with a different payload -
idempotency_expiredthe key matches, but its replay window has closed -
address_unavailablethe address or domain is already claimed
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.
-
cursor_expiredthe event cursor points before the oldest retained event
412 — Precondition failed
The version or content hash you sent no longer matches. Re-read, re-apply, and send the version you just read.
-
version_conflictthe resource changed since you read it
413 — Payload too large
A size limit was exceeded. Body size is counted as it streams rather than read from Content-Length.
-
payload_too_largea body, an upload or an attachment exceeds its size limit
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.
-
recipient_suppresseda recipient is on the suppression list -
domain_not_readythe domain has not finished verifying -
attachment_not_readythe file has not passed a scan yet, or failed one -
test_mode_unsupportedthe feature does not exist in test mode -
reserved_addressthe mailbox name is reserved on the platform domain
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_limitedtoo 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.
-
provider_unavailablean upstream mail provider could not be reached or answered usably -
temporarily_unavailablethis service, or something it depends on, is not available right now
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.
-
last_owner_requireda workspace cannot be left without an active owner -
environment_immutablea project's environment cannot be changed after creation
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.