Tenant isolation
Orbit is a multi-tenant platform. Every organization’s working data lives in its own PostgreSQL schema namedtenant_<tenant_id> (dashes become
underscores in the schema name). Shared catalog and routing tables —
the things every tenant needs to read but no tenant owns — live in the
public schema. This page defines the terms, catalogues both splits, and
walks the request-resolution chain end to end so you can reconstruct it
from the code.
What a tenant is
Three related terms appear across the docs:- Organization (org) — a row in the shared
public.organizationscatalog with its ownid,tenant_id, and provisioning status (tenant_schema_ready). Your API key is issued against one org. - Tenant schema — one PostgreSQL schema (
tenant_<tenant_id>) holding that org’s working data: contacts, messages, campaigns, agents, sender pools, and so on. Roughly 200 tables form this per-tenant catalog. - Subaccount — a separate org (row) under a reseller parent that shares the parent’s tenant schema. Sibling subaccounts can co-own one schema; ownership gating decides what each can see.
tenant_schema_ready flag flips. On
most endpoints a not-yet-ready schema returns 503; endpoints that
explicitly tolerate provisioning-in-progress opt out.
Section 1 — The public schema: catalog tables every tenant reads
public holds tables every tenant reads, or tables that identify tenants
themselves. Reading a row here doesn’t read another tenant’s data — the
org boundary is still enforced by ownership columns. Mutations on a public
table are gated the same way: the platform IP denylist, the DNIS routing
table, and every per-org row in organizations all carry an org-ownership
check on write. public is catalog-only: no per-tenant working data
(messages, contacts, recordings) ever lives there.
These are catalog or routing tables: the shape is “one row per org or
one row per platform artifact”, never “one table per tenant”.
Section 2 — Tenant schemas: one per tenant, named tenant_<tenant_id>
Everything an org creates, sends, or receives lives in its own tenant
schema. Senders, inbound SMS routing, SMPP credentials, sender pools,
contacts, campaigns, flows, agent state, recordings, inbox, CDP events,
WFM — all are per-tenant tables. Provisioning creates the schema, runs
the tenant-table migration set against it, and flips tenant_schema_ready.
Because each tenant schema carries its own copy of the same table set, a
pool id — or any other per-tenant resource id — is only unique within
one tenant schema. sender_pools ids repeat across tenants; Postgres
enforces uniqueness per-tenant via the table’s primary key inside each
schema. A sibling subaccount shares your tenant schema, so a number it
owns is visible to the full org-family tree; a sender pool it owns is
visible too, but only inside your family’s schema — never across tenants.
Section 3 — Why sibling subaccounts share a schema (and what isolates them)
Subaccounts under one reseller parent share one tenant schema. Isolation between siblings therefore isn’t schema-vs-schema — it is org-ownership gating on every shared-schema surface. Each row in a shared-schema table carries an owning-org column, and every read or write on that table is gated against the requesting org:- Numbers, CNAM, and emergency addresses are gated by org ownership, exactly as the number lifecycle, CNAM, and emergency-address pages state: a sibling subaccount cannot read or mutate a record owned by another sibling, even though both live in the same schema.
- Wallet / transfer level: the parent moves capabilities between siblings (for example reassigning an owned number to a sibling), but only with ownership checks enforced on each verb.
- Public catalog rows (organizations, to resolve the tenant id; platform IP denylist; DNIS/inbound routing) are readable as catalog — not data — but each mutation is still ownership-checked.
Org-P with sibling subaccounts
Org-A and Org-B, all bound to one tenant schema tenant_p_9f8e7d6c:
The boundary to remember: sibling subaccounts share a schema; different
tenants never do. A sibling co-owns the schema; a different tenant has
its own schema entirely.
Section 4 — How the API resolves tenant on every request
The tenant is never taken from request input —tenant_id is a security
boundary, not a parameter. The resolution chain on every authenticated
request:
- Auth middleware resolves
sk_live_...to one org, reads that org’stenant_idandtenant_schema_readyfrompublic.organizations. - The per-request bridge opens the request against that org’s
tenant_<tenant_id>schema. If the schema is still provisioning and the route did not declare itself able to degrade, the API returns 503Tenant database temporarily unavailablehere — before any handler runs — rather than issue a query against the wrong schema. - The handler lists
sender_poolsfrom the resolved tenant schema and returns it. A client-suppliedtenant_idin the query string or body is ignored at this point: the resolved org from step 1 wins.
tenant_id values are ignored or rejected on the handful
of legacy endpoints that still accept them; the resolved org always wins.
Section 5 — The SOC 2 posture this underpins
The server-side resolution above is the load-bearing control the SOC 2 tenant-isolation controls page cites:tenant_id originates from the API key + the organizations catalog,
never from request input. That control, plus the schema-per-tenant data
split in Section 2 and the org-ownership gating in Section 3, is what the
auditable posture rests on.
Cross-references
- SOC 2 controls → Tenant Isolation — the
control text behind
tenant_id-from-server. - Sender pools — pool ids are per-tenant-schema unique.
- Number lifecycle → Reassign to a subaccount — sibling-subaccount ownership gating in practice.
- SMPP guide — tenant-scoped
smpp_credentialsin action.