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:
- A Nest
HttpExceptionkeeps its status and message, and its code is derived from the status. - Anything else is logged with its stack and returned as
internal/ 500 with a fixed message.
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.