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:

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.