Trunks, Outbound & PSTN
A SIP trunk is a first-class connection between SIP.IO and a SIP peer: a PSTN carrier, a bring-your-own-carrier (BYOC) gateway, or a customer’s PBX. One sip_trunk model covers all three, in either direction, with the wholesale carrier available as the zero-config default for everything a trunk doesn’t handle.
The SIP trunk
Section titled “The SIP trunk”{ "id": "tr_01jh5j4wq2c57wv738rf5d2b1w", "account_id": "acc_01jf18ah3jeb5w6dfp27sgjsbt", "name": "Acme carrier", "provider": "generic", "role": "carrier", "direction": "both", "proxy": "sip.carrier.example:5060", "transport": "udp", "do_register": 0, "inbound_auth": "ip", "max_channels": 30, "cid_number": "15550009999", "status": "active"}Three columns classify a trunk:
| Column | Values | Meaning |
|---|---|---|
role | carrier · pbx | A PSTN gateway, or a customer phone system. |
direction | inbound · outbound · both | Which legs this trunk handles. |
provider | generic · carrier · … | generic = manual SIP config; carrier = the built-in wholesale carrier. |
The rest of the row is split by leg direction: how we reach the peer (outbound) and how the peer reaches us (inbound).
Outbound: reaching the peer
Section titled “Outbound: reaching the peer”For the outbound leg, the trunk says where to send the INVITE and how to authenticate to the peer:
| Field | Purpose |
|---|---|
proxy | host[:port] of the peer (may be NULL for inbound-only / register-from-peer). |
transport | udp / tcp / tls. |
do_register | 1 to register to the peer (dynamic-IP carriers/PBXs). |
auth_username / auth_secret_ref | Credentials; the secret is a the key-value store reference, never stored in the row. |
realm / from_domain / from_user | Digest realm and From overrides. |
expire_sec | Registration expiry (default 3600). |
cid_number | Default caller-ID presented on this trunk. |
max_channels | Per-trunk concurrency cap (a CAC dimension). |
Registration failover
Section titled “Registration failover”When do_register = 1 across a multi-node cluster, exactly one node may hold the upstream registration, because otherwise nodes clobber each other’s Contact at the peer (a flapping AOR). SIP.IO elects a single active registrar per trunk:
reg_nodenames the designated primary node.- Each node heartbeats; a node is alive within a 90-second window.
- If the designated
reg_nodeis alive it owns the registration; if it’s down, ownership deterministically falls to another alive node (so a dead node’s trunks spread out instead of piling onto one survivor), and is reclaimed when the primary recovers.
The control plane holds only the assignment and liveness; the SBC syncs the elected registration into its outbound-registration (UAC) state.
Inbound: the peer reaching us
Section titled “Inbound: the peer reaching us”For the inbound leg, inbound_auth sets how a peer proves who it is:
inbound_auth | How the peer is identified |
|---|---|
ip (default) | Source IP matched against the trunk’s allow-list. |
register | The peer registers to us (using inbound_username + inbound_secret_ref). |
digest | The peer sends digest credentials on each request. |
inbound_username / inbound_secret_ref hold the credentials we assign the peer (the secret is a the key-value store reference).
Inbound identification by source IP
Section titled “Inbound identification by source IP”For inbound_auth = 'ip', allowed source addresses live in sip_trunk_acl:
{ "trunk_id": "tr_01jh5j4wq2c57wv738rf5d2b1w", "cidr": "203.0.113.4" }When an INVITE arrives without an authenticated account, the SBC passes the source IP to the routing brain, which maps it to a trunk, and thus an account, by matching sip_trunk_acl for an active, inbound-capable, IP-auth trunk:
INVITE from 203.0.113.4 └─▶ match sip_trunk_acl.cidr (status=active, inbound_auth=ip, direction ∈ {inbound,both}) ├─ hit → bind account → continue routing (extension or outbound pipeline) └─ miss → reject 403 "unidentified_source"The SBC also allow-lists these IPs at its own edge; the worker-side match is account binding plus defense in depth. A PBX placing an outbound call (its to is a PSTN number) flows straight into the outbound pipeline below; a call to an internal extension routes like any other.
Receive-on-PBX
Section titled “Receive-on-PBX”The reverse direction, delivering an inbound DID to a customer PBX, is a route target. A did (or any object) with dest_kind: "trunk" rings the trunk’s PBX:
{ "e164": "15551115555", "dest_kind": "trunk", "dest_id": "tr_01je1xz6cs9fp102qawd74vz3k" }The leg is built to the trunk’s proxy (or its registered Contact, for do_register/register peers) while preserving the called DID as the request-URI, so the PBX sees the number that was dialed. The trunk’s cid_number is presented if set, otherwise the original caller-ID passes through.
Outbound routes
Section titled “Outbound routes”An outbound_route matches a dialed number to a destination, longest-prefix by priority:
{ "id": "route_01jhbtw98vkxqerdczqs3j410k", "account_id": "acc_01jf18ah3jeb5w6dfp27sgjsbt", "name": "US via trunk", "match_prefix": "1", "match_country": "US", "via": "trunk", "trunk_id": "tr_01jh5j4wq2c57wv738rf5d2b1w", "priority": 10, "status": "active"}| Field | Purpose |
|---|---|
match_prefix | Dialed-number prefix to match ('' = catch-all). |
match_country | Optional ISO-3166 alpha-2. |
via | carrier (the wholesale default) or trunk. |
trunk_id | The sip_trunk, when via: "trunk". |
priority | Lower wins. |
The first active route whose prefix matches builds the leg: via: "trunk" reads the sip_trunk and emits a directive with its proxy, transport, registration, and From/CID fields; via: "carrier" hands off to the wholesale carrier.
The wholesale default
Section titled “The wholesale default”If no trunk route matches, calls terminate via the default wholesale carrier (carrier). Each SIP.IO account maps to a verifiable subaccount on the carrier, and the outbound INVITE carries the subaccount identity and a token in X- headers. This subaccount model is what enables proper STIR/SHAKEN attestation and per-subaccount billing on outbound: the carrier signs the Identity header at egress.
The outbound pipeline
Section titled “The outbound pipeline”Putting it together, when an authenticated device dials a PSTN number the brain runs:
dial ─▶ account number-transform (outbound_dst) ─▶ CAC admit (account / user / trunk / rate) ─▶ select outbound_route (by prefix/country, priority) ─▶ trunk-level number-transform (if via trunk) ─▶ resolve caller-ID (by destination country) + outbound_cid transform ─▶ directive: sip_trunk OR wholesale carrier (+ token)Number transforms
Section titled “Number transforms”number_transform rows are ordered regex rewrites, applied at specific points in the pipeline. One mechanism covers everything:
| Field | Purpose |
|---|---|
scope_kind / scope_id | account / trunk / did / route. |
direction | inbound_dst / inbound_cid / outbound_dst / outbound_cid. |
match_regex / replacement | The rewrite. |
ordering | Apply order. |
stop_on_match | Stop processing further rules on a match. |
For example, strip a dialing prefix on outbound_dst at account scope, then format the number for a specific carrier on outbound_dst at trunk scope.
Caller-ID presentation
Section titled “Caller-ID presentation”For each outbound call, the brain resolves the presented caller-ID via caller_id_rule rows, choosing the best match by scope (user over account), then destination-country specificity, then priority:
{ "id": "cid_01ja65vtvewx9txajnfjfy0n5t", "account_id": "acc_01jf18ah3jeb5w6dfp27sgjsbt", "scope_kind": "account", "match_country": "GB", "cid_number": "442071234567", "cid_name": "Acme UK", "priority": 10}This lets you present a local number per destination country. The resolved number is then run through any outbound_cid transforms. If no rule matches, the device’s own caller-ID is kept (or the trunk’s cid_number, on a trunk leg).
Per-user caller-ID
Section titled “Per-user caller-ID”A user can have a selectable set of caller-IDs they’re allowed to present, in the user_caller_id table, each internal (extension-to-extension identity) or external (a PSTN number), with one default per kind:
{ "user_id": "us_dana", "kind": "external", "value": "442071234567", "label": "UK line", "is_default": 1 }The caller-ID a user requests on a call is validated against this allowlist: a requested number that isn’t one of the user’s own is silently dropped (anti-spoofing), falling back to their default and then the account caller_id_rule. The same model stamps the user’s identity on internal extension-to-extension calls.
Related controls
Section titled “Related controls”- Toll-fraud gate: an outbound PSTN call must come from an active, account-owned device (anonymous or cross-account identities are rejected
403), a brain-side defense-in-depth check on every outbound leg. Brain-initiated legs (dialer, callback, transfer) from trusted media nodes are exempt. - Geo / fraud policy:
geo_policyrestricts which destination countries are callable (anti-IRSF), rejecting with a media prompt. - DNC lists:
phone_list(kinddnc_out) blocks outbound to listed numbers. - Concurrency & rate: outbound is subject to account
max_out, per-usermax_simultaneous, per-trunkmax_channels, and sliding-window rate rules.
Status & roadmap
Section titled “Status & roadmap”Live: the sip_trunk model; outbound via generic trunks (with/without registration); registration failover; inbound identification by source IP; receive-on-PBX (dest_kind: "trunk").
Roadmap:
- Inbound digest verification: the
digest/registerinbound-auth columns exist; per-request credential verification at the SBC/authpath is being wired. - CIDR containment:
sip_trunk_aclmatches exact host entries today; subnet (/24) containment is a follow-up. carrieras a first-class provider row: the wholesale carrier is the built-in default; modeling it as aprovider: "carrier"trunk is planned.