SIP trunking
CallAgent terminates SIP itself through FreeSWITCH rather than wrapping a managed carrier, so bringing your own trunk is a first-class path: your carrier, your DIDs, your rates.
Use it when you already have a carrier relationship, when your numbers cannot move, or when you need a trunk that CallAgent has no adapter for. If you have none of those, a managed number is less work.
The honest state
Read this before you plan a migration
No SIP trunk call has ever reached an assistant.
Trunk authentication works and is proven against a real SIP peer: registration, digest challenge, credential failure reporting, ACL admit and refuse, re-registration before expiry. All of that is real.
What does not work is the step after admission — handing the media to the voice gateway. An inbound INVITE is admitted, stamped with its DID and trunk, transferred to the media-handoff extension, and dies there with handoff-failed. The cause is that the FreeSWITCH dialplan expects an outbound ESL socket on port 8084 and the gateway binds one port, 3010, for HTTP and WebSocket. Nothing listens on 8084.
Everything below about configuring a trunk is accurate and worth doing. Everything below stops short of audio. Full detail in What is not built.
The model
Four objects, in this order:
| Object | What it is |
|---|---|
| Trunk | The relationship with a carrier: credentials or an IP allowlist, codecs, DTMF mode, transport, number formatting, limits. |
| Gateway | One host to send to, or receive from, under that trunk. A trunk with several gateways gets failover; each is health-checked separately. |
| DID | An inbound number, or a pattern, on that trunk, and where a call to it should go. |
| Route | Outbound: which trunk a dialled number leaves through. |
Each gateway becomes one generated Sofia profile file on the FreeSWITCH side, named with the gateway's ULID. That name is what the dialplan dials and what an inbound call arrives tagged with, so it is stable across renames.
Create a trunk
The telephony API is camelCase. (The assistants API is snake_case; see API conventions.)
curl -X POST "$CALLAGENT_API_URL/telephony/sip-trunks" \
-H "Authorization: Bearer $CALLAGENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Carrier A",
"direction": "both",
"authMode": "credentials",
"username": "1002001",
"password": "…",
"realm": "sip.carrier-a.example",
"register": true,
"registerExpirySeconds": 3600,
"transport": "udp",
"codecs": ["PCMA", "PCMU", "G722"],
"dtmfMode": "rfc2833",
"inboundRequireAcl": true,
"inboundSourceAcl": ["203.0.113.0/24"],
"outboundCallerIdNumber": "+40312345901",
"maxConcurrentCalls": 30
}'Then at least one gateway:
curl -X POST "$CALLAGENT_API_URL/telephony/sip-trunks/$TRUNK_ID/gateways" \
-H "Authorization: Bearer $CALLAGENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "sbc-1",
"host": "sbc1.carrier-a.example",
"port": 5060,
"priority": 10,
"weight": 100
}'Gateways are tried by priority ascending, then weight descending within a priority. A gateway with failoverOnly: true sorts last whatever its priority.
Every trunk field
| Field | Values | Default |
|---|---|---|
direction | inbound · outbound · both | — |
authMode | credentials · ip · both | credentials |
register | boolean | false |
registerExpirySeconds | 60–86400 | 3600 |
retrySeconds | 5–3600 | 30 |
transport | udp · tcp · tls | udp |
srtpPolicy | disabled · optional · required | disabled |
verifyTlsPeer | boolean | |
codecs | list of names | ["PCMA","PCMU","G722","OPUS"] |
dtmfMode | rfc2833 · inband · info | rfc2833 |
optionsPingEnabled, optionsPingSeconds, keepaliveSeconds | 5–3600 | |
inboundRequireAcl, inboundSourceAcl | CIDRs | |
inboundDestinationType / Id / Value | the fallback when no DID matches | |
inboundStripDigits, inboundNumberPrefix | number normalisation | |
outboundCallerIdNumber / Name / Privacy | ||
leadingPlus | keep · strip · add · international_prefix | |
internationalPrefix, outboundPrefix, outboundStripDigits | ||
allowSipUriDialing, customSipHeaders | ||
maxConcurrentCalls, maxCallsPerSecond |
password is encrypted by the model and is never returned by any endpoint.
When authMode is ip, the generated Sofia XML omits username, password and auth-username entirely rather than writing placeholders, so a mistyped IP-auth trunk fails visibly instead of registering as a broken credentials trunk.
Codecs
codecs is emitted as absolute_codec_string — the exact list, in that order, with no negotiation fallback. G.729 is not in the default list and is not in the shipped FreeSWITCH module set. The gateway normalises everything to 16 kHz mono PCM internally, so an 8 kHz trunk and a 48 kHz browser meet the same pipeline.
DTMF
Three modes, per trunk because carriers disagree:
dtmfMode | Sofia value | Note |
|---|---|---|
rfc2833 | rfc2833 | Default. Out-of-band RTP events. |
info | info | SIP INFO messages. |
inband | none | Sofia does not detect; CallAgent's own Goertzel detector reads the audio. Fragile under transcoding — expect misses on a G.729 or heavily transcoded leg. |
dtmfDurationMs defaults to 2000 and interDigitDelayMs to 120.
A drift worth knowing about
The TypeScript contract's DtmfModeSchema declares five values — rfc2833, rfc4733, sip_info, inband, auto. The PHP enum that actually generates the Sofia configuration has three: rfc2833, inband, info. Configure through the API, using the three.
Routing an inbound call
When a call arrives, the resolver tries these in order and takes the first match:
- an inbound route matching the number, in priority order;
- the number's dynamic hook, if one is configured;
- the number's static destination;
- the trunk's fallback destination (
inboundDestinationType/Id/Value) — SIP inbound only.
For a trunk specifically, step 3 and 4 are preceded by the trunk's own DID table: SipDid rows are matched first, then a phone_numbers row whose number equals the normalised DID, then the trunk fallback.
No match returns nothing, and the caller is rejected. That is deliberate: routing a call you cannot identify to a default assistant is how one tenant's caller reaches another tenant's agent.
The tenant is bound from the row that matched, never from anything in the request.
DIDs
curl -X POST "$CALLAGENT_API_URL/telephony/sip-trunks/$TRUNK_ID/dids" \
-H "Authorization: Bearer $CALLAGENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"number": "+40312345901",
"destinationType": "assistant",
"destinationId": "'"$ASSISTANT_ID"'",
"priority": 100
}'A DID can match by exact number or by pattern with a matchType. Destination types are the same ten as everywhere else: assistant, squad, queue, ivr, ring_group, extension, voicemail, external, sip_uri, hook.
Where the DID is read from is per trunk: inboundDidSource is request_uri (the default), to_header, or header with inboundDidHeader naming it. Carriers disagree about this more than about anything else.
The four test endpoints
They exist because "the trunk is configured" and "the trunk works" are different claims. Be precise about which claim each one supports.
| Endpoint | What it actually does |
|---|---|
POST .../test/options | Sends a SIP OPTIONS to every gateway and records the response. A real packet on the wire. |
POST .../test/register | Attempts a REGISTER and records the result, including the carrier's reason on failure. A real packet on the wire. |
POST .../test/inbound | A configuration readiness check, not a call. It verifies the trunk allows inbound, that the ACL is not empty-and-required, and that a DID mapping or fallback destination exists. It reports last_inbound_at if a call has ever arrived, and otherwise says so in as many words: "No inbound call has arrived on this trunk yet — place one to confirm end to end." |
POST .../test/outbound | Takes a destination and attempts a real call through the trunk. |
GET .../diagnostics returns the recorded results, newest first. GET .../config returns the generated Sofia XML per gateway plus the ACL fragment, so you can diff it before applying.
Apply the generated config
Writing the XML and making FreeSWITCH reload it is php artisan callagent:sip:apply. php artisan callagent:sip:options-probe sends OPTIONS to every enabled gateway on a schedule and records the result.
In-dialog SIP requests
sipRequest tools send an in-dialog request mid-call — carrier-specific supervision, or a transfer with custom headers. See Tools.
Proving it without a carrier
The repository ships a test peer. telephony/sip-peer/ is a Kamailio container that behaves like a carrier on two sockets — digest authentication on 5060, IP authentication on 5062 — behind the sip-lab compose profile.
./telephony/sip-peer/prove.shIt proves five things against real sockets: a challenged REGISTER that succeeds; a wrong password that fails with 401 and surfaces the reason on the trunk row; an INVITE admitted from an allowed address; an INVITE refused with 403 from one that is not; and re-registration before expiry.
It cannot prove what only a carrier can: SHA-256 digest, TLS and SRTP against a real SBC, NAT and keepalive quirks, real DIDs, caller-ID presentation, billing, or 302/Diversion handling. And it cannot prove audio, for the reason at the top of this page.
API
GET/POST /api/v1/telephony/sip-trunks
GET/PATCH/DELETE /api/v1/telephony/sip-trunks/{id}
POST /api/v1/telephony/sip-trunks/{id}/test/options
POST /api/v1/telephony/sip-trunks/{id}/test/register
POST /api/v1/telephony/sip-trunks/{id}/test/inbound
POST /api/v1/telephony/sip-trunks/{id}/test/outbound
GET /api/v1/telephony/sip-trunks/{id}/diagnostics
GET /api/v1/telephony/sip-trunks/{id}/config
GET/POST /api/v1/telephony/sip-trunks/{trunk}/gateways
PATCH/DELETE /api/v1/telephony/sip-trunks/{trunk}/gateways/{id}
POST /api/v1/telephony/sip-trunks/{trunk}/gateways/{id}/test
GET/POST /api/v1/telephony/sip-trunks/{trunk}/dids
PATCH/DELETE /api/v1/telephony/sip-trunks/{trunk}/dids/{id}Full parameters and responses: SIP trunks, gateways, DIDs.