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.code is a stable machine-readable identifier. Agents and SDKs branch on this value, not on the human-readable message.
  • error.message is a short human-readable description. It can change between releases without warning. Do not parse it.
  • error.request_id echoes the server-issued request id and is also returned in the X-Request-Id response 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 on gates[].subject), never the human message. Reasons are stable and additive — new gates may appear, existing ones are not renamed in place.
  • gates[].fix is present only when there is a customer-side action; some denials (for example, the sender must fix their own DNS) carry no fix. When present, fix.subject is the entity the action applies to.

Error codes

CodeHTTP statusMeaningRecovery
unauthorized401Missing 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.
forbidden403The 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_found404The 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_error400Request 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_conflict409A 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.
conflict409The request conflicts with current state (for example, a resource was modified concurrently).Re-read the resource, reconcile, and retry.
rate_limited429Per-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_exceeded429Rate 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_unavailable503A 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_error500An 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.

CodeHTTP statusMeaningRecovery
recipient_not_allowed403A 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_domain403The 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_repliable422The 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_enabled403Content discard was requested but the org has not opted in to the feature.Enable content discard in webhook settings first.
search_timeout504A 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.

CodeHTTP statusMeaningRecovery
outbound_capacity_exhausted503The outbound relay is temporarily at capacity.Retry with back-off, honoring Retry-After.
outbound_unreachable502The outbound relay was unreachable.Retry with back-off.
outbound_relay_failed502The relay returned an unclassified failure.Retry with back-off, then escalate with the request_id.
outbound_response_malformed502The relay returned an unexpected response.Retry once, then escalate with the request_id.
outbound_key_invalid500A 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.

CodeHTTP statusMeaningRecovery
feature_disabled403x402 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_address422The 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_declined422The 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_failed422The 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_failed502The 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_expired422The 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):

ReasonMeaning
settle_daily_cap_exceededThe payee org's daily settle cap (default 25 USDC) is full for the UTC day.
settle_monthly_cap_exceededThe payee org's monthly settle cap (default 250 USDC) is full for the UTC month.
amount_exceeds_per_settle_ceilingThis 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.reasons is an array of stable subreason codes (a single-element array when there is one reason). The human message is preserved unchanged alongside it.
  • details.failure_category is one of declined, cap, verification, settlement.
  • The same failure_category and reasons are also added to the payment.failed webhook 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.

ReasonMeaning
pausedx402 payments are paused for the org (kill-switch on).
per_payment_capThe signed amount exceeds the per-payment limit.
daily_capThe signed amount would exceed the daily limit.
allowlistThe payee counterparty is not on the spend allowlist.
spend_non_positive_amountThe 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).

ReasonMeaning
signature_unrecoverableThe payment signature did not recover.
signer_mismatchThe recovered signer does not match authorization.from.
invalid_from_address / invalid_to_addressfrom / to is not a valid address.
payer_mismatchauthorization.from does not match the expected payer.
payto_mismatchauthorization.to does not match the challenge payTo.
self_sendThe payer and payee wallets are the same address.
amount_mismatchauthorization.value does not match the expected amount.
verify_non_positive_amountauthorization.value is not positive.
nonce_mismatchauthorization.nonce is not the interaction-bound nonce.
degenerate_window / window_too_wideThe authorization validity window is degenerate or too wide.
insufficient_headroomThe authorization has expired or lacks settlement headroom.
not_yet_validThe authorization is not yet valid (validAfter in the future).
malformed_payloadThe x402 payment payload was malformed.
unsupported_networkThe 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:

ReasonRetry guidance
insufficient_fundsPermanent. Fund the paying wallet and have the payee issue a fresh challenge.
facilitator_unavailableTransient. The same signed payment may succeed later. The 502 carries a Retry-After header (seconds); honour it and retry the same payment.
settlement_misconfiguredOperator misconfig (missing facilitator credentials). Retrying without an operator fix will keep failing.
settlement_failedAn 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.