1 - Call flows
The ordered steps of an inbound and an outbound call — and why the orders differ.
Both legs end in the same place: a bridgedCall handed to sfugw.Run, which
moves audio until one side stops. Everything before that differs.
Inbound
The gateway is the answering party. telephony.SIPRuntime invokes
gateway.OnInvite for every out-of-dialog INVITE.
carrier gateway resolver control plane
│ INVITE ────────▶ │ │ │
│ │ 1. Resolve ───────────▶│ │
│ │ (host, number, IP) │ │
│ ◀── 4xx/5xx ──── │ ◀── reject ────────────│ │
│ │ │ │
│ │ 2. CreateSession ──────┼────────────────▶│
│ ◀── 200 OK ───── │ 3. Accept │
│ ──── ACK ──────▶ │ │
│ │ 4. PublishEvent CALL_ANSWERED ─▶ resolver│
│ │ 5. JoinSession ────────┼────────────────▶│
│ ◀═══ RTP ══════▶ │ ◀════════ bridge ══════╪═════ WebRTC ═══▶│
- Route.
(host, dialled number, source IP) → project, trunk ACL,
dispatcher rule. A rejection here is a specific SIP status the carrier can
act on, not a blanket 503. See routing. - Room. An
X-Session-Id header on the INVITE wins if present; otherwise
a session is created, carrying the dispatcher rule in its metadata so the
platform can see what the room is for. The header only chooses the room — it
cannot overrule admission. - Answer.
200 OK + ACK, which binds RTP. - Tell.
CALL_ANSWERED is published before the WebRTC handshake, not
after. That event is what summons the agent, so the dispatcher gets to work
while the gateway is still joining. - Join and bridge. A seat is taken in the room, then audio flows.
The room is joined only after the call is answered. On this path “accepted”
is the pickup, so no seat is taken until there is a live call to put in it —
a join ticket is only valid for about 30 seconds, and a participant appearing in
the room is the agent’s cue to start talking.
Rejections
| Condition | SIP status |
|---|
Empty To user | 400 Bad Request |
| Gateway is shutting down | 503 Shutting down |
| Unknown domain, unassigned number, missing config | 404 Not Found |
| Source IP not in the trunk’s ACL | 403 Forbidden |
| No dispatcher rule matched | 480 Temporarily Unavailable |
| Routing lookup failed | 500 Server Internal Error |
| Session could not be created | 503 Session unavailable |
The project-id lookup is deliberately best-effort: a call that routed cleanly is
not dropped because that second lookup missed. The dispatcher loses a field, the
caller keeps their call.
Outbound
The gateway is the calling party. POST /sip/originate splits into a
synchronous half the caller can act on and an asynchronous half it cannot.
caller gateway resolver control plane
│ POST ──────────▶ │ │ │
│ │ auth: Bearer <ulai key> │ │
│ │ 1. GetOutboundTrunk ───▶│ │
│ ◀── 404/502 ──── │ ◀── not found ──────────│ │
│ │ 2. CreateSession ───────┼────────────────▶│
│ ◀── 202 ──────── │ (unless session_id given) │
│ {session_id} │ │
╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ │ ╌╌ background ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ │
│ │ 3. INVITE ──▶ trunk, block through ring │
│ │ 4. PublishEvent CALL_ANSWERED ─▶ resolver │
│ │ 5. JoinSession ─────────┼────────────────▶│
│ │ ◀════════ bridge ═══════╪═════ WebRTC ═══▶│
- Trunk first. An unknown
trunk_id must not leave an orphan room behind,
so the trunk is resolved before anything is created. - Room. Created unless the request carried a
session_id. Its metadata
carries the number, the trunk and the agent_id, so an orchestrator watching
the control plane’s discovery feed can be ready before the phone is answered. - Dial.
Originate blocks through ring, up to OriginateTimeout (60 s).
The dial context stays alive for the whole call — sipgo builds the client
transaction on it, so cancelling it at answer would pull the dialog out from
under the call just connected. - Tell, then 5. join and bridge, exactly as inbound.
Ringing takes up to 30 seconds and has nothing useful to say until it is over,
which is why it is not on the request path. Trunk lookup and room creation are
fast, can fail in ways the caller can act on, and produce a result the caller
needs — so they are.
If the callee never answers
Busy, rejected, no answer, unreachable trunk — the room created for the call is
terminated, because anything already waiting in it deserves to be told rather
than left listening to silence. A 429 from the trunk is logged specially: it
is the one failure that is the platform’s own fault, and it is fixed by dialling
slower, not by retrying.
What both legs do at the end
sfugw.Run returns when the SIP leg drops, the room ends, or the context is
cancelled. Then:
CALL_HANGUP is published, with duration_seconds, pairing the
CALL_ANSWERED already sent. It is published on a context detached from the
call’s own, because the call’s context is usually being torn down at exactly
that moment.- The call is removed from
GET /calls. - The session is terminated, unless the room was borrowed and
SIP_TERMINATE_SESSION_ON_HANGUP=false.
2 - Routing plane
How a call is admitted, how a trunk is resolved, and what gets published.
Everything the gateway knows about numbers, trunks and agents comes from
ulai-sip-resolver over the
platform’s Redis. The gateway holds no routing table of its own.
inbound : (host, dialled number, source IP) → project, trunk ACL, dispatcher rule
outbound : (project, trunk id) → address, transport, digest auth
Neither leg carries trunk details on the wire. Callers used to send host, port,
transport and credentials on every origination, which put carrier passwords in
every dialler’s logs and meant a trunk migration had to be rolled out to each of
them.
Inbound admission
Resolve(host, dialledNumber, sourceIP) is the whole admission decision. It
returns the trunk the call came in on and the dispatcher rule that won, or an
error that maps to a specific SIP status:
| Resolver error | Status | Means |
|---|
ErrHostNotFound | 404 | No project owns that SIP domain |
ErrNumberNotAssigned | 404 | The domain is known, the number is not |
ErrConfigNotFound | 404 | The project has no SIP configuration |
ErrIPNotAllowed | 403 | The INVITE came from an address the trunk’s ACL does not list |
ErrNoMatchingRoute | 480 | Admitted, but no dispatcher rule matched the number |
| anything else | 500 | The lookup itself failed |
Each one is a different operational problem and deserves to be distinguishable
from the carrier’s side.
The project id is a second, separate lookup (ulai:sip_host:<host>), because
the resolver’s Result does not carry it. That lookup is best-effort: if it
fails the call still proceeds, logged as continuing without a project id, and
the published events lose a field.
A room created for an inbound call carries the routing decision in its metadata,
which rides the control plane’s discovery feed. An orchestrator that has never
heard of the call can read it and know which agent the room is waiting for:
| Key | Source |
|---|
project_id | The host lookup |
trunk_id, trunk_name | The resolved trunk |
rule_id, rule_name, rule_type | The dispatcher rule |
agent_name | The rule, when it names one |
dispatch_metadata | The rule’s free-form metadata, JSON-encoded |
name, phone_number, dialled, sip_call_id | The call |
direction | inbound |
source | sip-sfu-gateway |
Empty fields are left out rather than written blank. Several of them
routinely are — rules are stored keyed by id with no id inside the value — and a
dispatcher can act on an absent rule_id where it cannot tell a blank one from
a real empty answer.
An outbound room’s metadata is the same idea with the outbound fields:
phone_number, caller_id, name, agent_id, project_id, trunk_id,
trunk_name, direction: outbound, source.
Outbound trunks
GetOutboundTrunk(projectID, trunkID) returns a stored trunk, which the gateway
reduces to dial parameters:
- Address — accepted as
host, host:port, or either with a sip:/sips:
scheme, userinfo or URI parameters attached. Port 0 means unspecified, and
the dialler fills in 5061 for TLS or 5060 otherwise. - Transport —
udp, tcp, tls, or empty (treated as UDP). Anything else
is an error rather than a silent fallback: dialling a TLS-only trunk over UDP
fails as a timeout minutes later, which is a miserable way to learn about a
typo. - Credentials — digest username and password, empty when the trunk
authenticates by IP ACL.
The stored trunk carries a transport but no media-encryption field, so SRTP
policy is derived:
| Trunk transport | Offer SRTP | Require SRTP |
|---|
tls | yes | no |
udp, tcp | no | no |
This is not cosmetic. A carrier with secure trunking enabled — Twilio’s is, on a
TLS trunk — answers an RTP/AVP offer with 488 Secure media required and the
call never rings.
Offering without requiring is the safe half: a carrier that wants SRTP finds
crypto in the offer; one that does not echoes no a=crypto and the call falls
back to plain RTP. Override per request with offer_srtp and require_srtp —
including offer_srtp: false to force cleartext media on a TLS trunk.
require_srtp implies offer_srtp; asking for the contradictory pair
(offer_srtp: false, require_srtp: true) is a 400.
Events
Two event types are published to the platform’s telecom stream, through the same
resolver that authorised the call:
| Type | When | Notable metadata |
|---|
CALL_ANSWERED | Immediately after the call is answered, before the room is joined | routing fields, session_id, sip_call_id, direction, to_number, from_number |
CALL_HANGUP | When the bridge ends | the same, plus duration_seconds |
Publishing is bounded at 2 seconds and best-effort: an event is never allowed to
hold up a call, and a miss is logged and nothing more. The hangup publish is
detached from the call’s context on purpose — that context is being cancelled at
precisely the moment the event matters most.
Startup probe
go-redis connects lazily, so without a probe the first sign of a dead routing
store would be a carrier receiving a 500. At startup the gateway looks up a
host no project can own and reports what happened:
routing store reachable
WARNING: routing store unreachable (...) — every call will be rejected until it recovers
It does not fail startup. A malformed URL already did that; a store that is
down now may be up a second from now.
3 - Media pipeline
Codecs, SRTP, jitter, DTMF, and the ways a call ends without a BYE.
The audio path
The carrier speaks 8 kHz G.711. The SFU speaks 48 kHz Opus. Everything between
the two happens inside the gateway:
uplink caller μ-law 8k ─decode─▶ PCM 8k ─resample─▶ PCM 48k ─encode─▶ Opus ─▶ room
downlink room Opus 48k ─decode─▶ PCM 48k ─resample─▶ PCM 8k ─mix─▶ μ-law ─▶ caller
Both directions run on a 20 ms cadence: 160 μ-law bytes on the wire, 960
samples per Opus frame. Opus is encoded at 24 kbit/s with complexity 5 —
wideband speech without maxing out a CPU that may be carrying many concurrent
calls.
The downlink mixes: a room can hold several publishers, and the caller gets
all of them summed into one stream. Resampling is go-audio-resampler (pure
Go, SIMD-enabled); μ-law and A-law conversion is zaf/g711. The only native
code in the binary is libopus.
Codec negotiation
The SDP profile is deliberately narrow — the audio pipeline is μ-law 8 kHz, so
accepting anything else would be a lie:
- PCMU (μ-law, PT 0) or PCMA (A-law, PT 8). When the carrier negotiates
A-law, the RTP loops transcode A-law↔μ-law at the wire boundary so everything
above stays μ-law.
- telephone-event (RFC 4733 DTMF) on whatever payload type the carrier
assigns.
- No Opus on the SIP side, no video, no multiple
m= lines.
SRTP
SDES only, with the two common profiles: AES_CM_128_HMAC_SHA1_80 (Twilio’s
default) and _32. Whether it is offered is decided by the trunk’s transport —
see routing.
Inbound RTP: reorder, dedupe, conceal
A conventional jitter buffer imposes a fixed delay on every packet. For a voice
agent that delay is charged to time-to-first-token on every turn, including
the overwhelming majority where the network was perfectly ordered. So the
inbound buffer is not a fixed-delay design:
| Packet arrives | What happens |
|---|
| In sequence | Emitted immediately, zero added latency |
| Out of order | Held only until the gap resolves, bounded by a 40 ms holdout |
| Duplicated | Dropped |
| Never | Concealed after the holdout, stream continues |
Concealment repeats the previous frame, attenuated, decaying to silence over a
few frames. Repeating preserves the spectral envelope so an ASR hears a brief
smear rather than the click-and-jump digital silence produces; decaying stops a
lost burst becoming an audible buzz.
The cost is paid only by calls that actually have a disordered network.
Outbound RTP: a small playout cushion
The provider’s writer and the RTP writer are two independent 20 ms tickers.
Without a cushion, ordinary scheduler jitter forces silence into the middle of
speech. The playout buffer builds 60 ms (3 frames) before starting and caps
added latency at 160 ms (8 frames), dropping the oldest beyond that.
Symmetric RTP and the source gate
Carriers behind NAT routinely send RTP from a port they never advertised in
SDP, so the first well-formed packet latches the peer address and the writer
re-targets to it. Everything after that is checked against the latch.
That check matters because the RTP port range is a few hundred even ports cycled
round-robin: a call that ends while its carrier is still streaming leaves
packets in flight that land on whichever call binds that port next. Before the
gate existed, they were decoded and mixed into a live conversation as a second
voice.
A genuine media re-anchor — a B2BUA leg swap, an SBC failover — is admitted
only after the new source proves persistence (5 packets over at least 200 ms).
A re-INVITE can pre-authorise an address, but the previous peer stays valid
until the new one actually speaks: a re-INVITE is an intention, and a peer that
never follows through must not be able to mute a working call.
Re-INVITE, hold, and session timers
sipgo’s OnInvite fires for every INVITE, including in-dialog ones. Handing
those to the application handler treats a mid-call re-INVITE — a session-timer
refresh, a hold, a media re-anchor, an SBC failover — as a brand new call:
a second agent, a second billing row, a 180 Ringing inside an established
dialog, a 200 OK with a new To tag (a protocol violation), and an answer
advertising a new RTP port, so the carrier moves media to a socket nobody reads.
Dead air for the rest of the call.
In-dialog INVITEs are therefore handled separately, and answered as a
re-statement rather than a negotiation: same To tag, same RTP port, same
codec, session id unchanged with its version bumped, and the direction attribute
mirrored so hold is acknowledged rather than contradicted. The only thing that
may legitimately change is where the peer wants media — and that is followed.
A re-INVITE that tries to switch G.711 flavour mid-call is refused rather than
silently answered with a lie: the provider’s transcode setting is fixed when it
is constructed.
Session timers (timer) are supported; an INVITE that Requires an extension
the gateway does not support is rejected rather than answered.
DTMF
RFC 4733 telephone-event packets are decoded and surfaced as events. The
gateway logs them (DTMF: 5) and does nothing else with them — it has no IVR of
its own. An agent in the room that wants digits should consume the session
feed, not expect the gateway to act.
Ending a call without a BYE
A hangup’s BYE can be lost. A TLS trunk calling back a UDP-only listener never
reaches the gateway at all, and without a backstop such a call — and its room —
stays up until the process exits.
SIP_RTP_TIMEOUT_SECONDS (default 30) ends a call whose inbound audio has
stopped for that long, as if the far end had hung up. It is paused while the
call is on hold, so a legitimately silent leg is not cut off. Set 0 to
disable.
The other end-of-call signals:
| Signal | Source |
|---|
BYE | The far end, normally |
session_terminated | The control plane, over the session events feed — the authoritative “this call is really over” |
Transport Done() | The WebRTC leg dropping, which may be a transient blip the SDK reconnects through |
| Drain cancellation | Shutdown, after the 60 s drain window |