Error format
Every failed request returns a JSON body with a single error object:
{
"error": {
"type": "validation_error",
"code": "VALIDATION_ERROR",
"message": "Recipient '[email protected]' is on the suppression list"
}
}
codeis the field to branch on in your code. It is stable and upper-case.typeis a broader group (auth_error,validation_error,rate_limitedand so on). Several codes can share one type.messageis meant for people. It names the field, address or limit involved, and its wording can change, so don't parse it.- Some errors also carry
docs_url, a link to the relevant part of this page. Most errors don't have one yet, so treat it as optional.
A few errors add fields of their own, listed below.
Error codes
| HTTP status | code | type | What it means | Retry? |
|---|---|---|---|---|
| 401 | AUTH_FAILED | auth_error | The API key is missing, malformed, revoked or unknown. | No. Fix the key. |
| 403 | FORBIDDEN | forbidden | The key is valid but may not do this, for example sending from a domain that isn't on the account. | No |
| 403 | INSUFFICIENT_SCOPE | permission_error | The key lacks the scope this endpoint needs. The message names the scope. | No. Use a key with that scope. |
| 403 | ACCOUNT_SUSPENDED | account_suspended | Sending is paused on the account. The body adds suspended_at. | No. Contact support. |
| 403 | CSRF_FAILED | auth_error | A browser session request that changes data was sent without X-Requested-With: XMLHttpRequest. API-key requests never get this. | No. Add the header. |
| 404 | NOT_FOUND | not_found | The resource doesn't exist or belongs to another account. | No |
| 409 | CONFLICT | conflict | The resource already exists, such as a second signup form for the same list. | No |
| 422 | VALIDATION_ERROR | validation_error | The request body is invalid, or the send breaks a rule: a suppressed recipient, an unverified domain sending to someone other than you, a field over its limit. | No. Fix the request. |
| 422 | ATTACHMENT_REFUSED | attachment_refused | An attachment is a blocked file type or failed the virus scan. Nothing was sent. | No |
| 429 | RATE_LIMIT_EXCEEDED | rate_limited | Too many requests or sends. See Rate limits. | Yes, after a pause |
| 429 | QUOTA_EXCEEDED | quota_exceeded | The monthly sending allowance is used up. | Yes, once retry-after has passed or the allowance is raised |
| 500 | INTERNAL_ERROR | internal_error | Something failed on our side. | Yes, with backoff |
| 503 | SERVICE_UNAVAILABLE | service_unavailable | A dependency is briefly unavailable. | Yes, with backoff |
| 503 | ATTACHMENT_SCAN_UNAVAILABLE | service_unavailable | The virus scanner couldn't answer, so nothing was sent. The response has retry-after. | Yes, the whole request |
A small number of internal failures report DATABASE_ERROR or REDIS_ERROR
with status 500. Treat them like INTERNAL_ERROR.
Authentication
A 401 means the request carried no usable key. Send it as
Authorization: Bearer em_live_... (or in the X-EuroMail-Api-Key header) and
check that the key still exists in the dashboard. A 403 means the key works
but lacks permission: check its scopes on the API Keys page. See
API Keys & Authentication.
Request format
A 422 comes with a message that says what to change. The most common causes
when sending:
- the
fromdomain is added but not verified, and the recipient isn't your own account email (see Sandbox mode); - the recipient is on your suppression list;
- a required field is missing, or a value is over its limit.
The Sending Emails guide lists every field.
Not found
A 404 is also what you get for another account's resource, so a correct ID
used with the wrong account's key looks the same as a wrong ID.
Rate limits
Several limits apply at once. Each one answers with 429:
| Limit | Value | Applies to |
|---|---|---|
| Per account | 50 requests per second | Every authenticated API request |
| Whole API | 500 requests per second | All traffic, before authentication |
| Send rate, first 24 hours | 5 emails a minute, 25 an hour | Sends to anyone but yourself |
| Send rate, days 2 to 7 | 15 emails a minute, 75 an hour | Sends to anyone but yourself |
| Send rate, from day 8 | 100 emails a minute, 500 an hour | Sends to anyone but yourself |
| New-account allowance | 20 recipients, refilling at 2 an hour, for the first 24 hours (1,000 once an invoice is paid) | Counted per recipient, including cc and bcc |
| Monthly allowance | Your plan's volume; 3,000 until a non-zero invoice has been paid | Counted per recipient |
Account age is counted from when the account was created. Sandbox sends (from an unverified domain to your own address) skip the send-rate limits and the new-account allowance. Each email in a batch counts on its own against the send rate.
Rate-limit headers
| Header | When it is set | Meaning |
|---|---|---|
x-ratelimit-limit | Every authenticated response, and on a 429 from the request limits | The requests-per-second limit that applies (50 per account) |
x-ratelimit-remaining | Only on a 429 from the request limits | Always 0. It is not a live countdown. |
retry-after | On a 429 from the request limits, the new-account allowance and the monthly allowance, and on ATTACHMENT_SCAN_UNAVAILABLE | Seconds to wait before trying again |
x-quota-limit, x-quota-used | On QUOTA_EXCEEDED | The monthly allowance and how much of it is used |
A 429 from the per-minute or per-hour send rate has no retry-after. The
message says which window was exceeded; wait for the next minute or hour.
Retrying safely
- Retry
429,500and503. Don't retry other4xxerrors: the same request will fail the same way. - Wait for
retry-afterwhen it is present. Otherwise back off exponentially, for example 1, 2, 4 and 8 seconds, with some random jitter. - Put an
idempotency_keyin the JSON body of every send you might retry. It makes a retry return the original email instead of sending a second one. The key must be in the body: anIdempotency-KeyHTTP header is ignored. See Idempotency. - The TypeScript SDK retries network errors,
429and5xxresponses for you (maxRetries, default 2), and the PHP SDK does when you setmax_retries. With the other SDKs, add the retry yourself. See SDKs.