The operator SDK
@~inbox/sdk/operator is a second entrypoint of the same package, for the cross-tenant
operator surface. It exists because operator access is a
different posture from tenant-scoped use, and the difference is easy to get wrong from the main
client.
import { createOperatorClient } from '@~inbox/sdk/operator';
const ops = createOperatorClient({ baseUrl, appKey: process.env.INBOX_OPERATOR_KEY! });
const { data, meta } = await ops.connections({ status: 'disconnected', limit: 50 });
Two factories:
| Function | Returns |
|---|---|
createOperatorClient(opts) |
Just the operator namespace — all a dashboard needs |
createOperatorInbox(opts) |
The full Inbox, still without a tenant, for mixing operator and public routes |
The type-level guarantee
export type OperatorClientOptions = Omit<InboxOptions, 'tenant'> & {
appKey?: string;
session?: string;
};
tenant is omitted at the type level, so you cannot build a "tenant-scoped operator" — a thing
the service does not have. buildHeaders sends X-Axis-Tenant only when configured, so with no
tenant the header is absent, which is exactly what defines an operator request. The rule is enforced
by the compiler rather than by convention.
Credentials
The credential must hold stats. admin does not grant it — cross-tenant access is a separate,
explicit grant, and an admin key confined to one workspace must never leak the estate. Mutating
routes additionally need operator:connections:write.
Either credential works:
appKey(ansk_key) — the dashboard's normal choice. Attributable by key id.session— an operator's own Accounts session. Better for a human-facing dashboard, because reads attribute to a real person in the service's operator access log. A session caller must also satisfyuser.isOperator; see The operator surface.
Methods
Aggregates
| Method | Route |
|---|---|
overview() |
GET /v1/operator/overview — estate-wide totals plus closedByKind and aiCloseRate |
tenants({ page, limit, search }) |
GET /v1/operator/tenants — per-tenant footprint. search matches workspace ref, app slug or app name, and constrains the total as well as the page |
tenantSummary(tenantId) |
GET /v1/operator/tenants/:id/summary — one workspace in a single call |
stats(domain, { tenantId }) |
GET /v1/operator/stats/:domain |
usage({ startDate, endDate }) |
GET /v1/operator/usage — messages, campaign recipients, tickets opened/closed |
stats(domain) covers only the domains the reports catalog has no source for — contacts,
connections, segments, templates, flows, reviews, webhooks, imports. Threads, tickets, campaigns and
messages are answered by reports.query; duplicating them would create two sources of truth.
Reports
reports.query(spec), reports.catalog(), reports.presets() — the cross-tenant mirror of
Reports. In operator mode the compiler does not inject a tenant clause,
so a query spans every tenant of the app; a spec.tenantFilter narrows it to one workspace. Each
preset's dateRange is a placeholder carrying only { field } — inject the real window before
POSTing it.
Row inventories
Every one is a paged list; the singular form fetches one record.
| Method | Route |
|---|---|
connections() / connection(id) |
/v1/operator/connections |
threads() / thread(id) |
/v1/operator/threads |
threadEntries(id, { cursor, limit, tenantId }) |
/v1/operator/threads/:id/entries |
tickets() / ticket(id) |
/v1/operator/tickets |
contacts() / contact(id, { tenantId }) |
/v1/operator/contacts |
segments() |
/v1/operator/segments |
campaigns() / campaign(id) |
/v1/operator/campaigns |
templates() |
/v1/operator/templates |
flows() |
/v1/operator/flows |
reviews() |
/v1/operator/reviews |
webhookEndpoints() |
/v1/operator/webhooks |
webhookDeliveries() |
/v1/operator/webhooks/deliveries |
imports() |
/v1/operator/imports |
List methods take OperatorListParams: tenantId, status, network, channel, handler,
country, connected, rating, search, page, limit. Not every route honours every key — a
rating means nothing to connections — and unknown keys are ignored server-side rather than
rejected, so one shape serves them all.
Credentials, message payloads and raw webhook bodies are redacted service-side. Contact identifiers
are masked in lists and unmasked only by contact(id).
Connection requests
connectionRequests.list({ status, channelType, tenantId }) is read-only. The rest mutate another
tenant's estate and need operator:connections:write on top of stats:
| Method | Purpose |
|---|---|
approve(id, { notes }) |
Approve a request |
reject(id, { reason }) |
Reject it |
whatsappConnect(id, { apiKey, phoneNumberId, baseUrl?, displayPhoneNumber?, businessAccountId? }) |
Enter the provider credentials → saves the connection and its webhook |
emailConnect(id, { apiKey, baseUrl?, domain?, fromName? }) |
Creates the connection and registers the domain; returns DNS records |
test(id) |
Credential probe (WhatsApp) or domain verify (email) |
Pagination
Two shapes, and the SDK models them separately.
| Helper | Meta | Used by |
|---|---|---|
paged() |
{ page, limit, total, hasMore } |
Every row inventory |
cursorPaged() |
{ hasMore, nextCursor, limit } |
threadEntries only |
meta.total spans the whole filtered dataset, not the page, so a consumer can paginate
truthfully. A transcript has no meaningful page number, so threadEntries is walked by cursor —
appending on scroll cannot skip or repeat a message as new ones arrive. Feed meta.nextCursor back
as cursor; it is null when exhausted. Entries come back newest first.
The 502 guard
Both helpers throw when meta is absent:
502 malformed_response — Operator route /v1/operator/threads returned no pagination meta;
cannot page safely.
They deliberately do not synthesise { total: data.length, hasMore: false }. A caller would
render one page and declare the dataset complete — precisely the failure the method exists to
prevent. A missing envelope means an older service or a proxy stripping it, and that must be loud.
This is also why paged() calls requestEnvelope rather than request: request unwraps to
data and would drop meta on the floor.
Access logging
threadEntries is the one operator route that serves message bodies, and contact(id) is the one
that returns unmasked identifiers. Both are access-logged per read, as a structured
operator.read log line carrying actorUserId, actorSessionId, actorKeyId, appId,
tenantId, recordId and count.
It is a log line rather than a table on purpose: the audit trail must never be able to fail the
request it describes, and a dropped audit row would be worse than a log the platform already ships.
actorUserId is null for machine keys — those are attributable by actorKeyId.