---
title: "Errors"
description: "Typed error envelope, error codes, and recovery guidance for the Primitive v1 REST API."
canonical: "https://docs.primitive.dev/docs/errors"
last-updated: "2026-05-17T00:00:00.000Z"
---

# 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"
  }
}
```


- `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.

- When escalating to support, include the request timestamp and the endpoint called, plus any identifier you have: your `Idempotency-Key` if you sent one, or the returned email `id` when the request got far enough to produce one.

Validation errors name the rejected fields in `error.message`, with each field path prefixed onto its failure (`field: reason`):


```
{
  "success": false,
  "error": {
    "code": "validation_error",
    "message": "from: Invalid email address"
  }
}
```


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"
      }
    ]
  }
}
```


- 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.

`send_to_known_addresses` reports why the most recent inbound from the recipient did not unlock replies:

ReasonMeaningFix action`recipient_not_known`No mail has been received from this address.`wait_for_inbound``recipient_grant_pending`A message from this address was accepted but its grant decision has not been written yet. Transient.`retry_shortly``recipient_grant_not_recorded`Mail was received from this address, but no grant decision was ever recorded for it (it predates decision recording, or recording did not complete). Not transient; a new message is needed.`wait_for_inbound``recipient_unauthenticated`Mail was received but the sender did not authenticate (DMARC failed).`sender_must_fix_authentication``recipient_weak_binding`The sender authenticated, but their DKIM signature does not cover the `To` header, so the message could not be bound to your inbox. Unlocks once their provider signs `To` or a message from them passes SPF.`sender_dkim_must_sign_to_header``recipient_only_sent_reports`Only bounces or automated reports have arrived from this address; those never unlock replies.`wait_for_inbound``recipient_grant_replay_denied`The last message failed replay defense (stale, future-dated, duplicate or missing `Message-ID`, your address not in `To`/`Cc`, or the check itself failed). A fresh message is needed.`wait_for_inbound``recipient_grant_denied`A message was received but did not qualify to unlock replies for another reason.`wait_for_inbound`

## Error codes

CodeHTTP statusMeaningRecovery`unauthorized`401Missing 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.`download_token_expired`401The signed download URL (from a webhook payload) is past its 24-hour validity window. The content itself is still stored. Returned only by `/v1/emails/{id}/raw` and `/v1/emails/{id}/attachments.tar.gz`.Retry the same URL with `Authorization: Bearer YOUR_API_KEY` and no `token` parameter; API-key downloads do not expire. `error.details.remediation` carries this instruction.`download_token_invalid`401The `token` query parameter on a download URL failed verification (wrong signature, audience, or email id).Use the signed URL exactly as delivered in the webhook payload, or switch to API-key auth as for `download_token_expired`. Do not retry with the same token.`forbidden`403The 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`404The 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`400Request body or query parameters failed schema validation. `error.message` names the rejected fields.Fix the fields named in `message`, retry once. Do not loop without changing input.`mx_conflict`409A 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`409The request conflicts with current state (for example, a resource was modified concurrently).Re-read the resource, reconcile, and retry.`rate_limited`429An organization rate limit was exceeded. Returned by the org-wide API limiter and by `/v1/send-mail`. The API request limit is 3000 per minute for Developer, Power, and Platinum organizations, and 120 per minute for agent-tier organizations; see [Limits](/docs/limits).Honor the `Retry-After` response header before retrying. Implement client-side back-off.`rate_limit_exceeded`429Rate 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`.`outbound_capacity_exhausted`503Outbound capacity is temporarily exhausted. Returned by `/v1/send-mail`; see [Outbound relay failures](#outbound-relay-failures) for the full relay error set.Retry with exponential back-off. No `Retry-After` header is sent on this response. Do not exceed 5 attempts; escalate persistent failures with the request timestamp and endpoint.`internal_error`500An unhandled fault on the server side. Include the request timestamp and endpoint when escalating.Retry once with back-off, then escalate with the request timestamp and endpoint if persistent.

## Send and reply errors

These codes come from the sending and sent-email management endpoints: `POST /v1/send-mail`, the reply/forward endpoints, the reschedule/cancel endpoints, and the read endpoints.

CodeHTTP statusMeaningRecovery`recipient_not_allowed`403A 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`403The `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`422The 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.`not_scheduled`409A reschedule (`PATCH /v1/sent-emails/{id}`) or cancel targeted a send that is no longer scheduled: it already executed, is executing now, or was already canceled. The message names the current status.Read `GET /v1/sent-emails/{id}` for the current state. Do not retry.`discard_not_enabled`403Content discard was requested but the org has not opted in to the feature.Enable content discard in webhook settings first.`search_timeout`504A 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_exhausted`503The outbound relay is temporarily at capacity.Retry with exponential back-off; no `Retry-After` header is sent.`outbound_unreachable`502The outbound relay could not be reached at all: the connection failed before the request was delivered, so nothing was sent.Retry with back-off.`outbound_relay_timeout`504The outbound relay accepted the request but did not answer within the deadline. The message may still be in flight, so a retry of the same idempotency key replays this result rather than sending a second copy.Retry with back-off. Reuse the same `Idempotency-Key`; a new key can produce a duplicate delivery.`outbound_relay_failed`502The relay returned an unclassified failure.Retry with back-off, then escalate with the request timestamp and endpoint.`outbound_response_malformed`502The relay returned an unexpected response.Retry once, then escalate with the request timestamp and endpoint.`outbound_key_invalid`500A server-side outbound credential is invalid.Escalate with the request timestamp and endpoint; retrying will not help until fixed.

## x402 payment errors

These codes come from the x402 endpoints (synthetic and email-native). See [Collecting Payments](/docs/collecting-payments) and [x402 over Email](/docs/x402-over-email). x402 is in an invite-only soft launch; an org without access gets `feature_disabled`.

CodeHTTP statusMeaningRecovery`feature_disabled`403x402 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`422The 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`422The 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`422The 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`502The 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`422The 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 `challenge_expired`, matching 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_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 per-settle ceiling (default 10 USDC).

The daily and monthly caps, and the per-settle ceiling, are all raisable per organization by the Primitive team. See the Settle limits section in [x402 over Email](/docs/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`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`).

ReasonMeaning`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:

ReasonRetry 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: 3000
ratelimit-remaining: 0
ratelimit-reset: 1718900000
ratelimit-policy: 3000;w=60
```


This example shows the standard organization's API request limit. `Retry-After` is in seconds, `ratelimit-reset` is a Unix timestamp (seconds), and `ratelimit-limit` reports the limit that rejected the request. The value is 120 for agent-tier API traffic and unauthenticated IP traffic; send and per-resource limits can have other values. 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" } }
```


The same four `ratelimit-*` headers are on other v1 API responses too, so a client can track its budget before it runs out. A `401` for a missing or invalid token reports the per-IP bucket that the request was charged to (120 per minute). The public GraphQL endpoint (`/v1/graphql`) reports its own per-IP bucket on every query response. Two cases carry less: organizations exempted from the API limit get no `ratelimit-*` headers, and while the limiter is briefly unavailable a response carries no live bucket state (a `401` then carries only `ratelimit-limit` and `ratelimit-policy`). Treat a missing `ratelimit-remaining` as unknown, not as zero.

## 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](/docs/signature-verification) 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](/docs/versioning).

Maintained by the [primitive.dev team](/about) · [Trust & security](/trust)Last updated: May 17, 2026
