Handling errors
Every failure returns the same JSON body, whatever went wrong.
{
"status": 422,
"type": "invalid_from_address",
"message": "The 'from' address is not a valid mailbox on a domain you own",
"request_id": "req_01J8K2P...",
"doc_url": "https://docs.epostix.com/errors/invalid_from_address",
"details": []
}
The full catalogue has a page per type. This page is about wiring it into your code.
Branch on type, never on message
type is part of the contract. message is written for people and gets reworded whenever
the wording can be improved, so matching on it produces a handler that breaks silently.
Always have a fallback branch. New types are added without a version bump, and an exhaustive match will break the first time that happens. See Versioning.
Read details when a field is at fault
details is populated when specific fields were rejected. Each entry gives you a path you can
map back onto your own form or payload.
{
"type": "validation_error",
"details": [
{"field": "from", "message": "property \"from\" is missing", "code": "required"},
{"field": "to", "message": "must contain at least one recipient", "code": "invalid"}
]
}
code is the stable part. required means the field was absent, invalid means it was
present and unacceptable.
Decide retries by group, not by status
Three behaviours, and the difference matters more than the number.
Do not retry. The request is wrong and will stay wrong. Everything in the 4xx range apart from the ones below. Fix the request and send a new one.
Wait, then retry unchanged. rate_limit_exceeded,
daily_quota_exceeded and
idempotency_in_progress all carry Retry-After in
seconds. Honour it rather than choosing your own interval.
Retry with backoff. internal_error and
service_unavailable are ours. Back off exponentially, and
cap the attempts.
Always retry sends with the same idempotency key
This is the one thing that turns a safe retry into a duplicate email if you get it wrong.
When a send fails with a 5xx or times out, you do not know whether it was processed. Retrying
with the same Idempotency-Key means a request that actually succeeded returns its original
result instead of sending again. Retrying with a fresh key sends a second email.
See Idempotency for how to derive a key that survives a restart.
Log the request id
request_id appears on every error and identifies the call in our logs. Log it alongside
your own context. It is the difference between a support conversation that takes one message
and one that takes six.
A handler worth copying
if (response.ok) return response.json();
const error = await response.json();
switch (error.type) {
case "rate_limit_exceeded":
case "daily_quota_exceeded":
case "idempotency_in_progress":
return retryAfter(response.headers.get("Retry-After"));
case "internal_error":
case "service_unavailable":
return retryWithBackoff();
case "validation_error":
return reportFields(error.details);
default:
throw new SendFailed(error.type, error.message, error.request_id);
}