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."
}nameis the stable, machine-readable identifier for the condition. Branch on this — it never changes for a given condition.messageis 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
| Status | name | When |
|---|---|---|
401 | unauthenticated | The API key is missing, malformed, revoked, or unknown. |
403 | forbidden | Valid key, but it lacks the required permission or is scoped to a different inbox. |
403 | read_only | The tenant's subscription is canceled — reads still work, writes are blocked. |
403 | cap_hit | A plan limit was reached (inbox count, monthly email volume, storage, or webhook count). |
404 | not_found | The resource doesn't exist — or belongs to another tenant. Unknown and cross-tenant ids are indistinguishable, so existence is never leaked. |
409 | conflict | The request conflicts with current state (e.g. a non-idempotent duplicate). |
409 | address_unavailable | The requested inbox username is taken, reserved, or still quarantined after a delete. Choose another. |
422 | validation | The request body or query parameters failed validation. Carries per-field errors. |
429 | rate_limited | The 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.