# 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

An error response:

```text
{
  "statusCode": 422,
  "name": "missing_required_field",
  "message": "Missing required field: subject."
}
```

`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.

A validation error with field detail:

```text
{
  "statusCode": 400,
  "name": "validation_error",
  "message": "Unknown fields: is_admin, priority.",
  "errors": [
    { "path": "is_admin", "message": "Unknown field: is_admin." },
    { "path": "priority", "message": "Unknown field: priority." }
  ]
}
```

> 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_error` is `400` when a field is wrong, `403` when the request is well-formed and not allowed — an unverified sending domain, a missing `User-Agent`, a policy restriction — and `422` when the body is fine and the resource itself refuses it, such as a plan limit reached.
- `restricted_api_key` is `401` when a `sending_access` key is used on an endpoint that is not sending, and `403` when 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.

`missing_api_key` and `invalid_api_key` are both `401` and differ in what you sent. The first means no usable `Authorization: Bearer` header arrived; the second means a token did arrive and matches nothing live, so check the value rather than your environment.

`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 Authorization header, or one that is not `Bearer &lt;token>` |
| `invalid_api_key` | `401` | a Bearer token that resolves to no live key or OAuth access token |
| `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 |
| `receiving_mx_in_use` | `409` | turning receiving on (or moving it) at a name whose MX already delivers to another mailbox provider, without `confirm_replace_mx` |
| `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 |
| `marketing_quota_exceeded` | `429` | team marketing quota (0149): the broadcast meter, never the transactional one |
| `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.

An OAuth flow error:

```text
{
  "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

`rate_limit_exceeded` and `503` are worth retrying, with backoff — a `rate_limit_exceeded` tells you exactly how long to wait in its `retry-after` header. The quota `429`s — `daily_quota_exceeded`, `monthly_quota_exceeded` and `marketing_quota_exceeded` — carry no `retry-after` and do not clear until the quota resets or the plan changes. `500` is worth one retry with an idempotency key, and worth reporting if it persists. Everything in the `4xx` range other than `rate_limit_exceeded` describes something about the request that will not change on its own; retrying it unchanged will fail again.
