Tenancy
Every tenant-scoped call to Inbox carries one header:
X-Axis-Tenant: ws_<accountsUserGroupId>
A tenant is a workspace. It is the scope for connections, threads, tickets, contacts, segments, campaigns, templates, flows, files and posts — everything except the cross-tenant operator surface.
This page is the most important concept page on the site, because the failure mode described below accounts for most "my data is missing" reports.
The value must be the Accounts user group id
The ws_ prefix namespaces an Axis Accounts user group id. The user group is the workspace, and it
is the only identifier shared across every Axis app. That sharing is the whole point: a WhatsApp
connection made in one app appears in another with no reconnect, because both apps send the same tenant
value.
Never use a per-app local workspace id. It differs per app, and it fragments the same customer across apps in a way that cannot be cleanly undone later.
The tenant key is composite
model Tenant {
appId String
externalRef String
// …
@@unique([appId, externalRef])
}
The identity of a tenant is (appId, externalRef), not the ws_ value on its own. The same
ws_abc123 presented under two different apps resolves to two different tenants with two different
tenantIds and no shared data.
This is intentional — apps are isolated from each other by default — but it means "same workspace" is only true when the app is also the same. If a call from one app cannot see data written by another, this is usually why, and the fix is app configuration, not the tenant value.
Auto-provisioning, and the gotcha it creates
TenantService.resolve on the bearer path does this:
const t = await this.store.resolveOrProvision(auth.appId, tenantHeader);
which is a plain Prisma upsert on (appId, externalRef):
await this.prisma.tenant.upsert({
where: { appId_externalRef: { appId, externalRef } },
create: { appId, externalRef },
update: {},
});
A tenant value that does not exist is not an error. It is created.
So a typo, a stale environment value, a local id used where the Accounts group id belonged, or a
copy-pasted value from a different environment all produce the same symptom: a 200 OK on a fresh,
empty workspace. Every list is empty. Every write succeeds — into a workspace nobody will look at again.
Nothing in the response says anything is wrong; tenantId even looks plausible.
Three practical consequences:
- "My data disappeared" is almost always a wrong tenant. Check the header before you check the
database.
GET /v1/whoamireturns the resolvedtenantId— compare it against a known-good one. - A stable
tenantIdacross calls proves nothing about correctness. It only proves you sent the same wrong value twice. - Derive the tenant value from the session at request time in exactly one place in your app. A hardcoded literal is a bug waiting for an environment change.
Provisioning also seeds workspace metadata when a name is available — workspaceName and a
globally-unique ticketPrefix (initials, disambiguated on collision: KL, KL2, KL3). Both are
best-effort and never fail the request that triggered them.
The two credential paths behave differently
| Path | Tenant source | On a bad value |
|---|---|---|
| Bearer (service key or user session) | X-Axis-Tenant header |
Silently provisions a new tenant |
| Realtime JWT | tenantId claim inside the token |
403 tenant_not_authorized |
The JWT path is the only one that validates. It looks the tenant up by id and checks that its appId
matches the credential's; a mismatch throws tenant_not_authorized. It cannot auto-provision because a
token carries an internal tenantId, not an external ref, so there is nothing to provision from.
A missing header on a tenant-scoped route is a clean 400 tenant_missing. Missing is caught; wrong is
not.
The operator surface is defined by the header's absence
Cross-tenant operator routes (/v1/operator/*) carry no X-Axis-Tenant. TenantGuard recognises
an @Operator() route and skips tenant resolution entirely, leaving tenantContext unset — deliberately,
because failing those routes closed for lacking a tenant would break the whole surface.
Authorisation moves to OperatorGuard instead, and it is stricter than the tenant-scoped one:
- The
statsscope is required.admindoes not imply it. Anadminkey confined to one workspace must never be able to read the whole estate. - A mutating operator route additionally needs
operator:connections:write, checked explicitly soadminalone can never satisfy it. - A session caller additionally needs
user.isOperator— membership of the app named byINBOX_OPERATOR_APP_SLUG. This matters becausestatsis granted to every user session (the tenant-scoped/v1/reports/*routes need it), so the scope check alone would let any authenticated workspace user read the entire estate. Machinesk_keys carry nouserand are unaffected.
With INBOX_OPERATOR_APP_SLUG unset, no session ever reaches the operator surface and cross-tenant
reads require a machine key carrying inbox:stats.
The SDK encodes this at the type level: createOperatorClient omits tenant entirely, so you cannot
accidentally send one.
App resolution
Accounts keys are not app-exclusive — one key can carry scopes for several apps. Inbox picks which app it is acting as, in this order:
INBOX_ACCOUNTS_APP_SLUGAXIS_ACCOUNTS_APP_ID- the first app in the credential's grant
Step 3 is a real hazard. "First" is whatever order Accounts returned, which is not a contract. On a
multi-app key the app — and therefore the tenant, since the key is (appId, externalRef) — can change
without any change on your side.
Set the slug. Accounts app UUIDs are environment-specific: axis-engage has a different id locally,
on dev and in production, so a hardcoded UUID that works in one environment silently resolves the wrong
app (or no app) in another. The stable key is (tenant slug, app slug).
Tenant merges
Two workspaces can be folded together — the Inbox half of Accounts' user_group.merged. Connections,
labels, segments, canned replies, contacts and campaigns re-point to the target, collisions on the
tenant-scoped uniques are merged, and the source tenant is archived.
POST /v1/admin/tenants/merge is the one route in the service that needs both authorisation paths at
once. The TenantMerge audit row deliberately has no foreign keys, so the record outlives the archived
source tenant — see Data model.
Checklist
- Value is
ws_+ the Accounts user group id, derived from the session, not hardcoded. - App slug is configured, so app resolution is deterministic.
GET /v1/whoamireturns thetenantIdyou expect, and the scopes you expect.- Operator clients send no tenant header at all.