Errors
Every error type with the HTTP status it arrives with, what causes it, and whether retrying the request will help.
Updated
Every failure returns the same shape:
{
"error": {
"type": "domain_not_verified",
"message": "The domain `mail.yourdomain.com` is not verified yet. Finish its DNS setup before sending."
}
}Branch on type, never on message. Types are stable; messages are written
for humans and may be reworded.
Every type
| Type | HTTP | Cause | Retry? |
|---|---|---|---|
validation_error | 422 | Malformed payload - bad address, missing subject, no body. | No - fix the request. |
authentication_error | 401 | Key missing, wrong, or revoked. | No. |
permission_denied | 403 | Account suspended, subscription cancelled, or a sending key on a management endpoint. | No. |
domain_not_verified | 403 | The from domain isn't on your account, or hasn't passed DNS checks. | After verifying. |
email_not_verified | 403 | Your account's own email address hasn't been confirmed yet. | After clicking the link in your confirmation email. |
suppressed_recipient | 422 | A recipient is on your suppression list. | No - that's the point. |
quota_exceeded | 402 | Month's allowance used. | Next period, or after an upgrade. |
trial_expired | 402 | Free trial ended. | After a plan is active. |
rate_limit_exceeded | 429 | Too many requests per second. | Yes, after Retry-After. |
not_found | 404 | No such message, domain or key on this account. | No. |
internal_error | 500 | Something failed on our side. | Yes, with backoff. |
Handling them
Most types mean stop and fix something. Only two are worth retrying automatically:
const data = await res.json();
if (!res.ok) {
switch (data.error.type) {
// The address is dead or complained. Skip it - this isn't a failure.
case "suppressed_recipient":
return;
// Wait exactly as long as we're told, then try again.
case "rate_limit_exceeded":
await new Promise((r) =>
setTimeout(r, Number(res.headers.get("retry-after") ?? 1) * 1000),
);
return retry();
// Transient on our side.
case "internal_error":
return retryWithBackoff();
// Out of allowance - alert someone rather than looping.
case "quota_exceeded":
case "trial_expired":
await notifyOps(data.error.message);
return;
default:
throw new Error(`${data.error.type}: ${data.error.message}`);
}
}Retrying safely
Any retry risks sending twice if the first request actually succeeded but the
response was lost. Send an Idempotency-Key and that can't happen - see
Idempotency.
Seeing failures
Every authenticated request is logged, with its status and error type:

Filter by 4xx or 5xx to find what's failing.