Errors
You'll decide whether to trust this API by reading this list — that's what it's for. Every error is JSON with a human message. Structured failures also carry a stable type.
Envelope
One JSON object. message is always there. A structured failure adds type. A validation 422 may also include errors.
{
"message": "This API key does not have the \"messages:send\" ability. Abilities are chosen when the key is minted and cannot be widened — mint a new key that includes this ability.",
"type": "forbidden_ability",
"required_ability": "messages:send"
}Status codes
| Code | Type | Meaning | Retry | Body |
|---|---|---|---|---|
| 401 | — | Missing, bad, revoked or expired key. | stop retrying | 401 response bodyresponse body { "message": "Unauthenticated" } |
| 403 | forbidden_ability | The key lacks the ability this route requires. | stop retrying | 403 response bodyresponse body {
"message": "This API key does not have the \"messages:send\" ability. Abilities are chosen when the key is minted and cannot be widened — mint a new key that includes this ability.",
"type": "forbidden_ability",
"required_ability": "messages:send"
} |
| 403 | application_archived | The key is bound to an archived Application. No new sends. | stop retrying | 403 response bodyresponse body {
"message": "This key is bound to the Application \"legacy-mailer\", which is archived. Archived Applications accept no new sends; use a key for a live Application.",
"type": "application_archived",
"application": "legacy-mailer"
} |
| 403 | sealed_body_denied | Revealing a sealed body is owner-only and audited. | stop retrying | 403 response bodyresponse body {
"message": "This message is sealed. Revealing a sealed body is owner-only and audited, and this key was not minted by an owner — have an owner mint a read-content key, or open the message in the panel as an owner.",
"type": "sealed_body_denied",
"required_ability": "reveal_sealed_body"
} |
| 404 | — | Unknown template, template version, application, or message id. | stop retrying | 404 response bodyresponse body { "message": "No template with slug \"auth/password-resett\" on this account. Sync it from git or create it in the panel before sending against it." }
{ "message": "No template version with id \"999999\" on this account." }
{ "message": "No application with slug \"billing\" on this account. Create it in the panel, or omit \"application\" and use a key scoped to one." } |
| 409 | — | This Idempotency-Key was already used with a different body. | stop retrying | 409 response bodyresponse body { "message": "Idempotency-Key was already used with a different request body" } |
| 410 | — | The stored body is past retention. | stop retrying | 410 response bodyresponse body {
"message": "Content expired",
"completed_at": null
} |
| 413 | report_too_large | The DMARC report is over 5 MB. | stop retrying | 413 response bodyresponse body {
"message": "That report is larger than 5 MB, which no real aggregate report is.",
"type": "report_too_large"
} |
| 422 | — | A message and no type: missing or unexpected template variables, no published version, application required, or From not verified. | stop retrying | 422 response bodyresponse body { "message": "Template \"auth/password-reset\": missing variables: reset_url, expires_minutes" }
{ "message": "Template \"auth/password-reset\": unexpected variables: extra" }
{ "message": "Template has no published version" }
{ "message": "application is required for org-wide keys" }
{ "message": "From domain \"mail.example\" is not verified for this account. Verify it under Domains (TXT ownership), or send From an address on a domain you have already verified." } |
| 422 | — | Validation: a message plus errors, and no type. A cursor that is not a uuid fails here, before cursor_expired. | stop retrying | 422 response bodyresponse body {
"message": "The to field must be an address, an object with an email, or a list of either.",
"errors": {
"to": [
"The to field must be an address, an object with an email, or a list of either."
]
}
} |
| 422 | cursor_expired | The message that cursor points at was erased or aged out. Start the listing again without a cursor. | stop retrying | 422 response bodyresponse body {
"message": "That cursor no longer exists — the message it points at was erased or has aged out of retention. Start the listing again without a cursor.",
"type": "cursor_expired"
} |
| 422 | limit_exceededrecipients_per_message | Too many recipients in one message, across to, cc and bcc. No Retry-After. | stop retrying | 422 response bodyresponse body {
"message": "Too many recipients in one message: 101 of a maximum 100 across to, cc and bcc.",
"type": "limit_exceeded",
"limit": {
"dimension": "recipients_per_message",
"binding": "platform",
"lift": "Split the send into batches of 100 recipients or fewer. This ceiling is the same one the inbound SMTP shim applies.",
"limit": 100,
"used": 101
}
} |
| 422 | limit_exceededapplications | The plan's application cap on POST /v1/applications. No Retry-After. | stop retrying | 422 response bodyresponse body {
"message": "Application limit reached: 3 of 3 on Free. Upgrade your plan to create more applications.",
"type": "limit_exceeded",
"limit": {
"dimension": "applications",
"binding": "plan",
"lift": "Upgrade your plan to create more applications.",
"limit": 3,
"used": 3
}
} |
| 422 | empty_report | POST /v1/dmarc-reports with no file. | stop retrying | 422 response bodyresponse body {
"message": "Send the report as the request body, or as a file field named \"report\". Gzip, zip and plain XML are all accepted.",
"type": "empty_report"
} |
| 422 | unreadable_report | The report file cannot be read. A document type declaration is one of those refusals. | stop retrying | 422 response bodyresponse body {
"message": "Refusing a DMARC report containing a document type declaration.",
"type": "unreadable_report"
} |
| 422 | invalid_cursor | GET /v1/dmarc-reports was given an integer id, or a cursor that is not a position in the list. Start again without a cursor. | stop retrying | 422 response bodyresponse body {
"message": "That cursor is an integer id. This list is ordered by range_end, then id, and a cursor has to name both or a later page drops reports. Start the listing again without a cursor.",
"type": "invalid_cursor"
} |
| 429 | limit_exceededapi_rate | The key's minute bucket. | honour Retry-After | 429 response bodyresponse body HTTP/1.1 429 · Retry-After: 42 X-RateLimit-Limit: 60 X-RateLimit-Remaining: 0 { "message": "API rate limit exceeded: used 60 of 60 requests this minute. Wait for the next minute window, or raise the key/plan API rate limit.", "type": "limit_exceeded", "limit": { "dimension": "api_rate", "binding": "plan", "limit": 60, "used": 60, "lift": "Wait for the next minute window, or raise the key/plan API rate limit." } } |
| 429 | limit_exceededdaily_send | The plan, account or application ceiling on the account counter, or a member's own counter (binding member). Retry-After is the seconds until midnight in the account's timezone, at least 1. application_daily_send uses that same header and has no separate sample. | honour Retry-After | 429 response bodyresponse body HTTP/1.1 429 · Retry-After: 21600 { "message": "Daily send limit exceeded: plan daily send limit — used 1,000 of 1,000 recipients today. Upgrade your plan to raise the daily send cap, or wait until midnight (UTC).", "type": "limit_exceeded", "limit": { "dimension": "daily_send", "binding": "plan", "lift": "Upgrade your plan to raise the daily send cap, or wait until midnight (UTC).", "limit": 1000, "used": 1000 } } |
| 429 | limit_exceededrecipient_messages_per_hour | One address from this account during the current UTC hour. Retry-After is 600, which can still fall inside that hour. | honour Retry-After | 429 response bodyresponse body HTTP/1.1 429 · Retry-After: 600 { "message": "One recipient has already received 60 messages from this account in the last hour, of a maximum 60.", "type": "limit_exceeded", "limit": { "dimension": "recipient_messages_per_hour", "binding": "account", "lift": "This ceiling exists to catch a retry loop, not to shape normal traffic. If an address legitimately needs more, it can be raised per account.", "limit": 60, "used": 60 } } |
| 429 | content_view_quota_exceeded | This person's content reads for today. No Retry-After. The same read keeps failing until midnight in the account's timezone. | stop retrying | 429 response bodyresponse body HTTP/1.1 429 { "message": "Content view limit reached: you have opened 20 of 20 message bodies today. It resets at midnight Europe/Kyiv. Every body opened on this account is recorded in the audit log; ask an owner to raise the per-person cap if you need more.", "type": "content_view_quota_exceeded", "limit": 20, "used": 20 } |
| 429 | edge_rate_limited | Unauthenticated traffic from this address. | honour Retry-After | 429 response bodyresponse body HTTP/1.1 429 · Retry-After: 12 { "message": "Too many requests from this address. This limit is on unauthenticated traffic reaching the API, not on your account or key.", "type": "edge_rate_limited" } |
| 503 | dependency_unavailable | A dependency the request will not run without. Retry-After is 30. | honour Retry-After | 503 response bodyresponse body {
"message": "Rate limiter unavailable — refusing request rather than bypassing the cap",
"type": "dependency_unavailable",
"dependency": "redis"
} |
| 503 | body_unreadable | The ciphertext is present and cannot be decrypted. | stop retrying | 503 response bodyresponse body {
"message": "Message body cannot be decrypted — the keyring is unavailable on this server. This is a platform fault, not missing content. Contact support.",
"type": "body_unreadable",
"reason": "decrypt"
} |
| 503 | no_usable_transport | Nothing could take the message, so it was not queued. | fix the transport, then the same key | 503 response bodyresponse body {
"message": "no_usable_transport: No transport is configured for this application, or all configured transports are disabled. Add or re-enable a transport in the panel, then retry with the same idempotency key. The message was not queued.",
"type": "no_usable_transport",
"last_transport_error": "connection refused to smtp.example:587"
} |
Reports you forward to us
POST /v1/dmarc-reports takes the report file as the request body — gzip, zip or plain XML. The refusals are in the list above: 422 empty_report, 422 unreadable_report and 413 report_too_large.
Sending the same report twice is not an error: it replaces the one already held and answers 200 instead of 201, so a provider retry never doubles a number on your screen.
When to stop retrying
Decide from the type first, then from limit.dimension. The status code is shared by rows that stop and rows that retry.
Stop retrying when there is no type — 401, 404, 409, 410, and a 422 that is only a message or a validation errors object — and when the type is forbidden_ability, application_archived, sealed_body_denied, cursor_expired, empty_report, unreadable_report, report_too_large, body_unreadable or content_view_quota_exceeded. That content-view 429 sends no Retry-After. The same read keeps failing until midnight in the account's timezone. Also stop retrying a 422 limit_exceeded whose dimension is recipients_per_message or applications. Those send no Retry-After.
Honour Retry-After when the type is limit_exceeded and the dimension is api_rate, daily_send, application_daily_send or recipient_messages_per_hour, and when the type is edge_rate_limited. On POST /v1/messages, send the same Idempotency-Key. daily_send and application_daily_send set Retry-After to the seconds until midnight in the account's timezone, at least 1. A member cap uses dimension daily_send with binding member. recipient_messages_per_hour sets Retry-After to 600, and the counter is the current UTC hour, so that delay can still fall inside it. edge_rate_limited is unauthenticated traffic from this address.
A 503 dependency_unavailable sends Retry-After: 30. Retry it. A 503 no_usable_transport means the message was not queued. Fix the transport, then send again with the same key. It sends no Retry-After.