---
title: Errors
description: The problem+json body, every error code and what it means, and which failures are worth retrying.
sidebar:
  icon: triangle-alert
---

Every failure answers with `application/problem+json` ([RFC 9457](https://www.rfc-editor.org/rfc/rfc9457)):

```json
{
  "type": "https://docs.aiinbx.com/problems/invalid_request",
  "title": "Unprocessable Content",
  "status": 422,
  "detail": "Request body is invalid",
  "code": "invalid_request",
  "request_id": "8f2c4a1b-...",
  "issues": [
    { "path": "to.0", "message": "Invalid email address" },
    {
      "path": "subject",
      "message": "String must contain at most 998 character(s)"
    }
  ]
}
```

| Prop | Type | Default | Description |
| - | - | - | - |
| `code` | `string` | - | The stable, machine-readable identifier. Branch on this, never on `detail`. |
| `status` | `number` | - | The HTTP status, repeated in the body. |
| `title` | `string` | - | The standard reason phrase for the status. |
| `detail` | `string` | - | A human-readable explanation. Wording may change — don't parse it. |
| `request_id` | `string` | - | Also on the X-Request-ID header. Quote it when reporting a problem. |
| `type` | `string` | - | A URI for the code — it resolves to this page. |
| `issues?` | `object[]` | - | Field-level validation failures: a `path` and a `message` each. |

## Handling them

```ts TypeScript
import { APIError, NotFoundError, RateLimitError } from "aiinbx"

try {
  await aiinbx.emails.send(payload)
} catch (error) {
  if (error instanceof NotFoundError) return null
  if (error instanceof RateLimitError) return scheduleRetry()

  if (error instanceof APIError) {
    console.error(error.status, error.code, error.requestId)
    if (error.code === "invalid_request") {
      console.error(error.body.issues)
    }
  }

  throw error
}
```

```python Python
from aiinbx import APIStatusError

try:
    client.emails.send(payload)
except APIStatusError as error:
    print(error.status_code, error.request_id)
    print(error.body["code"], error.body.get("issues"))
```

## Retry or not

| Status | Meaning                             | Retry?                   |
| ------ | ----------------------------------- | ------------------------ |
| `400`  | Malformed request                   | No — fix the request     |
| `401`  | Missing or invalid key              | No                       |
| `403`  | Key lacks the scope                 | No                       |
| `404`  | No such resource                    | No                       |
| `409`  | Conflicts with current state        | No — read `code` first   |
| `413`  | Payload too large                   | No                       |
| `415`  | Body isn't JSON                     | No                       |
| `422`  | Failed validation                   | No — read `issues`       |
| `429`  | Budget spent or provider throttling | Yes, after `Retry-After` |
| `500`  | Something broke on our side         | Yes, with backoff        |
| `502`  | Upstream mail provider failed       | Yes, with backoff        |
| `503`  | Temporarily unavailable             | Yes, with backoff        |

Both SDKs already retry network failures and `408`, `409`, `429`, and `5xx` twice with bounded exponential backoff, honoring `Retry-After`. Tune with `maxRetries` / `max_retries`.

:::warning
When you retry a send yourself, reuse the same [`Idempotency-Key`](/reference/conventions#idempotency). Without one, a retry after a timeout can send the message a second time — the request may well have succeeded before the connection dropped.
:::

## Codes

Grouped by what actually went wrong.

### Authentication and authorization

#### unauthorized

`401` — no `Authorization` header, a malformed one, or a deleted key. The response carries `WWW-Authenticate: Bearer`. Check that the key is being read from the environment and hasn't been deleted. See [Authentication](/authentication).

#### forbidden

`403` — the key is valid but its [scope](/authentication#scopes) doesn't cover this operation. A `read` key can't send; a `sending` key can't change configuration. Retrying won't help; use a key with the right scope.

#### shared_apps_disabled

`403` — the workspace requires its own OAuth app, and a mailbox connection was attempted without an `app_id`. Create an [OAuth app](/guides/mailboxes#your-own-oauth-app) and pass its ID.

### Request format

#### unsupported_media_type

`415` — an operation that accepts a JSON body was called without `Content-Type: application/json`. Bodyless operations do not require this header.

#### invalid_json

`400` — the `Content-Type` said JSON but the body didn't parse. Usually truncation or a stray trailing comma.

#### invalid_request

`422` — the body parsed but failed validation. `issues` names each offending field with a `path` and a `message`. Also `400` from a few operations that validate outside the body schema.

#### invalid_query

`400` — a query parameter was rejected: an out-of-range `limit`, an unknown `status`, a malformed address filter. `issues` names the parameter.

#### invalid_cursor

`400` — the `cursor` isn't a cursor this endpoint issued. Cursors aren't portable between endpoints; use the `next_cursor` from the same list. See [Pagination](/reference/conventions#pagination).

#### invalid_thread_id

`400` — the `thread_id` isn't a well-formed thread ID (`thr_` plus 32 hex characters).

#### invalid_idempotency_key

`400` — the `Idempotency-Key` header is empty or longer than 255 characters.

#### method_not_allowed

`405` — the path exists but not for this HTTP method.

#### no_body

`400` — a request that requires a body arrived without one.

### Content

#### no_recipients

`400` — nothing deliverable is left. Usually every recipient was [suppressed](/guides/suppressions); the send response's `suppressed` array on a partial send tells the same story less severely.

#### sender_unknown

`400` — the `from` address is on no verified [domain](/guides/domains) and no connected [mailbox](/guides/mailboxes) of the workspace. Check the domain is verified and the mailbox is `active`.

#### too_many_recipients

`400` — more than 100 addresses in `to`, `cc`, `bcc`, or `reply_to`. Split the send.

#### attachments_too_large

`413` — the attachments exceed the per-message limit. Up to 20 attachments; large files belong behind a link.

### State conflicts

#### not_found

`404` — no such resource in this workspace. Also what you get for an ID of the wrong type, since IDs are type-tagged, and for a `space_id` or `space` that names no space of the workspace.

#### thread_not_found

`404` — the thread doesn't exist or isn't yours. Check the `thread_id` came from this workspace.

#### thread_empty

`409` — a forward was asked of a thread with nothing sent or received on it yet — only scheduled or canceled messages. There is nothing to forward until a message has left or arrived.

#### not_prepared

`404` — the attachment has no prepared text. Either its type is unsupported, extraction failed, or preparation hasn't finished. The original is still downloadable — see [Attachments](/guides/attachments#prepared-text).

#### not_scheduled

`409` — reschedule or cancel was called on a message that isn't `scheduled` any more. It has already begun sending. See [Scheduling](/guides/scheduling).

#### idempotency_key_reused

`409` — this `Idempotency-Key` was used with a different body. Either the key is being reused across genuinely different sends, or the body isn't as deterministic as you think (a timestamp in the subject will do it). See [Idempotency](/reference/conventions#idempotency).

#### domain_unverified

`409` — sending from a domain whose DKIM identity is not verified. Run `domains.verify`, then `domains.diagnostics` if it still won't pass. See [Domains](/guides/domains).

#### domain_taken

`409` — the workspace already has this domain. `saas.com` and `*.saas.com` are one identity, so either one takes the other. Another workspace having the name is not an error: whoever's DKIM record DNS carries sends as it — see [Moving a domain between workspaces](/guides/domains#moving-a-domain-between-workspaces).

#### reserved_subdomain

`409` — the name is `bounces.` under a wildcard, which is where that wildcard's return path lives. Pick another label.

#### reserved_domain

`409` — the name is under `aiinbx.app`. Every workspace is given one name there, `<slug>.aiinbx.app`, and nobody adds another — see [Your provided domain](/guides/domains#your-provided-domain).

#### domain_provided

`409` — the domain is the one the workspace was given. It came with the workspace and cannot be deleted; it goes when the workspace does.

#### slug_taken

`409` — the OAuth app `slug` is claimed. Slugs are globally unique because they form the hosted connect URL.

#### external_id_taken

`409` — another space in the workspace already has that `external_id`. One space per customer is the point of the field; find the existing one with `GET /spaces?external_id=`. See [Spaces](/guides/spaces#finding-a-space-by-your-id).

#### mailbox_inactive

`409` — the mailbox isn't `active`. Check its `state`: `needs_reauth` means the customer has to reconnect, `disconnected` means it's gone. See [Mailboxes](/guides/mailboxes#mailbox-state).

#### mailbox_space_conflict

`409` — the mailbox is already connected in another space. A mailbox's space is fixed when it is first connected; reconnect it through a URL minted for that same space.

#### invalid_domain

`400` — the domain name isn't a valid registrable domain.

#### unknown_region

`400` — `region` isn't one of `eu-central-1` or `us-east-1`.

### OAuth flow

#### invalid_state

`400` — the OAuth callback's state parameter didn't validate. Usually a link opened out of context or tampered with; start the [connect flow](/guides/mailboxes#connecting-a-mailbox) again.

#### expired_state

`400` — the connection URL expired. They're short-lived and single-use — redirect the customer, don't email them the link.

#### invalid_tenant

`400` — the Microsoft tenant on the OAuth app doesn't match the account that authorized. Check the `tenant` restriction.

#### invalid_return_url

`400` — a `return_urls` entry on an OAuth app isn't one a connect link could return to: not an absolute URL, not `https` (plain `http` is allowed on `localhost`), carrying credentials, or carrying a `#fragment`. At most 20 per app.

#### return_url_not_registered

`400` — a [connect link](/guides/mailboxes#connect-link) was opened with a `return_to` the app hasn't registered. Add it to the app's `return_urls` — matched on origin and path, so one entry covers every query string. The server-side `mailboxes.connect` call isn't held to the list, since it's authenticated.

### Upstream and server

#### too_many_requests

`429` — the workspace's [request budget](/reference/conventions#request-budgets) is spent, too much of it is in flight, or the platform is at capacity; `detail` says which. The response carries `Retry-After` in seconds and the `RateLimit-*` headers. Wait that long and retry — both SDKs do so automatically. Sustained `429`s mean the integration is faster than the budget: pace it from `RateLimit-Remaining`, or ask for a larger budget.

#### rate_limited

`429` — an upstream mail provider is throttling. Not the workspace's budget: that is [`too_many_requests`](#too_many_requests). The response carries `Retry-After` in seconds. Wait that long and retry — both SDKs do so automatically.

#### send_failed

`502` — the provider rejected the message at hand-off. `detail` carries what it said. Retryable, but if it repeats for the same message the content or sender is the problem.

#### provider_error

`502` — a mail provider (Gmail, Microsoft Graph, the sending backend) failed. Retry with backoff.

#### internal_error

`500` — something broke on our side. The `request_id` is already logged; quote it if it persists. Retry with backoff.

## Debugging

1. **Read `code`, not `detail`**

    `code` is stable. `detail` is prose and may be reworded.

2. **For 422, read `issues`**

    Each entry has a `path` into your request body and the reason it failed.

3. **Keep `request_id`**

    Log it beside your own trace ID — or send your own via
    [`X-Request-ID`](/reference/conventions#request-ids) so they're the same
    value.

4. **Check the scope for a 403**

    A `403` after a working `200` on a read endpoint is almost always a key
    scoped too narrowly.
