NAT and SIP trunks
Getting media through NAT and registering against SIP trunks are the two most common real-world hurdles. This guide covers what the SDK does automatically and what you must configure.
Choosing the SIP transport
By default the SDK sends outbound requests over UDP. Enterprise proxies that only accept TCP or TLS can be served by setting the default transport once:
using var client = new VoipClient(new VoipConfiguration
{
DefaultTransport = SipTransport.Tls // Udp (default) / Tcp / Tls / Ws / Wss
});
With AddCalloraVoip(...) use options.DefaultTransport or builder.WithTransport(SipTransport.Tls).
A sips: scheme or an explicit ;transport= on the target URI still overrides the default per call.
Over TLS/WSS each line can present its own client certificate for mutual TLS, with a per-line trust mode and RFC 5922 SIP-domain check — two accounts to the same trunk stay isolated. See SIP over TLS (mutual TLS).
Advertised media address
The SDK resolves the media address it advertises in SDP so it does not offer a loopback or wrong-interface address to a LAN or WAN peer. In straightforward NAT setups where the PBX/trunk performs the address fix-up (symmetric RTP / latching), this is usually enough for two-way audio.
Behind CGNAT or a static 1:1 NAT where the peer does not latch to the source address, you can
force the public media IP into the SDP c= line:
new SipAccount
{
// ... credentials ...
PublicSipHost = "203.0.113.7", // public signaling contact (Contact / Via)
PublicMediaHost = "203.0.113.7" // opt-in: public IP forced into SDP media (c=)
};
PublicMediaHost is an advanced opt-in: leave it unset (the default) to keep the auto-resolved,
symmetric-RTP-friendly address. Only set it when the RTP port is preserved end-to-end; a public
address with a remapped port breaks media. Non-IP values are ignored.
ICE (opt-in)
Full ICE (RFC 8445 / RFC 7675) is implemented and opt-in — role + tie-breaker, check-list FSM,
USE-CANDIDATE nomination, inbound/triggered checks, consent freshness, restart detection and local
restart initiation (ICall.RestartIceAsync(), RFC 8445 §9). Enable it via VoipConfiguration.Ice.
It is off by default and not yet proven in production trunks, so validate it against your own
network before relying on it; without ICE, plan for a media-relaying/SBC path on hostile NAT. On the
WebRTC side the same ICE stack is exercised in CI against real browsers and a real coturn relay. A
lost (unretained) TURN relay allocation is torn down immediately with a LIFETIME=0 refresh rather
than holding a port/permissions/quota until it expires (RFC 8656 §7/§3.9).
Registering against a trunk
var connect = await client.ConnectAsync(new SipAccount
{
Username = "trunkuser",
Password = "secret",
SipServer = "trunk.provider.example",
RegistrationExpiry = 300,
InboundNumbers = new[] { "4930123456" }, // DIDs you own
AcceptTrunkInbound = true
});
InboundNumbers lets the SDK match inbound requests addressed to your DIDs rather than a
registered extension AOR.
Static-IP trunks that do not register
Most trunks register — sipgate, easybell and Telekom CompanyFlex all take credentials, and the section above covers them. Some enterprise trunks instead authenticate you by source IP: there are no credentials, no registration, and the provider delivers inbound calls to an address agreed up front.
Set Register = false and pin the port you agreed on:
using var client = new VoipClient(new VoipConfiguration
{
LocalSipPort = 5060 // the port the provider delivers to
});
var connect = await client.ConnectAsync(new SipAccount
{
Username = "4930123456", // AOR user-part; not used for authentication — there is none
SipServer = "trunk.provider.example",
Register = false,
InboundNumbers = new[] { "4930123456" }
});
// Ready, not Registered — this line never registers.
Debug.Assert(connect.Line!.State == LineState.Ready);
No account user at all
Some static-IP trunks have no user-part either. Username may then be left empty, and addresses take the
host-only form sip:trunk.provider.example (RFC 3261 §19.1.1) instead of sip:user@host:
var connect = await client.ConnectAsync(new SipAccount
{
SipServer = "trunk.provider.example",
Register = false,
InboundNumbers = new[] { "4930123456", "4930123457" } // required in this mode
});
InboundNumbers is mandatory without a username, and connecting is refused otherwise. The username is
what gives a line its exact 1:1 inbound match; without it the only remaining rule is "anything addressed to
our domain", so the line would answer calls meant for a different line on the same provider domain. The DID
whitelist restores that discrimination.
Two things are easy to get wrong here:
Register = false is not ReregisterOptions.Disabled. That one only stops re-registration
after a lost binding; the initial REGISTER still goes out, which an IP-authenticated trunk does not
expect and may reject.
The local port matters. Without a registration nobody tells the provider where to reach you, so
it uses the agreed address. The default (0) takes an ephemeral port that changes on every restart
— fine for a registering line, unusable here. A port already in use fails at client construction
rather than quietly landing elsewhere, because a listener on the wrong port looks healthy while
every inbound call goes missing. For TLS use LocalSipTlsPort (conventionally 5061); it is a
separate listener and cannot share the other port.
Inbound admission works as it does for any trunk — InboundNumbers and AcceptTrunkInbound decide
which calls belong to this line.
Custom headers and caller identity
Add extra headers to an outbound INVITE for trunk/PBX routing (protected dialog/transport headers and header-injection attempts are refused):
await line.DialAsync("sip:4930999@trunk.provider.example", new DialOptions
{
CustomHeaders = new Dictionary<string, string>
{
["X-Trunk-Account"] = "acme-42"
}
});
On inbound calls the peer-asserted identity and retargeting history are read-only on the call:
var caller = call.RemoteAssertedIdentity; // P-Asserted-Identity (RFC 3325), trusted peers only
var chain = call.DiversionChain; // every address it was forwarded from, oldest first
var lastHop = call.Diversion; // first URI of the first Diversion row (RFC 5806)
DiversionChain is the one to route on. Two headers answer "where was this call forwarded from" —
Diversion (RFC 5806) and History-Info (RFC 4244) — and carriers differ on which they send. Reading
one of them directly makes your integration correct with part of the market and silently blind with
the rest, where a forwarded call then looks exactly like a direct one. DiversionChain reads both and
normalises them into one order: the number the caller originally dialled at the front, the party that
forwarded it to you at the back.
The two headers are ordered opposite ways, which is why this is not a matter of picking either. Diversion
is most-recent-first. History-Info is oldest-first by its index parameter and lists targets rather
than forwarders, so its last entry is where the request currently is — you. That entry is dropped, matched
by RFC 3261 §19.1.4 URI comparison rather than string equality.
An empty chain means no retargeting was reported. It does not mean the call arrived directly: a carrier that sends neither header leaves the distinction unavailable, and nothing downstream can recover it.
Reliable provisionals
The SDK sends reliable provisional responses (RFC 3262, 100rel) only when the peer
explicitly requires them (Require: 100rel). This avoids interop friction with peers
that don't expect PRACK.
Provider notes
Interop specifics per provider/PBX live under Interop:
- FRITZ!Box — verified manually against a live device (not an automated test); source of several hardening fixes
- Asterisk — the full SIP/RTP flow is automated in CI: register, in/outbound calls with live RTP, codec negotiation, SRTP-SDES, DTMF, hold, blind & attended transfer, session timers, early media and TCP/TLS, plus a two-leg bridged call with byte-exact bidirectional media
- FreeSWITCH — the two-leg scenario matrix (bridged media, hold, transfer, DTMF, SDES) runs against a real FreeSWITCH container, but local-first and narrower than the Asterisk matrix
- sipgate, 3CX — configuration guidance only; see the matrix for the full verification status, including the browser (Chromium, Firefox) and coturn coverage
Diagnostics
Enable the SIP wire trace (Trace log level) to see the actual SDP being offered/answered when audio is one-way or absent — see Diagnostics.