Errors
Every failure answers with the same three fields and a name from a closed list. Match on the name, not on the message.
The shape
{
"statusCode": 422,
"name": "missing_required_field",
"message": "subject is required."
}statusCode repeats the HTTP status, name is the stable identifier to branch on, and message is a sentence for a human. Messages are written to be readable and may be reworded; names are part of the contract and are not.
Validation failures add an errors array naming each field that failed. It is additive — code that ignores it keeps working.
{
"statusCode": 400,
"name": "validation_error",
"message": "The request body is invalid.",
"errors": [
{ "path": "to.0", "message": "Not a valid email address." },
{ "path": "subject", "message": "Must not be empty." }
]
}One error carries a fourth field: a missing User-Agent answers with code: 1010. No other response has it, so it can be matched exactly.
The vocabulary
Two names appear more than once, with different statuses, because the situation genuinely differs — the status is what tells them apart, so branch on both.
validation_erroris400when a field is wrong,403when the request is well-formed and not allowed — an unverified sending domain, a missingUser-Agent, a policy restriction — and422when the body is fine and the resource itself refuses it, such as a plan limit reached.restricted_api_keyis401when asending_accesskey is used on an endpoint that is not sending, and403when the key has been revoked. The first is the wrong key for the job; the second is a key that no longer works at all.
not_implemented at 501 is not a mistake on your side either. It means the route is ours and reserved for a later phase — it exists so an SDK gets a straight answer rather than a 404 that would imply something about the id it asked for.
| name | Status | Means |
|---|---|---|
invalid_idempotency_key | 400 | key length outside 1-256 |
validation_error | 400 | field-level validation failed (body, query, headers) |
missing_api_key | 401 | no/malformed Authorization, or hash not found |
restricted_api_key | 401 | sending_access key used on a non-sending endpoint |
email_above_quota | 403 | email content unavailable because the team is over quota |
invalid_permission | 403 | actor lacks a required scope (unverified user, future scoped keys/OAuth) |
restricted_api_key | 403 | key status = revoked (not active) |
suspended_api_key | 403 | key status = suspended or team risk_state = suspended |
validation_error | 403 | domain not verified, from-domain not allowed for this key, sandbox restriction, missing User-Agent (code 1010), policy restriction |
not_found | 404 | resource not found or not in this team (no existence leak), unknown route |
method_not_allowed | 405 | known path, wrong method |
concurrent_idempotent_requests | 409 | same key in flight |
invalid_idempotent_request | 409 | same key, different fingerprint |
resource_locked | 409 | scheduled email already dispatched / resource being updated |
validation_error | 422 | the body is well-formed but the resource refuses it: a plan limit reached, an unroutable webhook endpoint, a replay against a disabled webhook (14 §2) |
invalid_attachment | 422 | neither content nor path, both, fetch blocked, too large, bad type |
invalid_parameter | 422 | path/query parameter malformed (not a UUID, bad enum) |
missing_required_field | 422 | body missing from/to/subject/name/... |
missing_required_parameter | 422 | required query/path parameter absent |
daily_quota_exceeded | 429 | team daily quota |
monthly_quota_exceeded | 429 | team monthly quota |
rate_limit_exceeded | 429 | 10 rps team limit |
application_error | 500 | unexpected error (request id in message) |
not_implemented | 501 | the route exists and is reserved for a later phase |
service_unavailable | 503 | dependency down, global send disabled, readiness failed |
The OAuth flow's own vocabulary
The OAuth flow's routes answer in the shape OAuth libraries already parse instead: POST /oauth/token and POST /oauth/revoke use RFC 6749 §5.2's, and /oauth/register and its management routes RFC 7591 §3.2.2's. Branch on error; the description is for a human.
{
"error": "invalid_grant",
"error_description": "The authorization code has expired."
}| error | Status | Means |
|---|---|---|
invalid_request | 400 | A parameter is missing, repeated or malformed. The same name at 429 means the client or address is over its limit. |
invalid_client | 401 | The client is unknown or disabled. |
invalid_grant | 400 | The code or refresh token is expired, revoked, already used, issued to another client, or the verifier or redirect URI does not match. |
unauthorized_client | 400 | The client may not use this grant type. |
unsupported_grant_type | 400 | The grant type is neither authorization_code nor refresh_token. |
invalid_scope | 400 | A scope is unknown, or wider than the grant. |
invalid_client_metadata | 400 | Registration: a metadata field was refused. |
invalid_redirect_uri | 400 | Registration: a redirect URI was refused — not https or loopback, or malformed. |
invalid_token | 401 | Client management: the registration access token is missing, malformed or unknown. |
One refusal in the flow is not a body at all. GET /oauth/authorize with an unknown client or an unregistered redirect URI answers 400 with an error page and never redirects; every other authorize failure redirects back to the client with error and state. And an invalid_token from client management also carries WWW-Authenticate: Bearer error="invalid_token".
Every other route — /oauth/grants included, and any endpoint called with an OAuth access token — answers with the Rasket error body above.
What to retry
429 and 503 are worth retrying, with backoff — a 429 tells you exactly how long to wait in its retry-after header. 500 is worth one retry with an idempotency key, and worth reporting if it persists. Everything in the 4xx range other than 429 describes something about the request that will not change on its own; retrying it unchanged will fail again.