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_..." } }
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. |
rate_limited | 429 | Per-organization rate limit exceeded (default: 120 requests / 60 seconds, sliding window). | Honor the Retry-After response header before retrying. Implement client-side back-off. |
service_unavailable | 503 | A backing dependency is temporarily unreachable or misconfigured on our side. | Retry with exponential back-off. Do not exceed 5 attempts; persist failures past that should escalate. |
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. |
Rate-limit responses
429 responses carry:
Retry-After: 30The integer is seconds. Agents should sleep for at least that many seconds before retrying the same operation. The body matches the standard envelope:
{ "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.