Envelopes and errors

Every response from inbox is wrapped. Success bodies carry data and meta; failures carry error. There is no third shape, and no route answers with a bare payload.

Success

{
  "data": { "id": "thr_01J…", "status": "open" },
  "meta": { "requestId": "01J8Z…" }
}

ResponseInterceptor does the wrapping. If a handler already returned something with a data key — which paginated routes do, because they need to attach meta.nextCursor — the interceptor leaves the shape alone and merges requestId into the existing meta. So meta is always present and always has requestId; anything else in it came from the handler.

Unwrap on presence, not truthiness

This is the one gotcha on this page that has already cost real money.

// Correct
if (json && typeof json === "object" && "data" in json) return json.data;
return json;

// WRONG — looks equivalent, is not
return json?.data ?? json;

{ "data": null } is a legitimate answer. It means "we resolved your request and there is nothing to report" — the free-SMS allowance when billing is not configured, for one. Under ??, null falls through and the expression returns the whole envelope, which is a truthy object. Callers then sail straight past their if (!data) guard and read fields off an object that has none.

That is exactly what happened: the free-SMS screen read undefined as a zero allowance and told users "you have used your free SMS" when the truth was "we could not tell you". Every endpoint capable of answering data: null had the same corruption waiting in it.

The SDK's request() gets this right. If you are unwrapping by hand — in a BFF, in a proxy — check for the key.

Errors

{
  "error": {
    "code": "capability_unavailable",
    "message": "That capability is not available for this connection.",
    "requestId": "01J8Z…",
    "details": { "connectIntegrationKey": "whatsapp" }
  }
}
Field Present when
code always — one of the AxisErrorCode values
message always — a generic, user-safe string
requestId whenever the request got one
rateLimit on 429: { limit, remaining, resetAt }
details when the service has a structured hint

AxisErrorDetails is deliberately small and deliberately non-secret:

Field Meaning
connectIntegrationKey On capability_unavailable: which integration to connect to gain the missing capability
providerStatus The upstream provider's HTTP status, when a provider call failed
providerErrors The provider's field-keyed validation errors, as Record<string, string[]>

details never carries secrets, signatures, or credentials. It exists so a client can branch without string-parsing a message.

Codes never say which check failed

One code per failure mode, not per failure cause. invalid_api_key covers a key that does not exist, one that was revoked, one that expired, and one whose apps do not resolve to anything local. missing_credentials covers absent and malformed alike.

That is on purpose. A caller who can distinguish "no such key" from "revoked key" has an oracle. The cost is that you cannot debug from the code alone — which is what requestId is for. Quote it; the server logs correlate on it.

The generic-message rule applies to ERROR_MESSAGE too: "API key not found or revoked" is one string covering both, and insufficient_scope never names the scope you were missing.

Coercion of non-Axis errors

Anything thrown that is not an AxisError still comes out in the contract. AllExceptionsFilter handles two more cases:

The status-to-code mapping is worth knowing, because it means a code can arrive that the handler never chose:

Status Coerced code
400, 422 validation_failed
401 missing_credentials
403 insufficient_scope
404 not_found
409 idempotency_conflict
429 rate_limit_exceeded
other ≥ 500 internal
anything else validation_failed

So a raw ForbiddenException from a library surfaces as insufficient_scope even though no scope check ran, and a raw 409 surfaces as idempotency_conflict even though no idempotency key was involved. When a code looks wrong for the situation, suspect a coerced HttpException and check the requestId in the logs.

Two 409s that are not idempotency

conflict (409) is a distinct code from idempotency_conflict. It means a state invariant refused the write: the request was well-formed, the world just is not in a shape that permits it. It was introduced for the one-open-ticket-per-thread rule, which Postgres enforces with a partial unique index; the unique violation surfaces as conflict rather than leaking a raw Prisma error.

account_claimed_elsewhere is the third 409 — a native connection already claimed in another workspace.

Retry-After on 429

When a 429 carries rateLimit, the filter also sets a retry-after response header, in seconds, computed from resetAt. See Rate limits.

The full generated table of every code, its status and its message is at Error codes.