What a number row binds together
Each row in the list is onephone_number record, read from Supabase under
your org’s RLS scope and ordered by e164. The add/edit modal exposes these
fields:
The Trunk dropdown is populated from the
trunk table for your org and
displays display_name, falling back to the carrier trunk_id when the
trunk has no display name.
The Agent dropdown is populated from agent_config filtered to
active = true. An agent you have deactivated will not appear in the list,
so you cannot newly bind a number to it.
The list row renders the binding as a single line —
number_type · country · trunk → agent — with a status tag on the right, plus
an edit and a delete button.
Accepted number formats
The modal validatese164 against /^\+?\d{7,15}$/, which mirrors the BFF’s
phoneNumberSchema exactly. The leading + is optional because the API
accepts a plus-less national number; the form deliberately does not demand a
character the API does not require. Anything else (letters, punctuation,
too few or too many digits) is rejected client-side with
“Enter the number in international format”.
country, if filled, must be exactly two letters.
If the list fails to load, the screen says so explicitly —
“This is a load failure, not an empty list” — rather than rendering the empty
state. Treat that message as a Supabase/RLS problem, not as “you own no
numbers”.
What happens when you save
Save callsadmin.upsertPhoneNumber({ orgId, number }). That procedure does
four things in order, and all four matter for whether the call actually
routes.
-
Ownership check. The bound
agent_idis asserted to belong to the same org. Binding a number to another org’s agent fails here. -
Supabase write. With an
idpresent it is anUPDATEconstrained by bothidandorg_id; without one it is anINSERT. The saved row is read back and drives everything that follows. -
Redis dual write. Two keys are written from the same JSON payload:
The payload carries only the fields the C++ resolver needs:
- Dialplan rebuild. The engine’s dispatch blob is rebuilt so the inbound call routes to the bound agent instead of parking.
phone_number.added or phone_number.updated analytics
event carrying e164, number_type, country, provider, and whether an
agent and a trunk were bound.
Status values and inbound reachability
status is not cosmetic. It is the switch that decides whether the number
exists at all as far as the gateway is concerned.
Only
active publishes. Saving a number as pending or inactive keeps the
Supabase row — so the number stays in your list and keeps its trunk and agent
binding — while removing it from the lookup path. Flipping it back to active
re-writes both keys from the current row.
The list tags active in the “ok” tone and every other status in the “warn”
tone, so a non-routing number is visible at a glance.
Trunk ids: database row vs carrier identifier
There are two different things called a trunk id, and the distinction is the single most common source of “the number is saved but nothing routes”.trunk.id— the Postgres primary key (uuid/serial). This is what the Trunk dropdown stores inphone_number.trunk_id, because it is a foreign key.trunk.trunk_id— the carrier-supplied identifier, for exampleINT-KA-SBC09-9287. This is the keytrunk_managerin the C++ gateway is indexed by.
upsertPhoneNumber reads the trunk row by
its primary key and resolves it to the carrier trunk_id string. The payload
then carries the carrier identifier as trunk_id and keeps the primary key
separately as trunk_db_id.
Without that resolution the gateway would look up the numeric database id,
find no matching trunk, and drop the call. If the trunk row is missing or has
no carrier trunk_id, the payload’s trunk_id is written as null — the
number is still indexed, but with no carrier trunk attached.
Why an unpublished agent drops inbound calls
The engine serves published agent snapshots only. Binding a number to an agent that has never been published produces a row that looks correct in Supabase and Redis but silently drops inbound calls: the DID resolves, the agent id is present, and there is no snapshot for the engine to run. The screen guards against this. It runsagentVersions.publishedAgents({ orgId }) once for the whole org, refetching
every 30 seconds, and compares each row’s agent_id against the returned
list. A number bound to an agent that is not in that list gets a not
published warning tag with the tooltip:
This agent has never been published — inbound calls to this number cannot reach it. Publish it on the agent’s Versions tab.The tag is advisory. The save is not blocked, and the Redis payload is still written with that
agent_id. Fix it by publishing the agent from its Versions
tab; the tag clears on the next poll.
Note that this is a different condition from an agent that is simply
deactivated. Deactivated agents never appear in the modal’s dropdown at all,
because the picker filters agent_config to active = true. The
“not published” tag is about an agent that exists and is active but has no
served snapshot.
Deleting a number safely
The delete button asks for confirmation (Delete <e164>?) and then calls
admin.deletePhoneNumber({ orgId, id }). The procedure:
- Reads the existing row’s
e164before deleting it — it needs the number to know which Redis field and key to remove. - Deletes the Supabase row, constrained by both
idandorg_id. - Removes the per-tenant hash field and the flat DID index entry.
- Rebuilds the dialplan rules blob.
phone_number.deleted event with the e164.
If you only want to stop taking calls on a number temporarily, set its status
to inactive instead. That removes it from both Redis keys with the same
practical effect on routing, but keeps the row, the trunk binding, and the
agent binding intact for when you turn it back on.
Related
- Authentication — how console and gateway calls are scoped to an org
- Telemetry — CDR fields recorded for the calls these numbers receive