Skip to content
AI Inbx
Esc
navigateopen⌘Jpreview
On this page

Errors

The problem+json body, every error code and what it means, and which failures are worth retrying.

Every failure answers with application/problem+json (RFC 9457):

{
  "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)"
    }
  ]
}
PropType
codestring

The stable, machine-readable identifier. Branch on this, never on `detail`.

Typestring
statusnumber

The HTTP status, repeated in the body.

Typenumber
titlestring

The standard reason phrase for the status.

Typestring
detailstring

A human-readable explanation. Wording may change — don't parse it.

Typestring
request_idstring

Also on the X-Request-ID header. Quote it when reporting a problem.

Typestring
typestring

A URI for the code — it resolves to this page.

Typestring
issues?object[]

Field-level validation failures: a `path` and a `message` each.

Typeobject[]

Handling them

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

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.

forbidden

403 — the key is valid but its scope 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 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.

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; 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 and no connected mailbox 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.

not_scheduled

409 — reschedule or cancel was called on a message that isn’t scheduled any more. It has already begun sending. See 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.

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.

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.

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.

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.

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.

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

400region 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 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 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 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 429s 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. 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

Read `code`, not `detail`

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

For 422, read `issues`

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

Keep `request_id`

Log it beside your own trace ID — or send your own via X-Request-ID so they’re the same value.

Check the scope for a 403

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

Last updated on September 12, 2026

Was this page helpful?