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, shows tenant as required, omits session auth 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.