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:

  1. "My data disappeared" is almost always a wrong tenant. Check the header before you check the database. GET /v1/whoami returns the resolved tenantId — compare it against a known-good one.
  2. A stable tenantId across calls proves nothing about correctness. It only proves you sent the same wrong value twice.
  3. 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:

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:

  1. INBOX_ACCOUNTS_APP_SLUG
  2. AXIS_ACCOUNTS_APP_ID
  3. 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