Errors
Every code the API returns, the status it comes with, and what to do about it.
A refusal is JSON with a code in it, and prose saying what to do about it. The prose is there because the thing reading the error is often a coding assistant halfway through wiring up a signup flow, and "the domain is not verified" is only actionable next to "verify it here, or send from onboarding@emails.sh while testing".
There are two body shapes
Most of the API answers with a nested object: code, which is stable and safe to switch on; message, which says what happened; and next, which says what to do about it. This is the shape you get from /v1/emails, /v1/domains, /v1/webhooks, /v1/api-keys, /v1/audiences, /v1/broadcasts, /v1/segments, /v1/automations, /v1/analytics, /v1/ips, and the tag, attribute, and subscription routes under /v1/contacts/:id.
{
"error": {
"code": "invalid_from_domain",
"message": "the domain acme.com is not verified on this workspace",
"next": "Verify it at https://emails.sh/dashboard/domains, or send from onboarding@emails.sh while you are testing."
}
}A handful of routes still answer with a flat body instead: error is the code itself as a string, and message and hint are optional siblings rather than nested. Those routes are /v1/topics, /v1/suppressions, /v1/templates, the /v1/contacts collection and item routes including /v1/contacts/duplicates, and the rate limiter, which means a 429 rate_limited is flat whichever endpoint produced it.
{
"error": "template_not_published",
"message": "Template welcome has no published version, so there is nothing to send.",
"hint": "Publish one with POST /v1/templates/{id}/publish."
}Parse tolerantly. Read error, and if it is an object take error.code, and if it is a string take it as the code. Five lines once in your client is cheaper than discovering the difference in production, and the codes themselves are the same vocabulary either way.
interface Refusal {
code: string;
message: string;
next?: string;
}
// Both shapes, one function. error is either { code, message, next } or the
// code as a bare string with message and hint alongside it.
export function readRefusal(body: unknown, status: number): Refusal {
const b = (body ?? {}) as Record<string, unknown>;
const e = b.error;
if (e && typeof e === 'object') {
const nested = e as Record<string, unknown>;
return {
code: String(nested.code ?? 'internal_error'),
message: String(nested.message ?? `HTTP ${status}`),
next: typeof nested.next === 'string' ? nested.next : undefined
};
}
return {
code: typeof e === 'string' ? e : 'internal_error',
message: typeof b.message === 'string' ? b.message : `HTTP ${status}`,
next: typeof b.hint === 'string' ? b.hint : undefined
};
}Switch on the code. Do not match on message, next, or hint: the prose is deliberately improved over time, and code that greps it breaks when we make it clearer. Both SDKs do the normalising above for you and put the result on the error they throw, as code, message, and nextStep (next_step in Python).
The catalogue
| Code | Status | What to do |
|---|---|---|
unauthorized | 401 | No key, or a key that is wrong, revoked, or from another workspace. Keys start with esh_ and are made at https://emails.sh/dashboard/api-keys. |
insufficient_scope | 403 | The key is real but was not given this permission. Use a full-access key, or make one with the scope. |
sending_locked | 403 | The workspace owner has never confirmed their email address. Nothing sends until they do, and no card is involved. |
workspace_paused | 403 | Sending is paused because the bounce or complaint rate crossed a threshold. Not retryable. See https://emails.sh/dashboard/activity. |
invalid_json | 400 | The body did not parse. Check the Content-Type header and the quoting. |
invalid_request | 400 | The body parsed but was not an object, or a field was the wrong shape. The message names it. |
missing_from | 400 | No from address. |
missing_to | 400 | No recipients. |
missing_subject | 400 | No subject. |
missing_body | 400 | Neither html nor text, and no template. |
invalid_from | 400 | The from address did not parse as an address or as "Name <address>". |
invalid_recipient | 400 | One of the recipients is not a valid address. The message names it. |
too_many_recipients | 400 | More than 50 addresses across to, cc, AND bcc. Send one email per recipient through POST /v1/emails/batch. |
mixed_test_and_real_recipients | 400 | The recipients mix a reserved test address such as delivered@emails.sh with a real one. Nothing was sent to anybody. A send is either a test or it is real. |
invalid_template | 400 | template was given but is not { id, variables? }, or names a template that does not exist. |
reserved_header | 400 | A header the send writes itself was supplied. Message-ID, Date, From, To, Cc, Bcc, Reply-To, Subject, and the DKIM signature are ours. In-Reply-To and References are not on the list: set those to thread a conversation. |
invalid_header_name | 400 | A header name is not a valid token. |
invalid_attachment | 400 | An attachment is missing filename or content_base64, or the base64 did not decode. |
invalid_from_domain | 422 | The from domain is not verified on this workspace. Verify it at https://emails.sh/dashboard/domains, or send from onboarding@emails.sh while testing. |
domain_not_verified | 422 | The domain is added but its DNS records have not resolved yet. Call POST /v1/domains/:id/verify to see which are missing. |
address_receive_only | 422 | That address can receive mail but was never allowed to send it. |
address_paused | 422 | That sending address is paused. Resume it, or send from another address on the same domain. |
sandbox_unavailable | 422 | The shared onboarding@emails.sh sender could not be used for this workspace. |
sandbox_recipient_not_allowed | 422 | onboarding@emails.sh only reaches addresses belonging to this workspace. Verify a domain to reach anyone else. |
owner_email_unverified | 422 | The workspace owner has not confirmed their address, so even the sandbox sender is closed. |
recipient_suppressed | 422 | The address bounced, was marked as spam, or unsubscribed. The block is deliberate. Do not retry. Review it at https://emails.sh/dashboard/suppressions. |
recipient_blocked_by_policy | 422 | A rule on this workspace forbids sending to that recipient. |
topic_not_found | 404 | The topic key or id on the send does not exist on this workspace. |
topic_opt_out | 422 | The recipient has switched that topic off. Nothing was sent, and that is the feature. |
attachments_too_large | 413 | Attachments total more than 40 MB after base64 encoding. Host the file and link to it instead. |
message_too_large | 413 | The attachments each fit but the assembled message is over 40 MB once headers and both body parts are counted. Drop an attachment or link to it instead. |
send_at_invalid | 400 | send_at is not a time we can parse confidently. Use ISO 8601 with a Z offset, a relative offset such as "in 1 min", or a clock time such as "tomorrow at 9am", which is read as UTC. |
send_at_in_past | 400 | send_at is in the past. Omit it to send now. |
send_at_too_far | 400 | send_at is more than 30 days out. |
idempotency_key_reused | 409 | The same idempotency_key was used within 24 hours with a different body. Use a fresh key for a different message. |
idempotency_in_flight | 409 | A send with that idempotency_key is still being processed. Retry the identical request in a second or two. |
empty_batch | 400 | POST /v1/emails/batch was given no emails. |
batch_too_large | 400 | More than 100 emails in one batch call. Split it into chunks of 100. |
quota_exhausted | 429 | The monthly or daily send allowance is used up. The next line carries the reset time. Upgrading clears it. |
spend_cap_reached | 429 | Metered sending stopped at the spend cap set on the workspace. Raise it in billing. |
address_daily_cap_reached | 429 | The daily cap on that sending address is used up. |
duplicate_content_burst | 429 | The same body has already gone to a large number of distinct recipients today. Transactional sends carrying html are exempt. |
velocity_spike | 429 | The workspace is sending far faster than its recent average and is throttled. Ramp up gradually. |
rate_limited | 429 | 600 requests a minute per key, which is 10 a second. 30 a minute per IP without one. retry-after carries the seconds. |
not_found | 404 | No object with that id on this workspace. Ids from another workspace read as missing. |
internal_error | 500 | Ours. Retry once with the same idempotency_key, and if it repeats, quote the x-request-id to support. |
What to retry
- 4xx other than 429
- Never retry unchanged. The request was wrong and it will be wrong the second time. Fix the body or the key.
- 429
- Wait the number of seconds in retry-after, then send once. Backing off exponentially on top of that is fine; retrying in a tight loop is what gets a key throttled harder.
- 5xx
- Retry with backoff, and pass
idempotency_keyso a retry that crossed with a success does not send twice. - A timeout with no response
- The send may or may not have happened. Retry with the same
idempotency_key: that is exactly what it is for.
The two you will actually hit
invalid_from_domain means the from domain is not verified on this workspace. Either verify it at https://emails.sh/dashboard/domains, or send from onboarding@emails.sh while you are still testing. Nothing else clears it, and retrying will not.
sending_locked means nobody has confirmed the workspace owner's email address. It is the anti-abuse gate that stops a fresh signup being a free relay, and there is no card involved. Open the link in the confirmation email and it lifts immediately.