Errors
What each status means here, what causes it, and whether retrying will help.
The shape of an error
Errors use the same envelope as successes, with success: false. Validation failures add an errors object keyed by field name.
{
"message": "Validation failed",
"code": 422,
"success": false,
"errors": {
"to": [
"The to field must be a valid email address."
]
}
}401 — Invalid API key
The key is missing, unknown, revoked, expired or deleted. All five answer identically, with the same body.
That is deliberate. Distinguishing "expired" from "unknown" would confirm that a key exists, turning the endpoint into a way to test which key prefixes are real.
Check, in order:
- The header is
Authorization: Bearer mk_live_…, with the whole key — truncation while copying is the most common cause. - The key has not expired or been revoked in the dashboard.
- The member who created it still has access to the organization. A key dies with its creator's membership — see Authentication.
403 — Valid key, not allowed
Three different causes, and the message distinguishes them.
Missing scope. The message names the scope it wanted: "This API key is missing the required scope: messages:send." Scopes cannot be edited after creation — create a replacement key and delete the old one.
IP not allowlisted. The key is restricted to particular addresses and your request came from elsewhere. Note that an allowlist which is enabled but empty refuses everything.
The endpoint does not accept API keys. "This endpoint cannot be called with an API key." Some operations are deliberately dashboard-only — creating and revoking API keys, managing team members, billing checkout. No key reaches them however broadly scoped.
You may also see a 403 when a valid key acts for an organization whose member roles do not permit the action. Scopes cap a key; they do not widen it past what its owner may do.
404 — Not found
No such resource in this organization. A real id belonging to somebody else returns 404 rather than 403, on purpose: telling you the resource exists but is not yours is itself a disclosure.
422 — Validation failed
The request was understood; the values were not acceptable. Read errors for the per-field detail.
Do not retry a 422 unchanged. It will fail identically and spend your rate budget.
The two you will actually hit when sending:
- "The from address must be a verified Sender belonging to your organization." — a verified domain is not enough. Add the address as a sender.
- Recipient is suppressed — they have bounced, complained or unsubscribed. An
email.droppedevent is recorded. See Suppressions.
429 — Rate limited
Either too many requests, or the organization has spent its sending allowance. The response carries:
Retry-After— seconds to wait. Honour it rather than guessing.X-RateLimit-LimitandX-RateLimit-Remaining.
Sending limits count recipients, not messages — a batch of 200 uses 200 of the allowance. New sending domains are limited while they warm up, and the ceiling rises over days as delivery stays healthy.
if response.status_code == 429:
wait = int(response.headers.get("Retry-After", 2 ** attempt))
time.sleep(wait)
# retry, up to a bounded number of attempts500 — Our fault
Safe to retry with exponential backoff. If it persists for one specific request while others succeed, the request is probably triggering it — send us the message field and roughly when it happened.
Two things that are not errors
A 200 from a send means accepted, not delivered. Delivery is asynchronous. Follow delivery events to learn what actually happened.
A batch with failures still returns 200. Recipients are processed independently, so check the failed count and the per-recipient results — code that branches on the status code alone will silently drop mail it believes it sent.