Multi-tenant DID inventory is the full set of phone numbers a platform holds and assigns across its customer accounts: numbers bought from carriers, numbers ported in from customers, numbers waiting to be used, and numbers recently released. For a UCaaS provider or an ISV embedding voice, it’s one of the few parts of the stack where a sloppy data model turns directly into lost revenue and angry tenants.
The governing principle: a number is a stateful resource with a lifecycle, not a row with a tenant_id. It has a history, obligations attached to it, and states in which it must not be reassigned. Model the lifecycle explicitly and most inventory problems don’t happen. Model it as a foreign key and you’ll rediscover every one of them in production.
This assumes you’re building the platform side and already understand SIP and number basics.
The lifecycle
Every number moves through a small set of states. Write them down as an enum and enforce the transitions in code.
- available: in your stock, paid for, assigned to no one.
- reserved: held for a tenant during checkout or provisioning, with a TTL.
- pending_regulatory: requested for a tenant, waiting on documents or approval.
- porting_in: a tenant’s existing number being moved to you; not yet live on your network.
- assigned: live for a tenant, routing configured.
- porting_out: the tenant has requested a move to another provider.
- quarantined: released by a tenant, aging before it can return to available.
- returned: released back to the carrier; you no longer pay for it.
Illegal transitions are where bugs live. quarantined → assigned directly is a privacy incident waiting to happen. porting_out → available means you’ve resold a number another provider now controls. Enforce the graph, log every transition with a timestamp and actor, and you’ll be able to answer “who had this number on 3 March?” in one query.
A minimal data model
Something like this covers most platforms:
CREATE TABLE numbers (
e164 TEXT PRIMARY KEY, -- +4420...
country CHAR(2) NOT NULL,
number_type TEXT NOT NULL, -- geographic | national | mobile | toll_free
capabilities TEXT[] NOT NULL, -- {voice, sms}
state TEXT NOT NULL,
state_changed_at TIMESTAMPTZ NOT NULL,
tenant_id UUID, -- null unless reserved/assigned/porting
source TEXT NOT NULL, -- purchased | ported_in
provider_ref TEXT, -- carrier's identifier
regulatory_bundle_id UUID, -- documents this number depends on
routing_profile_id UUID, -- where calls go
emergency_address_id UUID, -- where required
reserved_until TIMESTAMPTZ,
quarantine_until TIMESTAMPTZ
);
CREATE TABLE number_events (
e164 TEXT NOT NULL,
from_state TEXT,
to_state TEXT NOT NULL,
tenant_id UUID,
actor TEXT NOT NULL,
at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Code language: PHP (php)Two design choices matter more than they look.
Routing is a reference, not a column. Point numbers at a routing profile owned by the tenant, holding the SIP destination, failover and options. When a tenant changes their PBX endpoint, you update one profile, not 4,000 numbers.
Regulatory documents are a reference too. Many numbers in the same country will depend on the same end-user documents. When a document expires, you query every number referencing that bundle. Our DID regulatory requirements checklist covers the intake side.
Stock strategy: buffer vs on-demand
Holding stock costs money every month. Not holding it costs you time at checkout. The right answer differs by country and number type.
For number types with light requirements and fast provisioning, on-demand is fine: reserve from the carrier when the tenant checks out. For popular area codes where you sell many numbers, a small buffer makes the experience instant. A simple sizing rule for a buffer: provisioning lead time in days × average daily consumption for that area code, plus a margin. If lead time is one day and you sell three numbers a day in an area code, a buffer of five or six is plenty.
For number types that require end-user documents before assignment, buffering often isn’t possible at all, because the number can’t be allocated until the specific end user is approved. Don’t build a UI that implies instant availability for those. Route the order into pending_regulatory and show the status.
Call tracking platforms have their own sizing math, since they need large pools per client; see our guide to sizing a call tracking number pool.
Reservation must be idempotent
Checkout flows retry. Users double-click. Network calls time out after the server has already acted. If “reserve number” isn’t idempotent, you’ll assign the same number twice or leak reservations that never expire.
The pattern that holds up:
- The client sends a reservation request with an idempotency key.
- The server atomically moves one available number to reserved with reserved_until = now() + TTL, keyed to that request.
- A retry with the same key returns the same number.
- Checkout commits the reservation to assigned, or it expires and a sweeper returns the number to available.
Use a single atomic update with a WHERE state = ‘available’ guard, or a SELECT … FOR UPDATE SKIP LOCKED if you’re picking from a set. Don’t read-then-write in two statements.
Release and aging
When a tenant releases a number, it doesn’t go straight back into stock. It goes into quarantine first.
The reason is practical. The number’s previous owner’s customers, contacts and automated systems will keep calling and texting it for a while. If you reassign it immediately, the new tenant receives calls meant for the old one, and some of those calls may contain information the new tenant should never hear. Aging gives that traffic time to die down.
Pick a quarantine period and watch inbound traffic on quarantined numbers. If a number is still receiving meaningful traffic at the end of the period, extend its quarantine or return it to the carrier rather than reassign it. Numbers that were heavily used, such as a main business line, deserve longer aging than a number that barely rang.
Decide, too, when to return numbers to the carrier instead of re-stocking them. A quarantined number in an area code where you have plenty of stock is just a monthly cost.
Porting in and porting out
Tenants arrive with existing numbers, and eventually some of them leave with numbers too. Both directions need first-class support.
Porting in is part of onboarding for most business customers. Model it as a state, not a background task, so the tenant can see progress and your routing is ready the moment the port completes. Our guide to porting numbers with LOA and FOC covers the paperwork and timelines.
Porting out happens when a tenant moves to another provider. Don’t block legitimate port-out requests or slow them to retain a customer; that’s a fast way to damage trust and, in many places, runs against the rules. Do verify requests properly, because fraudulent port-outs are a known attack. When a port-out completes, move the number to returned, never back to available.
Capabilities: voice and SMS are not the same number
A number’s capabilities are part of its record, and they vary. Some numbers support voice only, some voice and SMS, and SMS on some number types requires separate registration.
In the US, sending application-to-person SMS from local numbers generally requires 10DLC brand and campaign registration, which is per tenant, not per platform. If your tenants text from the numbers you assign, registration status belongs in your model alongside the number. Our 10DLC registration guide walks through the process.
Emergency calling data
For platforms offering outbound calling in the US, emergency calling isn’t optional. Interconnected VoIP services must support 911, and rules require passing a dispatchable location so responders can find the caller. Other countries have their own requirements.
In the data model, that means an emergency address linked to the number or, better, to the endpoint or user making the call, with a way for tenants to keep it current. Get legal advice for the markets you serve. The engineering point is simple: if the address isn’t in your model, you can’t send it.
Cost attribution per tenant
Every number costs you something monthly, and many carry per-minute charges on top. If you can’t attribute those costs to tenants, you can’t price accurately or spot unprofitable accounts.
Store the monthly cost and the carrier’s rate plan reference on each number, and roll costs up by tenant from the state history, not from a snapshot. A number that moved from one tenant to quarantine mid-month should be billed to the right place for the right days. Quarantined and available stock are your costs, not any tenant’s, so report them separately; a growing quarantine or buffer is an inventory decision to review, not overhead to spread across customers.
Per-minute inbound charges belong in the same model. Tag CDRs with the tenant from the number’s state at call time, so a port or reassignment during the month doesn’t misallocate usage.
Reconcile nightly
Your database and your carrier’s inventory will drift. A port completes on the carrier side but your webhook fails. A number is returned by one of your ops staff through the carrier portal. A reservation sweeper crashes.
Run a nightly reconciliation: every number the carrier bills you for should exist in your table in a paid state, and every number you think you hold should exist on the carrier side. Alert on differences. The most common finding is numbers you’re paying for that nobody is using, which is money you can recover the same day.
Where the carrier fits
Everything above is your platform’s responsibility, but it’s easier when your supply is concentrated. Each additional number provider adds another inventory feed to reconcile, another set of regulatory formats and another porting process.
IDT Express provides DID numbers in 140+ countries, so a platform can source most of its international inventory through one relationship. If you’re building for AI voice use cases, our post on DID strategy for AI receptionist platforms looks at the tenant-number question from that angle, and the DID number pillar guide ties the cluster together.
Frequently asked questions
How should a UCaaS platform manage phone numbers across customers?
Model each number as a resource with explicit lifecycle states, such as available, reserved, assigned, quarantined and porting, and enforce which transitions are allowed. Link numbers to tenant routing profiles and regulatory documents by reference rather than storing that data on each number.
What is number aging or quarantine?
Number aging is holding a released phone number out of circulation for a period before reassigning it, so calls and messages meant for the previous owner stop arriving. It protects both the new owner’s experience and the previous owner’s privacy.
Should ISVs keep a buffer of DID numbers in stock?
For popular area codes with light regulatory requirements, a small buffer sized to provisioning lead time and daily consumption makes checkout instant. For number types that need end-user documents before assignment, buffering usually isn’t possible.
How do I prevent assigning the same phone number to two customers?
Make number reservation idempotent, using an idempotency key and a single atomic state change from available to reserved with an expiry. Retries then return the same number, and expired reservations are swept back into stock.
Why reconcile number inventory with the carrier?
Your records and the carrier’s inventory drift over time because of failed webhooks, manual changes and completed ports. A regular reconciliation finds numbers you’re paying for but not using, and numbers you think you hold but don’t.
Talk to our numbers team
Building number management into your platform? Talk to the IDT Express numbers team about sourcing DID inventory across your markets.


