> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Tenant isolation

> How Orbit isolates customer data: a shared public catalog plus one PostgreSQL schema per tenant, resolved per request from your API key — never from a client-supplied tenant_id.

# 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.

| Public table                                       | What it holds                                   |
| -------------------------------------------------- | ----------------------------------------------- |
| `organizations`                                    | Orgs (id, tenant\_id, plan, provisioning flag)  |
| `users`                                            | Users across all orgs                           |
| `platform_ip_denylist`                             | Platform-wide IP denylist                       |
| `honeypot_dids`                                    | Honeypot DID catalog for spam-signal detection  |
| `dnis_routes`                                      | Shared DNIS → org routing table                 |
| `inbound_routes`                                   | Shared inbound-route (voice/SMS/email) targets  |
| `platform_numbers` / `platform_number_assignments` | The platform-wide DID pool and each assignment  |
| `phone_number_owners`                              | Cross-tenant reverse map: number → owning org   |
| `sip_devices` / `tenant_sip_credentials`           | Shared SIP device + per-tenant credential index |

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.

| Tenant table                        | What it holds                            |
| ----------------------------------- | ---------------------------------------- |
| `sender_pools`                      | Sender-pool definitions + member senders |
| `smpp_credentials`                  | Per-tenant SMPP connector credentials    |
| `sms_inbound_routes`                | Per-tenant inbound-message routing rules |
| `contacts`, `messages`, `campaigns` | Audience + send artifacts                |
| `flows`, `agents`, `recordings`     | Automation, AI agent state, media        |
| `cdp_events`, `usage_events`        | Event stream + usage metering rows       |

## 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](/numbers/lifecycle),
  [CNAM](/numbers/cnam), and [emergency-address](/numbers/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`:

| What `Org-A` requests                                     | Schema reached                    | Ownership gate                    | Result         |
| --------------------------------------------------------- | --------------------------------- | --------------------------------- | -------------- |
| Read its own sender pool                                  | `tenant_p_9f8e7d6c`               | row belongs to `Org-A`            | 200            |
| Read `Org-B`’s sender pool                                | `tenant_p_9f8e7d6c`               | row belongs to `Org-B`            | 403            |
| Parent `Org-P` reassigns a number from `Org-A` to `Org-B` | `tenant_p_9f8e7d6c`               | parent owns the reassignment verb | 200            |
| Read an org in a *different* reseller family              | `tenant_o_1234abcd` (unreachable) | different schema entirely         | not resolvable |

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.

<Warning>
  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.
</Warning>

## 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:

```text theme={null}
HTTP request, Authorization: Bearer sk_live_...
  │
  ▼  1. Auth middleware — resolve API key → one org
      (caches that org’s tenant_id on the request)
  │
  ▼  2. Per-request bridge — attach a tenant-scoped DB client
      (opens the request against the org’s tenant_<tenant_id> schema;
       if the attach fails and the route has not opted out, the
       request 503s rather than risk a wrong-tenant read)
  │
  ▼  3. Route handler — speaks to the tenant schema only
      (no tenant_id is read from query, body, or path — the SOC 2
       tenant-isolation control requires server-side resolution)
```

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](/compliance/soc2-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](/compliance/soc2-controls) — the
  control text behind `tenant_id`-from-server.
* [Sender pools](/guides/sender-pools) — pool ids are per-tenant-schema
  unique.
* [Number lifecycle → Reassign to a subaccount](/numbers/lifecycle) —
  sibling-subaccount ownership gating in practice.
* [SMPP guide](/guides/smpp) — tenant-scoped `smpp_credentials` in action.
