Errors
Every error response from https://api.primitive.dev/v1 uses the same envelope so agents and SDKs can branch on machine-readable codes without parsing HTTP status text or scraping prose.
Envelope
{ "success": false, "error": { "code": "unauthorized", "message": "Invalid or missing API key", "request_id": "req_01HZ8K3M2N7Q5R8S9T0V1W2X3Y" } }
error.codeis a stable machine-readable identifier. Agents and SDKs branch on this value, not on the human-readablemessage.error.messageis a short human-readable description. It can change between releases without warning. Do not parse it.error.request_idechoes the server-issued request id and is also returned in theX-Request-Idresponse header. Include this in support escalations so we can find the failing request in our logs.
Validation errors additionally carry an error.details object listing the rejected fields:
{ "success": false, "error": { "code": "validation_error", "message": "Request body failed validation", "details": { "from": "must be a verified outbound domain" }, "request_id": "req_..." } }
Send denials (recipient_not_allowed) additionally carry an error.gates[] array. Each entry describes one failed permission gate so an agent can branch on a stable reason and self-correct:
{ "success": false, "error": { "code": "recipient_not_allowed", "message": "Recipient is not allowed for this organization yet", "gates": [ { "name": "send_to_known_addresses", "reason": "recipient_not_known", "message": "You have not exchanged authenticated mail with this address yet.", "subject": "alice@external.com", "fix": { "action": "wait_for_inbound", "subject": "alice@external.com" }, "docs_url": "https://docs.primitive.dev/docs/sending" } ], "request_id": "req_..." } }
- Branch on
gates[].reason(and act ongates[].subject), never the humanmessage. Reasons are stable and additive — new gates may appear, existing ones are not renamed in place. gates[].fixis present only when there is a customer-side action; some denials (for example, the sender must fix their own DNS) carry nofix. When present,fix.subjectis the entity the action applies to.
Error codes
| Code | HTTP status | Meaning | Recovery |
|---|---|---|---|
unauthorized | 401 | Missing or invalid bearer token. The response carries a spec-shaped WWW-Authenticate: Bearer realm="Primitive API", resource_metadata="..." header pointing at the protected-resource metadata. | Acquire a token via the discovery flow at /auth.md, then retry. Do not loop. |
forbidden | 403 | The bearer is valid but lacks the scope required for the operation. | Reissue the token with the missing scope, or surface the missing scope to the human owner. Do not retry with the same token. |
not_found | 404 | The resource id does not exist or is not visible to the current organization. | Verify the id was returned from a previous call in the same org context. Do not retry. |
validation_error | 400 | Request body or query parameters failed schema validation. error.details enumerates the rejected fields. | Inspect details, fix the offending fields, retry once. Do not loop without changing input. |
mx_conflict | 409 | A domain claim conflicts with an existing mailbox provider on the same domain. | Surface the conflict to the user; pass confirmed: true in the request body to override. |
conflict | 409 | The request conflicts with current state (for example, a resource was modified concurrently). | Re-read the resource, reconcile, and retry. |
rate_limited | 429 | Per-organization rate limit exceeded. Returned by the org-wide API limiter and by /v1/send-mail. The per-minute API limit is plan-dependent; see Limits. | Honor the Retry-After response header before retrying. Implement client-side back-off. |
rate_limit_exceeded | 429 | Rate limit exceeded on a per-resource limiter (for example domain, CLI/agent signup, and webhook-secret rotation routes). Same meaning as rate_limited. | Treat the same as rate_limited: branch on the 429 status, honor Retry-After. |
service_unavailable | 503 | A backing dependency is temporarily unreachable or misconfigured on our side. | Retry with exponential back-off. Do not exceed 5 attempts; escalate persistent failures with the request_id. |
internal_error | 500 | An unhandled fault on the server side. The request_id is the entry point for our debugging. | Retry once with back-off, then escalate with the request_id if persistent. |
Send and reply errors
These codes come from POST /v1/send-mail, the reply/forward endpoints, and the read endpoints.
| Code | HTTP status | Meaning | Recovery |
|---|---|---|---|
recipient_not_allowed | 403 | A first-send gate refused the recipient. The response carries an error.gates[] array (see below) describing each failed gate. | Inspect gates[]; follow gates[].fix.action where present (for example, wait for inbound, or verify a domain). |
cannot_send_from_domain | 403 | The from address is not a verified outbound sender for your org. error.details.valid_senders lists the domains you can send from. | Send from a listed domain, or verify the from domain. Check GET /v1/outbound/status. |
inbound_not_repliable | 422 | The inbound email cannot be replied to — it was rejected at ingestion, its content was discarded, or it lacks a Message-ID/recipient. error.details.reason says which. | Do not retry. Start a fresh send instead. |
discard_not_enabled | 403 | Content discard was requested but the org has not opted in to the feature. | Enable content discard in webhook settings first. |
search_timeout | 504 | A search query exceeded its time budget. | Narrow the date range or add more filters, then retry. |
Outbound relay failures
When the outbound relay itself fails, the send returns one of these. The same Idempotency-Key makes a retry safe.
| Code | HTTP status | Meaning | Recovery |
|---|---|---|---|
outbound_capacity_exhausted | 503 | The outbound relay is temporarily at capacity. | Retry with back-off, honoring Retry-After. |
outbound_unreachable | 502 | The outbound relay was unreachable. | Retry with back-off. |
outbound_relay_failed | 502 | The relay returned an unclassified failure. | Retry with back-off, then escalate with the request_id. |
outbound_response_malformed | 502 | The relay returned an unexpected response. | Retry once, then escalate with the request_id. |
outbound_key_invalid | 500 | A server-side outbound credential is invalid. | Escalate with the request_id; retrying will not help until fixed. |
x402 payment errors
These codes come from the x402 endpoints (synthetic and email-native). See Collecting Payments and x402 over Email. x402 is in an invite-only soft launch; an org without access gets feature_disabled.
| Code | HTTP status | Meaning | Recovery |
|---|---|---|---|
feature_disabled | 403 | x402 is not enabled for your organization. error.details names the missing entitlement (missing_entitlement), a human message, and a docs_url pointing at the relevant "Requesting access" section. | Read error.details.docs_url and request access. Do not retry. |
no_payout_address | 422 | The payee has no payout wallet for this network (no address-bound wallet and no org default). | Register one with primitive payments register-payout-address --network <network>. |
payment_declined | 422 | The payer's spend policy refused the payment (paused, cap exceeded, or payee not allowlisted). | Inspect with primitive payments get-spend-policy; un-pause or raise the cap, then retry. |
payment_verification_failed | 422 | The signed payment did not match the recorded challenge (wallet, network, amount, or bound nonce). | Re-sign against the exact challenge you are answering. Do not retry unchanged. |
settlement_failed | 502 | The on-chain settlement did not complete, most often insufficient USDC in the paying wallet. | Fund the wallet and have the payee issue a fresh challenge; paying again is safe. |
challenge_expired | 422 | The synthetic-flow pay endpoint was called for a challenge past its expiry. In the email-native flow an expired challenge is not an API error: it surfaces as a reject step on the thread, whose reason is currently settlement_timeout (being renamed to challenge_expired to match this code). | The payee issues a new challenge; use a larger expires_in for slower payers. |
The email-native settle path additionally enforces per-payee-org settle caps. These surface as a reject step on the thread and a payment.failed webhook event (not as an API error code, because the settle happens after the payment reply arrives):
| Reason | Meaning |
|---|---|
settle_daily_cap_exceeded | The payee org's daily settle cap (default 25 USDC) is full for the UTC day. |
settle_monthly_cap_exceeded | The payee org's monthly settle cap (default 250 USDC) is full for the UTC month. |
amount_exceeds_per_settle_ceiling | This single settle is over the fixed 10 USDC per-settle ceiling. |
The daily and monthly caps are raisable by the Primitive team; the per-settle ceiling is fixed. See the Settle limits section in x402 over Email.
Structured failure reasons
The four top-level codes above are stable umbrellas. To let an auto-pay agent branch on why a payment failed without parsing the human message, every x402 failure also carries machine-readable detail:
{ "success": false, "error": { "code": "settlement_failed", "message": "facilitator settlement failed", "details": { "reasons": ["facilitator_unavailable"], "failure_category": "settlement" } } }
details.reasonsis an array of stable subreason codes (a single-element array when there is one reason). The humanmessageis preserved unchanged alongside it.details.failure_categoryis one ofdeclined,cap,verification,settlement.- The same
failure_categoryandreasonsare also added to thepayment.failedwebhook payload, so an async subscriber can branch identically.
The subreason codes per failure path:
payment_declined (category declined) — the payer's spend policy refused it. All permanent until the policy changes; the challenge stays pending so you can adjust and retry.
| Reason | Meaning |
|---|---|
paused | x402 payments are paused for the org (kill-switch on). |
per_payment_cap | The signed amount exceeds the per-payment limit. |
daily_cap | The signed amount would exceed the daily limit. |
allowlist | The payee counterparty is not on the spend allowlist. |
spend_non_positive_amount | The payment amount is not positive (malformed challenge). |
payment_verification_failed (category verification) — the signed payment did not match the recorded challenge. All permanent; re-sign against the exact challenge. reasons may carry several codes at once (index-aligned with the message).
| Reason | Meaning |
|---|---|
signature_unrecoverable | The payment signature did not recover. |
signer_mismatch | The recovered signer does not match authorization.from. |
invalid_from_address / invalid_to_address | from / to is not a valid address. |
payer_mismatch | authorization.from does not match the expected payer. |
payto_mismatch | authorization.to does not match the challenge payTo. |
self_send | The payer and payee wallets are the same address. |
amount_mismatch | authorization.value does not match the expected amount. |
verify_non_positive_amount | authorization.value is not positive. |
nonce_mismatch | authorization.nonce is not the interaction-bound nonce. |
degenerate_window / window_too_wide | The authorization validity window is degenerate or too wide. |
insufficient_headroom | The authorization has expired or lacks settlement headroom. |
not_yet_valid | The authorization is not yet valid (validAfter in the future). |
malformed_payload | The x402 payment payload was malformed. |
unsupported_network | The challenge network is not supported. |
settlement_failed (category settlement) — the on-chain settlement did not complete. This is where retryable vs permanent vs misconfig diverge:
| Reason | Retry guidance |
|---|---|
insufficient_funds | Permanent. Fund the paying wallet and have the payee issue a fresh challenge. |
facilitator_unavailable | Transient. The same signed payment may succeed later. The 502 carries a Retry-After header (seconds); honour it and retry the same payment. |
settlement_misconfigured | Operator misconfig (missing facilitator credentials). Retrying without an operator fix will keep failing. |
settlement_failed | An unclassified facilitator failure. Treated as transient (also carries Retry-After). |
challenge_expired (category settlement) — reasons: ["challenge_expired"]. Permanent for this challenge; the payee must issue a new one.
Settle-cap rejects (category cap) — the email-native rail's per-payee-org cap rejects (settle_daily_cap_exceeded, settle_monthly_cap_exceeded, amount_exceeds_per_settle_ceiling, documented above) surface with failure_category: "cap" in the payment.failed webhook payload.
Only facilitator_unavailable (and the generic settlement_failed) are retryable, and only those set Retry-After. Every other reason is permanent or a misconfiguration: retrying the same payment will fail the same way.
Rate-limit responses
429 responses carry a Retry-After header plus the standard rate-limit headers:
Retry-After: 30 ratelimit-limit: 120 ratelimit-remaining: 0 ratelimit-reset: 1718900000
Retry-After is in seconds; ratelimit-reset is a Unix timestamp (seconds) when the window resets; ratelimit-limit reflects your plan's per-minute API limit. Agents should sleep for at least Retry-After seconds before retrying the same operation. The body matches the standard envelope — branch on the 429 status, since the code is either rate_limited (org-wide limiter, /v1/send-mail) or rate_limit_exceeded (per-resource limiters):
{ "success": false, "error": { "code": "rate_limited", "message": "Rate limit exceeded" } }Idempotency
The /send-mail endpoint accepts an optional Idempotency-Key request header. When provided, replays of the same key return the original outcome (including the Idempotency-Key response header echoing the effective value) rather than firing a second send. Use this for any operation an agent might retry after a network failure.
If you omit the header, Primitive derives a key from the canonical request payload and returns it in the Idempotency-Key response header so you can use it on a follow-up retry.
Webhook signature failures
Webhook delivery failures appear in webhook event logs, not in API responses. See the signature verification guide for verifier logic.
Versioning and deprecation
The error envelope is part of the v1 contract. Breaking changes will only land in a new major version (/v2). See API versioning.