Errors

One consistent error envelope across the whole API, with a stable machine-readable name for every condition.

Every error the API returns uses one envelope, so your error-handling code is written once and works everywhere.

The envelope

{
  "name": "not_found",
  "message": "Inbox not found."
}
  • name is the stable, machine-readable identifier for the condition. Branch on this — it never changes for a given condition.
  • message is a human-readable description. Show it; don't parse it.

The HTTP status also carries meaning (see the table below), but name is the canonical thing to switch on.

Validation errors

Validation failures (422) use a slightly richer envelope: name is always validation, and an errors object carries per-field detail keyed by field name.

{
  "name": "validation",
  "errors": {
    "human_email": "must be a valid email address"
  }
}

Error catalog

StatusnameWhen
401unauthenticatedThe API key is missing, malformed, revoked, or unknown.
403forbiddenValid key, but it lacks the required permission or is scoped to a different inbox.
403read_onlyThe tenant's subscription is canceled — reads still work, writes are blocked.
403cap_hitA plan limit was reached (inbox count, monthly email volume, storage, or webhook count).
404not_foundThe resource doesn't exist — or belongs to another tenant. Unknown and cross-tenant ids are indistinguishable, so existence is never leaked.
409conflictThe request conflicts with current state (e.g. a non-idempotent duplicate).
409address_unavailableThe requested inbox username is taken, reserved, or still quarantined after a delete. Choose another.
422validationThe request body or query parameters failed validation. Carries per-field errors.
429rate_limitedThe key exceeded its rate limit. Carries a Retry-After header. See Rate limits.

Not-found never leaks existence

An unknown id and an id that belongs to another tenant both return the same 404 not_found. There is no way to distinguish "doesn't exist" from "exists but isn't yours" — so the API never leaks whether a resource exists in someone else's account.

Handling errors

Branch on name, and treat 429 specially by honoring Retry-After:

const res = await fetch(url, { headers: { Authorization: `Bearer ${key}` } });

if (!res.ok) {
  const err = await res.json(); // { name, message } or { name: "validation", errors }
  switch (err.name) {
    case "unauthenticated":
      throw new Error("Bad or missing API key");
    case "forbidden":
      throw new Error("Key lacks permission or scope");
    case "not_found":
      return null; // unknown or not yours — treat the same
    case "rate_limited": {
      const retryAfter = Number(res.headers.get("Retry-After")) || 1;
      await new Promise((r) => setTimeout(r, retryAfter * 1000));
      // …retry
      break;
    }
    default:
      throw new Error(err.message);
  }
}
res = requests.get(url, headers={"Authorization": f"Bearer {key}"})

if not res.ok:
    err = res.json()  # {"name", "message"} or {"name": "validation", "errors"}
    name = err["name"]
    if name == "unauthenticated":
        raise RuntimeError("Bad or missing API key")
    if name == "forbidden":
        raise RuntimeError("Key lacks permission or scope")
    if name == "not_found":
        return None  # unknown or not yours — treat the same
    if name == "rate_limited":
        retry_after = int(res.headers.get("Retry-After", "1"))
        time.sleep(retry_after)
        # …retry
    else:
        raise RuntimeError(err["message"])

The typed SDKs surface these as exceptions carrying the same name, and the CLI prints the same envelope with --format json — so error-handling logic ports one-to-one across every surface.

On this page