Table of Contents

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.