---
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 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`. New gates and reasons may appear, so treat a reason you do not recognize as a generic denial.

- `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.`service_unavailable`503The endpoint is temporarily at capacity or one of its dependencies is unavailable, for example when the database could not answer in time. Any endpoint can return it.Retry the same request after the `Retry-After` interval when the header is present, otherwise with back-off. On a send, reuse the same `Idempotency-Key`, so a send that was already accepted is replayed rather than sent twice.`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.`sending_ip_warmup_cap`429Only for an organization that sends from its own dedicated IP address. That address is warming up and today's recipient limit is used, or it is paused. Nothing was sent, and the mail is never moved to the shared sending pool instead. A send answered synchronously (for example one that asks to wait) gets this error at once; a send answered as queued is retried automatically for 30 minutes and then fails with this code. A scheduled send is not failed: it waits for the reset (it reads `scheduled` with `hold.reason` `sending_ip_warmup_cap`) and goes out on its own within the hour after 00:00 UTC, failing with this code only after 7 such holds. The dashboard's Domains page shows the address, its status and today's limit.Retry later with the same `Idempotency-Key`. `Retry-After`, when present, is a minimum wait: the limit resets at 00:00 UTC, so back off further while retries keep getting this code. Do not switch to a new key to get around it.`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. A history search (`search` on `GET /v1/emails`, `q` on `GET /v1/sent-emails`) also returns it when it reads its row budget or spends its time budget without filling the page.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 connection to the outbound relay failed after it opened, so the request may already have been delivered and the message may be in flight. 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_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.

When a relay failure may have reached the relay (`outbound_unreachable`, `outbound_relay_timeout`, `outbound_response_malformed`, or an `outbound_relay_failed` the relay did not refuse outright) and the send already holds a durable recovery copy, the send is answered as queued instead, in the endpoint's normal success shape. This applies to every send endpoint that does not ask to wait for delivery: `POST /v1/send-mail` without `wait` (`200` with `status: "queued"`), each message of `POST /v1/send-mail/batch`, `POST /v1/emails/{id}/reply`, and the [Simple Email](/docs/simple-email) send endpoints. Poll the send for its outcome rather than retrying. A send the relay refused outright, and a request that asked to wait, still gets the error.

A send that was accepted but could not be handed on because its last checks before delivery (the sender and HIPAA-mode rechecks) could not reach the database in time is also answered as queued, when it holds a durable recovery copy, including a request that asked to wait. Nothing was sent yet; a recovery pass delivers it, usually within minutes, after taking those checks again. Poll the send for its outcome rather than retrying. A send without a recovery copy gets a `service_unavailable` error instead when one of those checks cannot reach the database in time, and so does a send with `payload_attachments` whose uploaded payload cannot be read in time, since its copy is written only once that payload is attached. The error comes in the endpoint's usual error shape; `POST /v1/send-mail` answers it as a `503` with `Retry-After`. Nothing was sent, so the send's record is normally closed as `gate_denied` with `error_code` `service_unavailable` and stays in your sent list, and a retry with the same `Idempotency-Key` then sends the message once, as a new send with its own id.

A send to another Primitive address whose delivery could not be confirmed, because the receiving side did not answer, failed partway or could not take the message onto its queue, is answered the same way when it holds a durable recovery copy and the request did not ask to wait: as queued, in the endpoint's normal success shape. The message may already have arrived. It is delivered again under the same Message-ID, which the recipient keeps only once. Poll the send for its outcome rather than retrying; a retry with the same `Idempotency-Key` returns the queued send again. A request that asked to wait, or a send without a recovery copy, gets `direct_delivery_failed` with a `502`. That message too may already have arrived. Retry it with the same `Idempotency-Key`, which re-attempts the same message under the same Message-ID rather than sending a second one. A retry under a new key can deliver it twice if the first attempt did arrive.

## Rate-limit responses

A rate-limited request gets a `429` with 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. On a `429` all four `ratelimit-*` headers describe the limiter that rejected the request, never a different budget that still has room, and the `w=` value in `ratelimit-policy` is that limiter's own window in seconds: 60 for the API request limits, 3600 and 86400 for the hourly and daily send caps (for example `1000;w=3600`), and the limiter's own window on per-resource limits (a webhook test send limited to 4 per minute reports `4;w=60`). Agents should sleep for at least `Retry-After` seconds before retrying the same operation. The hourly and daily send caps are rolling windows rather than fixed ones, so on those two limiters `Retry-After` and `ratelimit-reset` report when the bucket will next admit a request, which can be well before the end of the current hour or day. 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" } }
```


A few `429` responses carry `Retry-After` and no `ratelimit-*` headers, because what refused the request reports only how long to wait: the signup and OAuth `slow_down` responses, the hosted MCP server, and an abuse backstop in front of the API that answers before a request reaches the rate limiters (code `rate_limit_exceeded`, `Retry-After: 60`). A `429` whose code is not `rate_limited`, `rate_limit_exceeded` or `slow_down` is a different kind of cap, such as a concurrency or attempt limit, and may carry no `Retry-After` at all; read its message. In every case, when `Retry-After` is present it is the value to wait for.

The same four `ratelimit-*` headers are on other v1 API responses too, so a client can track its budget before it runs out. On those responses they describe the API request budget the request was charged to. A `401` for a missing or invalid token reports the per-IP bucket that the request was charged to (120 per minute). 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 a fixed 5-minute window, and returns it in the `Idempotency-Key` response header. Two keyless sends with identical content to the same recipients inside one window are therefore a single send: the second sends nothing and returns the first send's result. That is `200` with `idempotent_replay: true` when the first send was accepted. When the first send failed, it is that send's stored error with its original status (for example `504` `outbound_relay_timeout`), not a new attempt.

A replay is always marked. The response carries an `Idempotent-Replayed: true` header and an `idempotency` object whose `key_source` is `explicit`, `auto_content` or `function_trigger`. The object is in `data` on a `200` and in `error.details` on a replayed error. Pass your own `Idempotency-Key` per logical send to send identical content again. See [Sends without a key](/docs/sending#sends-without-a-key).

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

Every v1 operation uses the same error envelope. New error codes are added without a version change, so treat a code you do not recognize as a generic error. See [API versioning](/docs/versioning).

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