This is the multi-page printable view of this section. Click here to print.

Return to the regular view of this page.

Concepts

How the gateway is put together and how a call moves through it.

Three things are worth understanding before changing anything:

  • Call flows — the ordered steps of an inbound and an outbound call, and why the order differs.
  • Routing plane — how a call is admitted, how a trunk is resolved, and what gets published.
  • Media pipeline — codecs, SRTP, jitter, DTMF, and the ways a call ends when nobody sends a BYE.

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 ═══▶│
  1. 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.
  2. 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.
  3. Answer. 200 OK + ACK, which binds RTP.
  4. 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.
  5. 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

ConditionSIP status
Empty To user400 Bad Request
Gateway is shutting down503 Shutting down
Unknown domain, unassigned number, missing config404 Not Found
Source IP not in the trunk’s ACL403 Forbidden
No dispatcher rule matched480 Temporarily Unavailable
Routing lookup failed500 Server Internal Error
Session could not be created503 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 ═══▶│
  1. Trunk first. An unknown trunk_id must not leave an orphan room behind, so the trunk is resolved before anything is created.
  2. 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.
  3. 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.
  4. 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 errorStatusMeans
ErrHostNotFound404No project owns that SIP domain
ErrNumberNotAssigned404The domain is known, the number is not
ErrConfigNotFound404The project has no SIP configuration
ErrIPNotAllowed403The INVITE came from an address the trunk’s ACL does not list
ErrNoMatchingRoute480Admitted, but no dispatcher rule matched the number
anything else500The 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.

Session metadata

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:

KeySource
project_idThe host lookup
trunk_id, trunk_nameThe resolved trunk
rule_id, rule_name, rule_typeThe dispatcher rule
agent_nameThe rule, when it names one
dispatch_metadataThe rule’s free-form metadata, JSON-encoded
name, phone_number, dialled, sip_call_idThe call
directioninbound
sourcesip-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.
  • Transportudp, 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.

Media encryption is inferred from transport

The stored trunk carries a transport but no media-encryption field, so SRTP policy is derived:

Trunk transportOffer SRTPRequire SRTP
tlsyesno
udp, tcpnono

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:

TypeWhenNotable metadata
CALL_ANSWEREDImmediately after the call is answered, before the room is joinedrouting fields, session_id, sip_call_id, direction, to_number, from_number
CALL_HANGUPWhen the bridge endsthe 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 arrivesWhat happens
In sequenceEmitted immediately, zero added latency
Out of orderHeld only until the gap resolves, bounded by a 40 ms holdout
DuplicatedDropped
NeverConcealed 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:

SignalSource
BYEThe far end, normally
session_terminatedThe 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 cancellationShutdown, after the 60 s drain window