# Error reference

Every error response carries the same JSON envelope. **Branch on `code`, never on the
`error` prose or the HTTP status alone** — `code` is the stable machine key; `error`
is a human sentence that may change; `details` is optional structured context.

```json
{
  "error": "This mailbox restricts agent sends to approved recipients.",
  "code": "RECIPIENT_AGENT_CONTAINED",
  "details": {
    "reason_axis": "recipient_containment",
    "remedy": "add_recipient_to_allowlist",
    "cheapest_next_step": "no_cost_remedy"
  }
}
```

- **HTTP status groups the class of failure** (4xx caller, 5xx transient). Use it for a
  coarse retry decision; use `code` for the precise cause and remedy.
- **`details` is present on some codes only.** Two families put machine-routable data
  there: the [denial envelope](#denial-envelope-capability-403--429) (capability denials)
  and the [`RATE_LIMITED` variants](#rate_limited-disambiguation). Everything else is a
  bare `{ error, code }`.

This page is the single source for the error-code catalog. For the *order* gates are
checked on a send, see [/agents/send-gates](/agents/send-gates); for the four governed
send outcomes see [/agents/send-outcomes](/agents/send-outcomes); for message states and
scan-verdict vocabulary see [/agents/messages](/agents/messages); for the attachment
staging lifecycle see [/agents/attachments](/agents/attachments); for CLI exit codes see
[/agents/cli](/agents/cli).

## Denial envelope (capability 403 / 429)

Capability denials — "your plan/credential/mailbox setting/capacity won't allow this" —
emit a **denial envelope** in `details` so you can route on the response instead of
parsing prose. Detect one by the presence of `details.reason_axis`:

```json
{
  "error": "This feature requires the pro plan or above.",
  "code": "TIER_LIMIT",
  "details": {
    "reason_axis": "tier",
    "remedy": "upgrade_tier",
    "required_tier": "pro",
    "cheapest_next_step": "pro",
    "upgrade_url": "https://app.replylayer.ai/settings/billing"
  }
}
```

`reason_axis` is a closed enum — exactly *why* the request was denied. `remedy` is the
matching action. Switch on `reason_axis` first, then read the typed sibling field the
remedy points at.

| `reason_axis` | Meaning | `remedy` | Who fixes | `upgrade_url`? |
|---|---|---|---|---|
| `tier` | Your plan does not include the feature | `upgrade_tier` (target → `required_tier`) | Human / admin (billing) | Yes |
| `key_role` | Wrong credential — a Bearer/agent key on a human-session-only surface | `use_session` | Human (dashboard session) | No |
| `mailbox_config` | The mailbox setting is off | `enable_mailbox_mode` (target → `required_mailbox_mode`) | Admin (dashboard) | No |
| `trust_capacity` | A rate/volume capacity ceiling was reached | `add_paygo_credits_or_await_trust_ramp` | Human/admin, or wait | Yes |
| `account_state` | Account lifecycle (e.g. sandbox trial ended) | `add_paygo_credits_or_upgrade` | Human / admin | Yes |
| `account_policy` | The capability is disabled for API keys on this account | `enable_account_policy` | Admin (dashboard session) | No |
| `recipient_containment` | An agent-origin send was contained to approved recipients | `add_recipient_to_allowlist` | Admin (add to allowlist / opt out); agent can continue an existing thread | No |
| `accountability_floor` | Relaxing injection defense needs an accountable billing party | `set_up_billing` | Human / admin (billing) | No |

**Field notes:**

- **`upgrade_url`** attaches only on the monetary axes (`tier`, `trust_capacity`,
  `account_state`) and only when a public dashboard link base is configured. It is
  **omitted, never `null`** — a non-monetary denial (a session swap, a mailbox toggle)
  deliberately carries no billing link so it can't misdirect you toward "upgrade".
- **`cheapest_next_step`** is the smallest money that unblocks you: `paygo` | `starter` |
  `pro`, or `no_cost_remedy` when the fix is a session/setting/time, not spend.
- **Typed context** may include `feature`, `required_tier`, `current_tier`,
  `required_mailbox_mode`, `effective_daily_limit`, `current_count`, `max_allowed`. Every
  field is an enum, number, or server-derived URL — never free-form operator text.
- **An agent cannot self-serve `key_role`, `account_policy`, or `accountability_floor`
  denials.** These require a human dashboard session (with fresh re-auth for the
  loosening ones). In particular, *loosening instruction trust is a dashboard-session +
  re-auth action only* — an API key can never enable it for itself.

## RATE_LIMITED disambiguation

`RATE_LIMITED` (HTTP `429`) is overloaded: it covers your daily send quota, a
new-account warm-up burst limit, and generic short-window throttles. **Branch on the
shape of `details`, not on the code:**

| Variant | Signal | Meaning | Backoff |
|---|---|---|---|
| Daily send budget | `details.daily_limit` **present** (with `sends_remaining: 0`, `reset_at` ISO-8601) | You spent today's send allowance | Retry after `reset_at` (midnight UTC), or add pay-as-you-go credits / upgrade |
| New-account warm-up | `details.reason === "new_account_warmup"` (with `retry_after_seconds`) | A send-rate warm-up burst limit; verifying your own sending domain lifts it | Retry after `retry_after_seconds` |
| Generic short-window | `details.retry_after` (seconds) **or no `details`** | A short-window request throttle | Wait the window (or a few seconds) and retry |

The discriminator is the **presence of `details.daily_limit`** → daily quota; anything
else is a short-window/velocity limit. Only the daily-budget variant is answered by "wait
for the reset or buy capacity"; the others are answered by "wait a moment and retry". Tier
and quota values live at [/docs/limits](/docs/limits).

## Content blocks are not always a 4xx

A scanner block on a **synchronous** send/reply is folded into the message's terminal
state and returns success-shaped unless you opt into strict outcomes with
`Prefer: outcome=strict` (then it surfaces as `EMAIL_EFFECT_REJECTED`, below). The
**draft** send path always fails loud (`DRAFT_REJECTED_BY_RESCAN`, `409`). See
[/agents/send-outcomes](/agents/send-outcomes) for the governed-outcome contract.

Scan findings themselves are not error codes — they live on the message's
`scan_result_json`. Two per-finding fields are agent-relevant:

- **`failure_class`** — `"inference_error"` marks an infrastructure failure (a scanner
  timeout/outage), not a content judgment. Such findings also carry **`retryable: true`**,
  and the customer-facing reason collapses to a static infrastructure string. A held or
  blocked send with `failure_class: "inference_error"` is safe to retry as-is; a genuine
  content block (`"model_judgment"`) is not — edit the content or escalate.
- **`agent_instructions[]`** — optional, machine-readable handling guidance computed
  structurally from typed fields (vendor-free, index-agnostic). Present on non-`allow`
  findings that have concrete guidance; absent on `inference_error`.

Verdict vocabulary (`quarantined` vs `blocked`, releasable vs terminal) is documented at
[/agents/messages](/agents/messages).

## Code catalog

HTTP status, cause, denial `reason_axis` (where applicable), who can fix it, and whether a
straight retry can succeed. "Who fixes" is `agent` (your key can recover), `admin`
(an account admin, dashboard), or `human` (a dashboard session, sometimes with re-auth).

### Authentication

| Code | HTTP | When it fires | Fix | Retry |
|---|---|---|---|---|
| `UNAUTHORIZED` | 401 | Missing, malformed, revoked, or unknown API key | agent (supply a valid key) | No |
| `API_KEY_FORMAT_LEGACY` | 401 | Key is in a retired format and no longer accepted | agent (mint a current-format key) | No |
| `EMAIL_NOT_VERIFIED` | 403 | The account's email is unverified | human (verify email) | No |
| `PHONE_NOT_VERIFIED` | 403 | The required signup phone is unverified | human (verify SMS code) | No |
| `PHONE_REQUIRED` | 400 | An OAuth new-account branch omitted the mandatory phone number | human (restart signup with a number) | No |
| `PHONE_INVALID` | 400 | Signup/resend phone number is not in a valid international format | human (correct it) | No |
| `PHONE_COUNTRY_NOT_SUPPORTED` | 400 | The number’s country is outside the SMS verification allowlist | human (use a number from a supported country) | No |
| `NO_PHONE_VERIFICATION_PENDING` | 400 | No current SMS challenge exists | human (request a new code) | No |
| `INVALID_PHONE_VERIFICATION_CODE` | 400 | The SMS code is wrong | human (re-enter code) | No |
| `PHONE_VERIFICATION_EXPIRED` | 410 | The 5-minute SMS challenge expired | human (request a new code) | No |
| `PHONE_VERIFICATION_ATTEMPTS_EXCEEDED` | 429 | Too many wrong codes invalidated the challenge | human (request a new code) | No |
| `PHONE_VERIFICATION_RATE_LIMITED` | 429 | SMS send/check limits were reached | human/agent (wait for `Retry-After`) | Yes (after window) |
| `PHONE_VERIFICATION_UNAVAILABLE` | 503 | SMS verification or its fail-closed limiter is unavailable | human/agent (retry later) | Yes |
| `PHONE_ALREADY_VERIFIED` | 409 | Resend/correction was attempted after phone verification completed | human/agent (stop resending) | No |
| `PHONE_VERIFICATION_SUPERSEDED` | 409 | A newer concurrent SMS request replaced this send | human/agent (retry deliberately) | Yes |
| `ACCOUNT_SUSPENDED` | 403 | The account is suspended | human/admin (reinstate) | No |
| `AUTH_TEMPORARILY_BLOCKED` | 429 | Too many failed auth attempts — a brute-force lock | agent (wait, then retry) | Yes (after window) |
| `REAUTH_REQUIRES_SESSION` | 403 | A route needs a dashboard session with fresh TOTP/SMS/password re-auth; a Bearer/agent key is rejected | human (dashboard re-auth) | No |
| `BILLING_REQUIRES_SESSION` | 403 | A billing route was called with a Bearer key; billing is session-only | human (dashboard) | No |

### SMS-based MFA & step-up re-auth

SMS is an alternative second factor to an authenticator app, and an SMS code is also the
fresh re-auth on the session-only loosening gates. The **ceremonies** these codes belong to
(enroll, method switch, SMS-disable proof, lost-factor recovery, phone change, and the
scoped step-up send) are **dashboard-session-only** — an API key gets `REAUTH_REQUIRES_SESSION`
(above) before any of them fires, so an agent integration never hits them directly. The two
transient SMS send failures (`MFA_SMS_RATE_LIMITED`, `MFA_SMS_UNAVAILABLE`) can *also* surface
on the public SMS login step. Catalogued here for completeness.

| Code | HTTP | When it fires | Fix | Retry |
|---|---|---|---|---|
| `MFA_SMS_NOT_AVAILABLE` | 400 | An SMS ceremony or step-up was requested but SMS-based MFA isn't enabled (or the account has no verified phone) | human (enable SMS MFA / verify a phone) | No |
| `REAUTH_SMS_CHALLENGE_REQUIRED` | 400 | An SMS step-up or ceremony needs a challenge minted first (`POST /v1/auth/reauth/sms/send`, or `/mfa/disable/send-sms` for a current-factor proof) | human (mint the challenge, then resubmit) | No |
| `REAUTH_INVALID_SMS_CODE` | 400 | The SMS re-auth or ceremony code is wrong or expired | human (re-enter, or resend) | No |
| `MFA_SMS_RATE_LIMITED` | 429 | Too many SMS codes requested (carries `Retry-After` / `details.retry_after`) | human/agent (wait for the window) | Yes (after window) |
| `MFA_SMS_UNAVAILABLE` | 503 | The SMS provider (or its fail-closed limiter) is momentarily unavailable | human/agent (retry later) | Yes |
| `MFA_SMS_SEND_CONFLICT` | 429 | A verification for this same step-up action is already in flight | human (wait a moment, then retry) | Yes (short) |
| `MFA_METHOD_SWITCH_REQUIRED` | 400 | Tried to enroll a fresh method while one is already enabled | human (use the method-switch ceremony) | No |
| `MFA_RECOVERY_ACK_REQUIRED` | 400 | Confirmed enrollment/switch before acknowledging the saved recovery codes | human (acknowledge, then confirm) | No |
| `MFA_NO_PENDING_CEREMONY` | 400 | Acknowledged or confirmed with no staged ceremony (or it expired / was superseded) | human (restart the ceremony) | No |
| `MFA_RECOVERY_WINDOW_EXPIRED` | 400 | No usable one-shot recovery grant on this session (expired, consumed, or reserved) — the lost-factor disable / rebind flows | human (log in again with a recovery code) | No |
| `REAUTH_SESSION_TOO_OLD` | 403 | An SSO-only account tried to enroll SMS MFA without a fresh session | human (sign in again, then retry) | No |
| `PRIVACY_ACK_REQUIRED` | 400 | The account's accepted Privacy Policy/DPA predates the SMS-verification scope; carries `details.current_privacy_version` / `current_dpa_version` | human (re-accept the current version, resubmit with `accept_privacy_version`) | No |
| `PASSWORD_CHANGE_UNAVAILABLE` | 400 | The self-service password-change endpoint was called on an account with no password (sign-in is managed through Google/GitHub) | human (sign in via the OAuth provider instead) | No |
| `EMAIL_CHANGE_COOLDOWN` | 429 | The account's email was changed within the last 24 hours; carries `details.retry_after` (seconds until the window lapses) | human (wait for the cooldown to lapse) | Yes (after window) |
| `EMAIL_CHANGE_TAKEN` | 409 | The new address proved by the confirm code is already in use on another account | human (choose a different address and restart) | No |
| `EMAIL_CHANGE_INVALID_CODE` | 400 | The email-change confirm code is wrong, expired, exhausted, submitted from a session other than the one that started the ceremony, or the account's MFA state changed since start — one generic code, no factor oracle | human (restart the change from Settings) | No |

### Authorization & scope

| Code | HTTP | When it fires | Axis | Fix | Retry |
|---|---|---|---|---|---|
| `INSUFFICIENT_SCOPE` | 403 | An agent (mailbox-scoped) key called an admin-only surface | — | admin (use an admin key) | No |
| `FORBIDDEN` | 403 | The credential is valid but not permitted for this resource | — | agent/admin | No |
| `MESSAGE_DELETE_NOT_PERMITTED` | 403 | Agent message-delete is disabled for API keys on the account | `account_policy` | admin (enable the policy) | No |

### Send gates — recipient policy

Fired at send/reply/draft time. The evaluation order and preflight pattern are documented
at [/agents/send-gates](/agents/send-gates).

| Code | HTTP | When it fires | Axis | Fix | Retry |
|---|---|---|---|---|---|
| `RECIPIENT_SUPPRESSED` | 403 | Recipient is on the account do-not-contact list | — | admin (remove suppression) or change recipient | No |
| `RECIPIENT_NOT_ON_ALLOWLIST` | 403 | Mailbox is in allowlist mode and the recipient is not approved | — | admin (approve the address); agent can continue an existing thread | No |
| `RECIPIENT_AGENT_CONTAINED` | 403 | Agent-origin send to a recipient that is neither approved nor a thread participant, when containment is enforced for the mailbox | `recipient_containment` | admin (add to allowlist / opt out); agent can continue an existing thread | No |
| `RECIPIENT_UNDELIVERABLE` | 422 | The recipient's domain has no reachable mail servers (no MX/A), or deep verification confirmed the mailbox doesn't exist | — | agent (fix the address) | No |
| `RECIPIENT_ADDRESS_INVALID` | 422 | The recipient fails a strict syntax check beyond basic email-format validation | — | agent (fix the address) | No |
| `RECIPIENT_DOMAIN_TYPO_SUSPECTED` | 422 | The recipient domain looks like a single-character typo of a common provider (suggestion in the error message) | — | agent (fix the typo, or resend as-is if correct) | No |
| `RECIPIENT_ROLE_ADDRESS` | 422 | The recipient is a role/distribution mailbox (`noreply@`, etc.), not an individual inbox. Exempt on a reply / thread continuation. | — | agent (use a different recipient) | No |
| `RECIPIENT_DISPOSABLE_ADDRESS` | 422 | The recipient domain is a disposable/temporary email provider. Exempt on a reply / thread continuation. | — | agent (ask for a permanent address) | No |
| `AMBIGUOUS_THREAD_MAILBOX` | 409 | A `thread_id` resolves in more than one of your mailboxes | — | agent (pass the mailbox) | No |
| `AMBIGUOUS_THREAD_RECIPIENT` | 422 | A thread has multiple participants and no recipient was chosen | — | agent (pass one participant) | No |
| `RECIPIENT_NOT_IN_THREAD` | 422 | The chosen recipient is not a participant of that thread | — | agent (pick a participant) | No |
| `THREAD_HAS_NO_INBOUND_RECIPIENT` | 422 | The thread is send-only — nobody to reply to | — | agent (start a new send) | No |
| `REPLY_LOOP_DETECTED` | 429 | The reply-loop guard tripped on repeated back-and-forth | — | agent (pause, don't auto-retry) | No (investigate) |

### Send gates — mailbox, domain & account state

| Code | HTTP | When it fires | Axis | Fix | Retry |
|---|---|---|---|---|---|
| `MAILBOX_SUSPENDED` | 403 | The target mailbox is suspended | — | admin | No |
| `DOMAIN_SUSPENDED` | 403 | The sending custom domain is suspended | — | admin | No |
| `DOMAIN_UNAVAILABLE` | 503 | The sending domain is not currently usable | — | admin, or retry later | Yes (transient) |
| `DOMAIN_UNHEALTHY` | 503 | The sending domain failed a health check | — | admin, or retry later | Yes (transient) |
| `SANDBOX_RECIPIENT_DOMAIN_CAP_EXCEEDED` | 429 | A Sandbox account made 3 send attempts to one non-simulator recipient domain in the rolling 24-hour window; exact first-party simulator scenarios are exempt | — | agent (wait for an earlier attempt to leave the window), or human (use another account / paid tier) | Yes (after window) |
| `SANDBOX_TRIAL_BUDGET_EXHAUSTED` | 403 | The sandbox cumulative send cap is spent | `trust_capacity` | human (add paygo credits / upgrade) | No |
| `SANDBOX_TRIAL_EXPIRED` | 403 | The sandbox trial window ended | `account_state` | human (add paygo credits / upgrade) | No |

### Governed send outcomes (strict mode)

Emitted **only** when the caller sets `Prefer: outcome=strict` on send/reply and the send
did not deliver. Without the header these outcomes surface success-shaped. Full contract:
[/agents/send-outcomes](/agents/send-outcomes).

| Code | HTTP | When it fires | Fix | Retry |
|---|---|---|---|---|
| `EMAIL_EFFECT_REJECTED` | 422 | Content policy rejected the send (terminal) | agent (edit content / escalate) | No — never retry a block as-is |
| `EMAIL_EFFECT_HELD` | 409 | Accepted into governance, awaiting human review (releasable) | human (review) | No (wait for review) |
| `EMAIL_EFFECT_HELD_INFRA` | 503 | Held by a transient infrastructure error | agent (retry shortly, honor `Retry-After`) | Yes |

### Content sanitization (outbound)

| Code | HTTP | When it fires | Fix | Retry |
|---|---|---|---|---|
| `OUTBOUND_HTML_ACTIVE_CONTENT_REJECTED` | 400 | HTML body carried active/unsafe constructs (`details.categories` names them, e.g. `script`, `iframe`, `form`, `event_handler`, `javascript_url`) | agent (remove those constructs, or send plain text) | No |
| `OUTBOUND_HTML_SANITIZE_FAILED` | 400 | HTML could not be parsed, or exceeded the input size limit | agent (simplify/shrink the HTML) | No |
| `BODY_FORMAT_HTML_SESSION_ONLY` | 400 | Requested an HTML-format read with a Bearer/agent key (session-only) | agent (request a non-HTML format) | No |

### Drafts & review

| Code | HTTP | When it fires | Fix | Retry |
|---|---|---|---|---|
| `DRAFT_REJECTED_BY_RESCAN` | 409 | The authoritative rescan at send time blocked the draft | agent (edit content) | No |
| `DRAFT_ALREADY_SENT` | 409 | The draft was already sent | agent (stop) | No |
| `DRAFT_EDITED_SINCE_HOLD` | 409 | The draft changed after being held for review | human/agent (re-review) | No |
| `DRAFT_NOT_HELD` | 409 | A review action targeted a draft that isn't in a held state | agent (recheck state) | No |
| `DRAFT_WRONG_STATE` | 409 | The draft is in a state that forbids this action | agent (recheck state) | No |
| `DRAFT_HELD_DECISION_MISSING` | 409 | A held-draft send lacked the required review decision | human (record a decision) | No |
| `BLOCK_TERMINAL` | 403 | The message is in a terminal blocked state and cannot be actioned | — | No |
| `ALREADY_RELEASED` | 409 | The message was already released | agent (stop) | No |
| `REVIEW_NOTE_REQUIRED` | 400 | A review action requires a note and none was supplied | agent (supply a note) | No |
| `SCHEDULED_SEND_QUOTA_EXCEEDED` | 429 | Too many scheduled sends queued | agent (reduce/space out) | Yes (later) |
| `ATTACHMENTS_REQUIRE_SYNC_SEND` | 400 | Attachments can't ride an async/scheduled path | agent (send synchronously) | No |

### Attachments

Staging + preview codes. The upload → poll → consume-once lifecycle is documented at
[/agents/attachments](/agents/attachments).

| Code | HTTP | When it fires | Axis | Fix | Retry |
|---|---|---|---|---|---|
| `OUTBOUND_ATTACHMENTS_DISABLED` | 403 | Outbound attachments aren't enabled on the mailbox | — | human (enable, session + re-auth) | No |
| `OUTBOUND_IMAGE_DISCLAIMER_REQUIRED` | 400 | An image attachment before the image-risk disclaimer is accepted (`details.required_version`) | human (accept disclaimer) | No |
| `OUTBOUND_ATTACHMENT_TYPE_NOT_ALLOWED` | 400 | The file's sniffed family is not an allowed outbound type | agent (send an allowed type) | No |
| `OUTBOUND_ATTACHMENT_FILENAME_INVALID` | 400 | The declared filename is malformed | agent (fix filename) | No |
| `OUTBOUND_ATTACHMENT_STAGING_QUOTA_EXCEEDED` | 429 | Too many staged-but-unsent attachments | agent (consume/expire some, retry) | Yes (later) |
| `ATTACHMENT_TOO_LARGE` | 400 | The uploaded file exceeds the size limit | agent (shrink) | No |
| `ATTACHMENT_MULTIPLE_FILES` | 400 | More than one file part in a single-file upload | agent (one file per request) | No |
| `ATTACHMENT_BLOCKED` | 400 | The attachment type is policy-blocked | agent (different type) | No |
| `ATTACHMENT_INFECTED` | 400 | The file failed virus scanning | agent (do not send) | No |
| `ATTACHMENT_AV_ERROR` | 400 | The file could not be virus-scanned (fail-closed) | agent (re-upload) | Yes (re-upload) |
| `ATTACHMENT_SCAN_PENDING` | 409 | A referenced attachment's content scan hasn't finished | agent (poll, then resend) | Yes (after poll) |
| `ATTACHMENT_ALREADY_CONSUMED` | 409 | A staging handle was already consumed by a prior send | agent (re-upload for a new send) | No |
| `ATTACHMENT_ACCESS_DISABLED` | 403 | Raw attachment access is disabled for this credential | `key_role` | human (session) | No |
| `ATTACHMENT_NOT_STORED` | 404 | The raw bytes were not retained | — | No |
| `ATTACHMENT_IMAGE_RISK_NOT_ACCEPTED` | 403 | Inline image access before the image-risk disclaimer is accepted | — | human (accept) | No |
| `ATTACHMENT_INLINE_NOT_IMAGE` | 403 | Inline-data request for a non-image part | — | agent | No |
| `ATTACHMENT_INLINE_TOO_LARGE` | 413 | Inline-data part exceeds the inline size limit | — | agent | No |
| `ATTACHMENT_PREVIEW_PENDING` | 409 | The safe-text preview is still generating | — | agent (retry shortly) | Yes |
| `ATTACHMENT_PREVIEW_NOT_AVAILABLE` | 404 | No safe preview exists (metadata-only, non-previewable, or generation failed) | — | No |
| `ATTACHMENT_PREVIEW_DISABLED` | 403 | Previews aren't available — mailbox mode off, plan-gated, or key not permitted (axis is `mailbox_config` or `tier`) | `mailbox_config` / `tier` | admin / human | No |
| `TIER_LIMIT` | 403 | The action needs a higher plan (`details.feature`, `required_tier`) | `tier` | human (upgrade) | No |

### Idempotency

The immediate-send idempotency contract keys on the `Idempotency-Key` header. A repeat of
a proven send replays the prior outcome (same `message_id`) rather than erroring.

| Code | HTTP | When it fires | Fix | Retry |
|---|---|---|---|---|
| `IDEMPOTENCY_KEY_REQUIRED` | 400 | An idempotency probe/route needs the key and none was given | agent (supply the key) | No |
| `IDEMPOTENT_REQUEST_IN_FLIGHT` | 409 | A concurrent request with the same key is still running (carries `Retry-After`) | agent (retry after the window) | Yes (short) |
| `IDEMPOTENT_REQUEST_NOT_PROVEN_SENT` | 409 | A prior attempt's delivery is indeterminate; replay is withheld until proven | agent (retry after reconciliation) | Yes (after reconcile) |
| `IDEMPOTENCY_KEY_BOUND_TO_DRAFT` | 409 | The key was already used on a draft path | agent (use a fresh key) | No |
| `IDEMPOTENCY_KEY_BOUND_TO_IMMEDIATE_SEND` | 409 | The key was already used on an immediate send | agent (use a fresh key) | No |
| `IDEMPOTENCY_KEY_DRAFT_CONSUMED` | 409 | The draft this key was bound to is already consumed | agent (use a fresh key) | No |

### Rate limits, quota & capacity

| Code | HTTP | When it fires | Axis | Fix | Retry |
|---|---|---|---|---|---|
| `RATE_LIMITED` | 429 | A limit was hit — see [disambiguation](#rate_limited-disambiguation) | — | agent (back off per `details`) | Yes |
| `SANDBOX_TRIAL_BUDGET_EXHAUSTED` | 403 | Sandbox cumulative send cap spent (also listed above) | `trust_capacity` | human | No |
| `SANDBOX_TRIAL_EXPIRED` | 403 | Sandbox trial window ended (also listed above) | `account_state` | human | No |

### Billing

Send-path billing gates. Managing billing is a human/admin dashboard action.

| Code | HTTP | When it fires | Fix | Retry |
|---|---|---|---|---|
| `BILLING_LAPSED` | 403 | Billing lapsed — sends are gated | human (restore billing) | No |
| `BILLING_REACTIVATION_PENDING` | 403 | Reactivation is pending | human (wait/complete) | No |
| `PAYGO_INSUFFICIENT_CREDITS` | 403 | Pay-as-you-go balance can't cover the send | human (top up credits at Settings → Billing) | No |

### Validation & transport

| Code | HTTP | When it fires | Fix | Retry |
|---|---|---|---|---|
| `VALIDATION_ERROR` | 400 / 422 | Request failed schema (400) or semantic (422) validation; `details` may name the field | agent (fix input) | No |
| `INVALID_BODY_FORMAT` | 400 | The body/format combination is unsupported | agent (fix format) | No |
| `INVALID_STATE` | 409 | The target resource is in a state that forbids the action | agent (recheck state) | No |
| `NOT_FOUND` | 404 | The resource does not exist (or isn't visible to this credential) | agent (check id/scope) | No |
| `REPORT_OUTBOUND_UNSUPPORTED` | 422 | Report/block was invoked on an outbound message (inbound-only) | agent (only report inbound) | No |
| `LEGAL_HOLD_ACTIVE` | 409 | A legal hold blocks the mutation (e.g. delete) | admin/legal | No |
| `WAIT_UNAVAILABLE` | 503 | The long-poll/wait facility is momentarily unavailable | agent (retry) | Yes |
| `UPSTREAM_ERROR` | 502 | A dependency returned an error | agent (retry with backoff) | Yes |
| `INTERNAL_ERROR` | 500 | An unexpected server error | agent (retry with backoff; report if persistent) | Yes |

## Retry semantics in one line

- **5xx** (`INTERNAL_ERROR`, `UPSTREAM_ERROR`, `DOMAIN_UNAVAILABLE`/`_UNHEALTHY`,
  `WAIT_UNAVAILABLE`, `EMAIL_EFFECT_HELD_INFRA`) and **short-window `429`s** → retry with
  backoff.
- **`IDEMPOTENT_REQUEST_IN_FLIGHT` / `_NOT_PROVEN_SENT` / `ATTACHMENT_SCAN_PENDING` /
  `ATTACHMENT_PREVIEW_PENDING`** → poll/retry after the indicated window.
- **Content blocks** (`EMAIL_EFFECT_REJECTED`, `DRAFT_REJECTED_BY_RESCAN`, the
  `OUTBOUND_HTML_*` rejects) → never retry unchanged; edit the content, unless the finding
  carries `failure_class: "inference_error"` (`retryable: true`).
- **Capability denials** (any `details.reason_axis`) → apply the `remedy`; most need a
  human/admin, and none can be resolved by re-sending the same request.

The `RATE_LIMITED` daily-budget path and idempotency guards are proven never to
double-charge or double-send on a retry.
