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.
{
"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
codefor the precise cause and remedy. detailsis present on some codes only. Two families put machine-routable data there: the denial envelope (capability denials) and theRATE_LIMITEDvariants. 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; for the four governed send outcomes see /agents/send-outcomes; for message states and scan-verdict vocabulary see /agents/messages; for the attachment staging lifecycle see /agents/attachments; for CLI exit codes see /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:
{
"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_urlattaches only on the monetary axes (tier,trust_capacity,account_state) and only when a public dashboard link base is configured. It is omitted, nevernull— 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_stepis the smallest money that unblocks you:paygo|starter|pro, orno_cost_remedywhen 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, oraccountability_floordenials. 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.
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 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 carryretryable: true, and the customer-facing reason collapses to a static infrastructure string. A held or blocked send withfailure_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-allowfindings that have concrete guidance; absent oninference_error.
Verdict vocabulary (quarantined vs blocked, releasable vs terminal) is documented at
/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.
| 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.
| 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.
| 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 | — | 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-window429s → 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, theOUTBOUND_HTML_*rejects) → never retry unchanged; edit the content, unless the finding carriesfailure_class: "inference_error"(retryable: true). - Capability denials (any
details.reason_axis) → apply theremedy; 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.