The error response envelope, every error code, and what to do about each
Every error response from the Send API, regardless of endpoint, has the same shape and a non-2xx status.
{
"error": {
"code": "validation_error",
"message": "Validation failed",
"details": [
{ "field": "to[0]", "message": "Invalid email in 'to' field: not-an-address" }
]
}
}| Field | Type | Description |
|---|---|---|
error.code | string | Machine-readable code from the table below. Branch on this, not on message. |
error.message | string | Human-readable explanation. Safe to log and show to an operator; wording may change. |
error.details | object[] | Present on validation_error when individual fields are at fault. Each has field (a JSON path such as from.email or attachments[1].contentType) and message. |
Some errors carry extra fields next to code and message when they help you act: monthly_limit_reached includes limit, used, resetsAt, and upgradeUrl; probation_limit_reached includes limit, window, metric, and resetsAt; recipient_unsubscribed includes unsubscribedRecipients; sender_not_verified includes help; a suppression not_found or conflict includes email. Treat any field other than code, message, and details as optional.
Success responses never have a top-level error key, so if ("error" in body) is a reliable check.
| Code | HTTP status | Meaning | What to do |
|---|---|---|---|
validation_error | 400 | The request is well-formed JSON but a field is missing, malformed, unknown, or fails a rule (recipient suppressed, domain already exists, batch too large, and so on). | Read details, fix the request. Do not retry unchanged. |
invalid_json | 400 | The body is not valid JSON, or is not a JSON object. | Fix serialization. Check Content-Type: application/json. |
unauthorized | 401 | No API key, or the key is unknown, revoked, or expired. | Check the Authorization header. See Authentication. |
forbidden | 403 | The key is valid but lacks the required scope or is not enabled for the Send service. | Use a key created in the send.dev dashboard. |
recipient_unsubscribed | 400 | A recipient has unsubscribed and the email is category: "marketing" (or your account blocks all mail to unsubscribed addresses). unsubscribedRecipients lists them. | Remove the recipient. If the email is genuinely transactional, send it without category: "marketing". |
sender_not_verified | 403 | from.email is not on a verified and approved domain or sender, the domain is rejected or held for review, or from.name resembles a protected brand. | Verify the domain or sender, or change the display name. See Domains and Senders. |
sending_paused | 403 | Sending for the whole account is paused, usually because bounce or complaint rates tripped a safeguard. | Nothing you send will go out until it is resolved. Contact support. |
not_found | 404 | No email, template, domain, sender, or suppression with that id on your account. | Check the id. Ids from other accounts return not_found, not forbidden. |
conflict | 409 | The request clashes with existing state: a domain or template name already exists, a sender is already verified, a template is still in use by queued emails, or a suppression is permanent. | Read message; usually there is nothing to retry. |
rate_limited | 429 | Too many send requests this minute for this key. | Wait Retry-After seconds and retry. See Rate limits. |
probation_limit_reached | 429 | Your account is in its probation period and has reached its hourly or daily send cap, or its cap on distinct recipient domains per hour. limit, window (hour or day), metric (sends or recipient_domains), and resetsAt say which. | Wait until resetsAt (also Retry-After) and resume. Nothing was queued. |
monthly_limit_reached | 429 | Your plan's monthly email allowance is used up. | Upgrade in the dashboard or wait for the month to reset. Retrying sooner will not help. |
service_unavailable | 503 | A dependency send.dev needs (its database or the mail provider) did not answer. Nothing was changed. | Retry after a few seconds. |
internal_error | 500 | Something failed on send.dev's side. | Retry with backoff. If it persists, contact support with the time and request details. |
| Code | Retry the same request? |
|---|---|
rate_limited | Yes, after Retry-After seconds. |
probation_limit_reached | Yes, after Retry-After seconds (that is, at resetsAt). Usually minutes to hours, not seconds. |
service_unavailable, internal_error | Yes, with exponential backoff, a few times. |
monthly_limit_reached, sending_paused | Not until the underlying condition changes. |
| Everything else | No. The request needs to change. |
A send request that returned an error was not queued, so retrying can never produce a duplicate email. Only a 202 means an email will be sent.
In Send Batch, each failed item inside results carries the same envelope, just without an HTTP status of its own:
{ "error": { "code": "sender_not_verified", "message": "Sender not verified. ..." } }Whole-batch failures (bad emails array, paused account, limits) use the envelope at the top level with a non-2xx status.
const res = await fetch("https://api.do.dev/v1/send/emails/send", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SEND_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(email),
});
if (!res.ok) {
const { error } = await res.json();
switch (error.code) {
case "rate_limited":
// wait Retry-After seconds, then retry
break;
case "validation_error":
// log error.details and fix the request
break;
case "probation_limit_reached":
// wait until error.resetsAt, then resume
break;
case "recipient_unsubscribed":
// drop error.unsubscribedRecipients from your list
break;
case "sender_not_verified":
case "sending_paused":
case "monthly_limit_reached":
// alert an operator; retrying will not help
break;
default:
// internal_error: retry with backoff
}
}