The Node SDK
@~inbox/sdk is the typed client for Inbox, published on npm at version 0.5.4. It is a thin
wrapper over fetch — no connection pooling, no state, no background work — so constructing one per
request costs nothing.
yarn add @~inbox/sdk
import { Inbox } from '@~inbox/sdk';
const inbox = new Inbox({
baseUrl: process.env.INBOX_BASE_URL!,
appKey: process.env.INBOX_SERVICE_KEY!, // sk_… — or `session` for a real user
tenant: `ws_${accountsUserGroupId}`,
});
const { data: threads } = await inbox.inbox.threads.list({ status: 'open' });
await inbox.inbox.threads.reply(threads[0].id, { message: 'On it — thanks for waiting.' });
The package README on npm is out of date. It documents the retired
axs_key format, showstenantas required, omitssessionauth entirely, and predates most of the namespaces below. These docs are generated against the current source; the README is not.
Construction
new Inbox({ baseUrl, appKey?, session?, tenant?, fetch?, maxRetries? })
| Option | Notes |
|---|---|
baseUrl |
Trailing slashes are stripped. |
appKey |
An Accounts service key. Must start sk_ or the constructor throws. A machine credential — it acts as the whole tenant. |
session |
An Accounts user session id, used as the bearer. Inbox resolves it to { userId, sessionId }, so ownership, assignment and group visibility attribute to a real person. |
tenant |
ws_<accountsUserGroupId>, sent as X-Axis-Tenant. Optional, because the operator surface is defined by the absence of that header. A tenant-scoped call without it is rejected with tenant_missing. |
fetch |
Override the fetch implementation. The default is globalThis.fetch bound — native fetch throws "Illegal invocation" when called as a method of another object. |
maxRetries |
Retries on 429/5xx. Default 3. |
Exactly one of appKey and session is required. Passing both, or neither, throws:
Provide exactly one of appKey (sk_…) or session (an Accounts session id).
Only the shape is checked locally. Validity is Accounts' business, and a bad key surfaces as a 401 from the service.
Namespaces
| Namespace | Main methods |
|---|---|
connections |
list, capabilities, start, complete, reconnect, disconnect, remove, switchIntegration, retryWebhookSubscription, sendTemplate, sessionStatus, qr, syncContacts |
posts |
create, list, get, update, retry, delete, liveStatus |
inbox.threads |
list, get, reply, action, setStatus, setRouting, takeover, analysis, analyze, plus the comment wrappers hide, unhide, like, unlike, deleteComment, privateReply |
inbox.accounts |
list, updateSettings |
tickets |
list, statistics, mine, get, open, close, reopen, assign, reassign, massReassign, massClose |
contacts |
list, summary, get, archive, import, resolve, update, addIdentifier, setIdentifierStatus, merge, segments |
segments |
list, get, create, update, delete, members, addMembers, removeMembers |
labels |
list, create, update, delete, listForThread, addToThread, removeFromThread, listForTicket, addToTicket, removeFromTicket |
notes |
listForThread, addToThread, removeFromThread |
cannedReplies |
list, create, update, delete |
autoResponse |
get, update |
reports |
query, catalog, export, inbox, campaigns, agents, topPerformingAgents |
webhooks |
register, list, delete |
realtime |
token |
admin |
releaseNativeClaim, webhookInbox.list, webhookInbox.requeue |
operator |
The cross-tenant surface — see The operator SDK |
Plus two top-level helpers: ping() and whoami().
The comment wrappers (hide, like, privateReply, …) all POST to the same
/v1/inbox/threads/:id/actions route with a different action in the body. Use them or action()
directly; they are the same call.
segments request and response shapes are service-internal rather than declared in @~inbox/types,
so they are typed loosely. operator rows are deliberately Record<string, unknown> — the operator
read model is projected per resource and evolves with the dashboard, so pinning row interfaces in
the SDK would guarantee drift. Declare your own and pass them as the generic.
Idempotency
Every write gets an Idempotency-Key header automatically — a generated UUID when you do not pass
one. The key is computed once, outside the retry loop, so the SDK's own retries replay rather
than duplicate:
const idempotencyKey = options.idempotencyKey ?? (isWrite ? uuid() : undefined);
Pass your own when the key must survive beyond one client call — a job that may be re-run, a user action you want repeat-safe across a page reload:
await inbox.posts.create(input, { idempotencyKey: `post:${jobId}` });
See Idempotency for the matching rules and the four outcomes.
Retries
The client retries on 429 and any 5xx, up to maxRetries. On a 429 with a Retry-After header
it honours the value — but caps it at 60 seconds, because an upstream (or a misconfigured proxy)
answering Retry-After: 86400 would otherwise park your request for a day holding its socket.
Otherwise the backoff is 2 ** attempt seconds, capped at 8. The response body is drained before
each retry so keep-alive sockets are not pinned.
Errors
Anything non-2xx throws an InboxError:
import { InboxError } from '@~inbox/sdk';
try {
await inbox.inbox.threads.reply(id, { message });
} catch (err) {
if (err instanceof InboxError && err.code === 'messaging_window_expired') {
// outside the 24h window — send an approved template instead
}
}
| Property | Meaning |
|---|---|
status |
HTTP status |
code |
The service's error code, or "unknown" if the body was unparseable |
message |
The service message, falling back to res.statusText |
body |
The whole parsed response body |
details |
Structured hints — today { connectIntegrationKey? }, telling you which integration to connect to gain a missing capability |
Envelopes
Three methods sit under everything:
| Method | Returns | Use |
|---|---|---|
request<T>() |
T — the envelope unwrapped to data |
Everything single-valued |
requestPaged<T>() |
{ data, meta } |
Cursor-paginated tenant lists |
requestEnvelope<T>() |
The raw { data?, meta? } |
Internal; backs the other two |
request() unwraps on the presence of data, not its truthiness:
if (json && typeof json === 'object' && 'data' in json) return json.data as T;
return json as T;
The naive json?.data ?? json is not equivalent, and the difference shipped a real bug. An endpoint
answering { data: null } — a legitimate "nothing to report", such as the free-SMS allowance when
billing is unconfigured — made null ?? json return the whole envelope, a truthy object. Callers
sailed past their if (!data) guard, read undefined off it, and the free-SMS screen rendered a
zero allowance: "you have used your free SMS" when the truth was "we could not tell you".
Verifying an outbound webhook
import { verifyWebhookSignature } from '@~inbox/sdk';
const ok = verifyWebhookSignature({
secret, // returned once at webhooks.register()
rawBody, // the bytes as received — never re-serialised JSON
timestamp: headers['x-axis-timestamp'],
signature: headers['x-axis-signature'], // sha256=<hex>
});
HMAC-SHA256 over ${timestamp}.${rawBody}. Re-serialising the JSON changes the bytes and every
signature fails. See Signature verification.