Skip to main content

Tenant isolation

Orbit is a multi-tenant platform. Every organization’s working data lives in its own PostgreSQL schema named tenant_<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.organizations catalog with its own id, 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.
A bare org is a catalog entry; it becomes a tenant once provisioning creates its schema and the org’s 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.
Worked illustration — a reseller parent 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.
Cross-tenant isolation exists at the schema layer; sibling-subaccount isolation exists at the org-ownership layer. The two are enforced differently — do not assume “same schema” means “open to the whole reseller family.” Each endpoint carries an ownership check.

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:
Worked resolution, GET /v1/sender-pools:
  1. Auth middleware resolves sk_live_... to one org, reads that org’s tenant_id and tenant_schema_ready from public.organizations.
  2. 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 503 Tenant database temporarily unavailable here — before any handler runs — rather than issue a query against the wrong schema.
  3. The handler lists sender_pools from the resolved tenant schema and returns it. A client-supplied tenant_id in the query string or body is ignored at this point: the resolved org from step 1 wins.
Client-supplied 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