Error reference

Every error response carries the same JSON envelope. Branch on code, never on the error prose or the HTTP status alonecode 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 code for the precise cause and remedy.
  • details is present on some codes only. Two families put machine-routable data there: the denial envelope (capability denials) and the RATE_LIMITED variants. 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_axisMeaningremedyWho fixesupgrade_url?
tierYour plan does not include the featureupgrade_tier (target → required_tier)Human / admin (billing)Yes
key_roleWrong credential — a Bearer/agent key on a human-session-only surfaceuse_sessionHuman (dashboard session)No
mailbox_configThe mailbox setting is offenable_mailbox_mode (target → required_mailbox_mode)Admin (dashboard)No
trust_capacityA rate/volume capacity ceiling was reachedadd_paygo_credits_or_await_trust_rampHuman/admin, or waitYes
account_stateAccount lifecycle (e.g. sandbox trial ended)add_paygo_credits_or_upgradeHuman / adminYes
account_policyThe capability is disabled for API keys on this accountenable_account_policyAdmin (dashboard session)No
recipient_containmentAn agent-origin send was contained to approved recipientsadd_recipient_to_allowlistAdmin (add to allowlist / opt out); agent can continue an existing threadNo
accountability_floorRelaxing injection defense needs an accountable billing partyset_up_billingHuman / 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:

VariantSignalMeaningBackoff
Daily send budgetdetails.daily_limit present (with sends_remaining: 0, reset_at ISO-8601)You spent today's send allowanceRetry after reset_at (midnight UTC), or add pay-as-you-go credits / upgrade
New-account warm-updetails.reason === "new_account_warmup" (with retry_after_seconds)A send-rate warm-up burst limit; verifying your own sending domain lifts itRetry after retry_after_seconds
Generic short-windowdetails.retry_after (seconds) or no detailsA short-window request throttleWait 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 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.

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

CodeHTTPWhen it firesFixRetry
UNAUTHORIZED401Missing, malformed, revoked, or unknown API keyagent (supply a valid key)No
API_KEY_FORMAT_LEGACY401Key is in a retired format and no longer acceptedagent (mint a current-format key)No
EMAIL_NOT_VERIFIED403The account's email is unverifiedhuman (verify email)No
PHONE_NOT_VERIFIED403The required signup phone is unverifiedhuman (verify SMS code)No
PHONE_REQUIRED400An OAuth new-account branch omitted the mandatory phone numberhuman (restart signup with a number)No
PHONE_INVALID400Signup/resend phone number is not in a valid international formathuman (correct it)No
PHONE_COUNTRY_NOT_SUPPORTED400The number’s country is outside the SMS verification allowlisthuman (use a number from a supported country)No
NO_PHONE_VERIFICATION_PENDING400No current SMS challenge existshuman (request a new code)No
INVALID_PHONE_VERIFICATION_CODE400The SMS code is wronghuman (re-enter code)No
PHONE_VERIFICATION_EXPIRED410The 5-minute SMS challenge expiredhuman (request a new code)No
PHONE_VERIFICATION_ATTEMPTS_EXCEEDED429Too many wrong codes invalidated the challengehuman (request a new code)No
PHONE_VERIFICATION_RATE_LIMITED429SMS send/check limits were reachedhuman/agent (wait for Retry-After)Yes (after window)
PHONE_VERIFICATION_UNAVAILABLE503SMS verification or its fail-closed limiter is unavailablehuman/agent (retry later)Yes
PHONE_ALREADY_VERIFIED409Resend/correction was attempted after phone verification completedhuman/agent (stop resending)No
PHONE_VERIFICATION_SUPERSEDED409A newer concurrent SMS request replaced this sendhuman/agent (retry deliberately)Yes
ACCOUNT_SUSPENDED403The account is suspendedhuman/admin (reinstate)No
AUTH_TEMPORARILY_BLOCKED429Too many failed auth attempts — a brute-force lockagent (wait, then retry)Yes (after window)
REAUTH_REQUIRES_SESSION403A route needs a dashboard session with fresh TOTP/SMS/password re-auth; a Bearer/agent key is rejectedhuman (dashboard re-auth)No
BILLING_REQUIRES_SESSION403A billing route was called with a Bearer key; billing is session-onlyhuman (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.

CodeHTTPWhen it firesFixRetry
MFA_SMS_NOT_AVAILABLE400An 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_REQUIRED400An 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_CODE400The SMS re-auth or ceremony code is wrong or expiredhuman (re-enter, or resend)No
MFA_SMS_RATE_LIMITED429Too many SMS codes requested (carries Retry-After / details.retry_after)human/agent (wait for the window)Yes (after window)
MFA_SMS_UNAVAILABLE503The SMS provider (or its fail-closed limiter) is momentarily unavailablehuman/agent (retry later)Yes
MFA_SMS_SEND_CONFLICT429A verification for this same step-up action is already in flighthuman (wait a moment, then retry)Yes (short)
MFA_METHOD_SWITCH_REQUIRED400Tried to enroll a fresh method while one is already enabledhuman (use the method-switch ceremony)No
MFA_RECOVERY_ACK_REQUIRED400Confirmed enrollment/switch before acknowledging the saved recovery codeshuman (acknowledge, then confirm)No
MFA_NO_PENDING_CEREMONY400Acknowledged or confirmed with no staged ceremony (or it expired / was superseded)human (restart the ceremony)No
MFA_RECOVERY_WINDOW_EXPIRED400No usable one-shot recovery grant on this session (expired, consumed, or reserved) — the lost-factor disable / rebind flowshuman (log in again with a recovery code)No
REAUTH_SESSION_TOO_OLD403An SSO-only account tried to enroll SMS MFA without a fresh sessionhuman (sign in again, then retry)No
PRIVACY_ACK_REQUIRED400The account's accepted Privacy Policy/DPA predates the SMS-verification scope; carries details.current_privacy_version / current_dpa_versionhuman (re-accept the current version, resubmit with accept_privacy_version)No
PASSWORD_CHANGE_UNAVAILABLE400The 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_COOLDOWN429The 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_TAKEN409The new address proved by the confirm code is already in use on another accounthuman (choose a different address and restart)No
EMAIL_CHANGE_INVALID_CODE400The 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 oraclehuman (restart the change from Settings)No

Authorization & scope

CodeHTTPWhen it firesAxisFixRetry
INSUFFICIENT_SCOPE403An agent (mailbox-scoped) key called an admin-only surfaceadmin (use an admin key)No
FORBIDDEN403The credential is valid but not permitted for this resourceagent/adminNo
MESSAGE_DELETE_NOT_PERMITTED403Agent message-delete is disabled for API keys on the accountaccount_policyadmin (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.

CodeHTTPWhen it firesAxisFixRetry
RECIPIENT_SUPPRESSED403Recipient is on the account do-not-contact listadmin (remove suppression) or change recipientNo
RECIPIENT_NOT_ON_ALLOWLIST403Mailbox is in allowlist mode and the recipient is not approvedadmin (approve the address); agent can continue an existing threadNo
RECIPIENT_AGENT_CONTAINED403Agent-origin send to a recipient that is neither approved nor a thread participant, when containment is enforced for the mailboxrecipient_containmentadmin (add to allowlist / opt out); agent can continue an existing threadNo
RECIPIENT_UNDELIVERABLE422The recipient's domain has no reachable mail servers (no MX/A), or deep verification confirmed the mailbox doesn't existagent (fix the address)No
RECIPIENT_ADDRESS_INVALID422The recipient fails a strict syntax check beyond basic email-format validationagent (fix the address)No
RECIPIENT_DOMAIN_TYPO_SUSPECTED422The 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_ADDRESS422The 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_ADDRESS422The recipient domain is a disposable/temporary email provider. Exempt on a reply / thread continuation.agent (ask for a permanent address)No
AMBIGUOUS_THREAD_MAILBOX409A thread_id resolves in more than one of your mailboxesagent (pass the mailbox)No
AMBIGUOUS_THREAD_RECIPIENT422A thread has multiple participants and no recipient was chosenagent (pass one participant)No
RECIPIENT_NOT_IN_THREAD422The chosen recipient is not a participant of that threadagent (pick a participant)No
THREAD_HAS_NO_INBOUND_RECIPIENT422The thread is send-only — nobody to reply toagent (start a new send)No
REPLY_LOOP_DETECTED429The reply-loop guard tripped on repeated back-and-forthagent (pause, don't auto-retry)No (investigate)

Send gates — mailbox, domain & account state

CodeHTTPWhen it firesAxisFixRetry
MAILBOX_SUSPENDED403The target mailbox is suspendedadminNo
DOMAIN_SUSPENDED403The sending custom domain is suspendedadminNo
DOMAIN_UNAVAILABLE503The sending domain is not currently usableadmin, or retry laterYes (transient)
DOMAIN_UNHEALTHY503The sending domain failed a health checkadmin, or retry laterYes (transient)
SANDBOX_RECIPIENT_DOMAIN_CAP_EXCEEDED429A Sandbox account made 3 send attempts to one non-simulator recipient domain in the rolling 24-hour window; exact first-party simulator scenarios are exemptagent (wait for an earlier attempt to leave the window), or human (use another account / paid tier)Yes (after window)
SANDBOX_TRIAL_BUDGET_EXHAUSTED403The sandbox cumulative send cap is spenttrust_capacityhuman (add paygo credits / upgrade)No
SANDBOX_TRIAL_EXPIRED403The sandbox trial window endedaccount_statehuman (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.

CodeHTTPWhen it firesFixRetry
EMAIL_EFFECT_REJECTED422Content policy rejected the send (terminal)agent (edit content / escalate)No — never retry a block as-is
EMAIL_EFFECT_HELD409Accepted into governance, awaiting human review (releasable)human (review)No (wait for review)
EMAIL_EFFECT_HELD_INFRA503Held by a transient infrastructure erroragent (retry shortly, honor Retry-After)Yes

Content sanitization (outbound)

CodeHTTPWhen it firesFixRetry
OUTBOUND_HTML_ACTIVE_CONTENT_REJECTED400HTML 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_FAILED400HTML could not be parsed, or exceeded the input size limitagent (simplify/shrink the HTML)No
BODY_FORMAT_HTML_SESSION_ONLY400Requested an HTML-format read with a Bearer/agent key (session-only)agent (request a non-HTML format)No

Drafts & review

CodeHTTPWhen it firesFixRetry
DRAFT_REJECTED_BY_RESCAN409The authoritative rescan at send time blocked the draftagent (edit content)No
DRAFT_ALREADY_SENT409The draft was already sentagent (stop)No
DRAFT_EDITED_SINCE_HOLD409The draft changed after being held for reviewhuman/agent (re-review)No
DRAFT_NOT_HELD409A review action targeted a draft that isn't in a held stateagent (recheck state)No
DRAFT_WRONG_STATE409The draft is in a state that forbids this actionagent (recheck state)No
DRAFT_HELD_DECISION_MISSING409A held-draft send lacked the required review decisionhuman (record a decision)No
BLOCK_TERMINAL403The message is in a terminal blocked state and cannot be actionedNo
ALREADY_RELEASED409The message was already releasedagent (stop)No
REVIEW_NOTE_REQUIRED400A review action requires a note and none was suppliedagent (supply a note)No
SCHEDULED_SEND_QUOTA_EXCEEDED429Too many scheduled sends queuedagent (reduce/space out)Yes (later)
ATTACHMENTS_REQUIRE_SYNC_SEND400Attachments can't ride an async/scheduled pathagent (send synchronously)No

Attachments

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

CodeHTTPWhen it firesAxisFixRetry
OUTBOUND_ATTACHMENTS_DISABLED403Outbound attachments aren't enabled on the mailboxhuman (enable, session + re-auth)No
OUTBOUND_IMAGE_DISCLAIMER_REQUIRED400An image attachment before the image-risk disclaimer is accepted (details.required_version)human (accept disclaimer)No
OUTBOUND_ATTACHMENT_TYPE_NOT_ALLOWED400The file's sniffed family is not an allowed outbound typeagent (send an allowed type)No
OUTBOUND_ATTACHMENT_FILENAME_INVALID400The declared filename is malformedagent (fix filename)No
OUTBOUND_ATTACHMENT_STAGING_QUOTA_EXCEEDED429Too many staged-but-unsent attachmentsagent (consume/expire some, retry)Yes (later)
ATTACHMENT_TOO_LARGE400The uploaded file exceeds the size limitagent (shrink)No
ATTACHMENT_MULTIPLE_FILES400More than one file part in a single-file uploadagent (one file per request)No
ATTACHMENT_BLOCKED400The attachment type is policy-blockedagent (different type)No
ATTACHMENT_INFECTED400The file failed virus scanningagent (do not send)No
ATTACHMENT_AV_ERROR400The file could not be virus-scanned (fail-closed)agent (re-upload)Yes (re-upload)
ATTACHMENT_SCAN_PENDING409A referenced attachment's content scan hasn't finishedagent (poll, then resend)Yes (after poll)
ATTACHMENT_ALREADY_CONSUMED409A staging handle was already consumed by a prior sendagent (re-upload for a new send)No
ATTACHMENT_ACCESS_DISABLED403Raw attachment access is disabled for this credentialkey_rolehuman (session)No
ATTACHMENT_NOT_STORED404The raw bytes were not retainedNo
ATTACHMENT_IMAGE_RISK_NOT_ACCEPTED403Inline image access before the image-risk disclaimer is acceptedhuman (accept)No
ATTACHMENT_INLINE_NOT_IMAGE403Inline-data request for a non-image partagentNo
ATTACHMENT_INLINE_TOO_LARGE413Inline-data part exceeds the inline size limitagentNo
ATTACHMENT_PREVIEW_PENDING409The safe-text preview is still generatingagent (retry shortly)Yes
ATTACHMENT_PREVIEW_NOT_AVAILABLE404No safe preview exists (metadata-only, non-previewable, or generation failed)No
ATTACHMENT_PREVIEW_DISABLED403Previews aren't available — mailbox mode off, plan-gated, or key not permitted (axis is mailbox_config or tier)mailbox_config / tieradmin / humanNo
TIER_LIMIT403The action needs a higher plan (details.feature, required_tier)tierhuman (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.

CodeHTTPWhen it firesFixRetry
IDEMPOTENCY_KEY_REQUIRED400An idempotency probe/route needs the key and none was givenagent (supply the key)No
IDEMPOTENT_REQUEST_IN_FLIGHT409A concurrent request with the same key is still running (carries Retry-After)agent (retry after the window)Yes (short)
IDEMPOTENT_REQUEST_NOT_PROVEN_SENT409A prior attempt's delivery is indeterminate; replay is withheld until provenagent (retry after reconciliation)Yes (after reconcile)
IDEMPOTENCY_KEY_BOUND_TO_DRAFT409The key was already used on a draft pathagent (use a fresh key)No
IDEMPOTENCY_KEY_BOUND_TO_IMMEDIATE_SEND409The key was already used on an immediate sendagent (use a fresh key)No
IDEMPOTENCY_KEY_DRAFT_CONSUMED409The draft this key was bound to is already consumedagent (use a fresh key)No

Rate limits, quota & capacity

CodeHTTPWhen it firesAxisFixRetry
RATE_LIMITED429A limit was hit — see disambiguationagent (back off per details)Yes
SANDBOX_TRIAL_BUDGET_EXHAUSTED403Sandbox cumulative send cap spent (also listed above)trust_capacityhumanNo
SANDBOX_TRIAL_EXPIRED403Sandbox trial window ended (also listed above)account_statehumanNo

Billing

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

CodeHTTPWhen it firesFixRetry
BILLING_LAPSED403Billing lapsed — sends are gatedhuman (restore billing)No
BILLING_REACTIVATION_PENDING403Reactivation is pendinghuman (wait/complete)No
PAYGO_INSUFFICIENT_CREDITS403Pay-as-you-go balance can't cover the sendhuman (top up credits at Settings → Billing)No

Validation & transport

CodeHTTPWhen it firesFixRetry
VALIDATION_ERROR400 / 422Request failed schema (400) or semantic (422) validation; details may name the fieldagent (fix input)No
INVALID_BODY_FORMAT400The body/format combination is unsupportedagent (fix format)No
INVALID_STATE409The target resource is in a state that forbids the actionagent (recheck state)No
NOT_FOUND404The resource does not exist (or isn't visible to this credential)agent (check id/scope)No
REPORT_OUTBOUND_UNSUPPORTED422Report/block was invoked on an outbound message (inbound-only)agent (only report inbound)No
LEGAL_HOLD_ACTIVE409A legal hold blocks the mutation (e.g. delete)admin/legalNo
WAIT_UNAVAILABLE503The long-poll/wait facility is momentarily unavailableagent (retry)Yes
UPSTREAM_ERROR502A dependency returned an erroragent (retry with backoff)Yes
INTERNAL_ERROR500An unexpected server erroragent (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 429s → 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.