Skip to content
DocsStart free

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.

{
"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:

ColumnValuesMeaning
rolecarrier · pbxA PSTN gateway, or a customer phone system.
directioninbound · outbound · bothWhich legs this trunk handles.
providergeneric · 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).

For the outbound leg, the trunk says where to send the INVITE and how to authenticate to the peer:

FieldPurpose
proxyhost[:port] of the peer (may be NULL for inbound-only / register-from-peer).
transportudp / tcp / tls.
do_register1 to register to the peer (dynamic-IP carriers/PBXs).
auth_username / auth_secret_refCredentials; the secret is a the key-value store reference, never stored in the row.
realm / from_domain / from_userDigest realm and From overrides.
expire_secRegistration expiry (default 3600).
cid_numberDefault caller-ID presented on this trunk.
max_channelsPer-trunk concurrency cap (a CAC dimension).

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_node names the designated primary node.
  • Each node heartbeats; a node is alive within a 90-second window.
  • If the designated reg_node is 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.

For the inbound leg, inbound_auth sets how a peer proves who it is:

inbound_authHow the peer is identified
ip (default)Source IP matched against the trunk’s allow-list.
registerThe peer registers to us (using inbound_username + inbound_secret_ref).
digestThe 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).

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.

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.

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"
}
FieldPurpose
match_prefixDialed-number prefix to match ('' = catch-all).
match_countryOptional ISO-3166 alpha-2.
viacarrier (the wholesale default) or trunk.
trunk_idThe sip_trunk, when via: "trunk".
priorityLower 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.

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.

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_transform rows are ordered regex rewrites, applied at specific points in the pipeline. One mechanism covers everything:

FieldPurpose
scope_kind / scope_idaccount / trunk / did / route.
directioninbound_dst / inbound_cid / outbound_dst / outbound_cid.
match_regex / replacementThe rewrite.
orderingApply order.
stop_on_matchStop 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.

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

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.

  • 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_policy restricts which destination countries are callable (anti-IRSF), rejecting with a media prompt.
  • DNC lists: phone_list (kind dnc_out) blocks outbound to listed numbers.
  • Concurrency & rate: outbound is subject to account max_out, per-user max_simultaneous, per-trunk max_channels, and sliding-window rate rules.

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/register inbound-auth columns exist; per-request credential verification at the SBC /auth path is being wired.
  • CIDR containment: sip_trunk_acl matches exact host entries today; subnet (/24) containment is a follow-up.
  • carrier as a first-class provider row: the wholesale carrier is the built-in default; modeling it as a provider: "carrier" trunk is planned.