Agents on aiDialer, our own multi-tenant AI call-centre platform, needed a phone without a desk phone. The brief was a softphone inside the web portal that does what a receptionist’s handset does: register, ring, answer, hold, blind and warm transfer, three-way calls, call pickup and intercom, all against FreeSWITCH. The SIP library was never the hard part. Five problems cost real time, and every one of them was about timing or network topology rather than code. Here is each one, with what the switch actually did, because in every case the browser showed something different.
The Architecture
| Layer | What we used | Why |
|---|---|---|
| SIP in the browser | JsSIP 3.13.8 | Mature SIP over WebSocket with REFER and Replaces, which warm transfer needs. It ships no browser bundle, so we build one in Docker with a pinned esbuild and record every bundled package’s version and integrity hash. |
| Signalling | SIP over secure WebSocket, one address per workspace | Each workspace is its own SIP domain, so a login cannot drift into another tenant. |
| TLS | nginx ends TLS with the platform’s wildcard certificate, then plain WebSocket to FreeSWITCH | FreeSWITCH’s own secure WebSocket listener presents a self-signed certificate that browsers refuse. |
| Media | DTLS-SRTP negotiated over ICE, with FreeSWITCH as the media endpoint | The switch has to own the media to hold, transfer, record and conference the call. |
| Logins | A twelve-hour, browser-only SIP password, separate from the extension’s desk-phone secret | A leaked browser credential expires within a day, and the switch accepts it only on the browser’s login path. |
1. Every Reply Vanished Behind the TLS Proxy
The first registrations went out and nothing came back. The browser speaks secure WebSocket and says so in its Via header. nginx ends the TLS, so FreeSWITCH receives plain WebSocket, reads a Via that asks for a secure transport, and sends its reply over a secure WebSocket transport it does not have. The reply is simply dropped.
The fix is one line: set JsSIP’s socket.via_transport to WS so the Via describes the hop the switch actually sees. We measured it rather than assumed it. Through the proxy, a WS Via got its 401 challenge back and a WSS Via got nothing at all.
2. Answered Is Not Joined
This was the expensive one. Both browsers show a call as connected when the SIP answer arrives. FreeSWITCH only joins a WebRTC leg to the rest of the call once that leg’s ICE and DTLS handshakes finish; until then the answered leg waits and the caller’s leg stays in early media.
While the hosting provider’s firewall was still dropping inbound UDP, that handshake never finished, so two browser calls were never joined even though both screens said they were talking. Once the media ports were opened the gap fell to a few hundred milliseconds, and it still mattered. Completing a warm transfer 160 milliseconds after the colleague answered made the switch park the caller and cancel the colleague, and merging into a three-way call was refused.
The phone now enables Transfer, Add call, Complete transfer and Merge calls only when the call’s RTCPeerConnection reports a connectionState of connected, and shows “Connecting audio…” until it does.
Read the switch, not the screen. show channels gives each leg’s real state, and the event socket at log level 7 shows the DTLS handshake reaching READY and the CHANNEL_BRIDGE event. Every problem in this write-up was invisible from the browser.
3. “Accepted” Arrived Before Anything Happened
A warm transfer holds the caller, rings the colleague as a second call, and completes with a REFER that carries a Replaces header for that second call. The switch reports the REFER as accepted before it has joined the caller and the colleague. Hanging up the agent’s legs at that moment, which is what the obvious code does, made the switch cancel the colleague. The switch ends the agent’s legs itself once the join is done, so the phone now waits and only hangs up a leg that is still up five seconds later.
Hold had the same shape. JsSIP’s hold and unhold return false and send nothing while the previous re-offer is still being prepared, so a quick hold-resume silently did nothing; the phone now retries every 250 milliseconds for up to eight seconds, then tells the agent. JsSIP’s isOnHold also changes when the request is sent, not when the switch confirms it. A merge that trusted it left 0.24 seconds before the switch’s 200 OK, so the merge now waits for JsSIP’s own succeeded callback.
4. A Conference That Started Too Early
Merging moves the agent’s first call and its other party into a conference room, sends the colleague’s leg after them, and ends the agent’s second leg. The server checks both calls belong to the agent first. The first version read the switch’s list of calls, which also pairs a leg with a phone that is still ringing, so a merge went ahead while the colleague’s phone rang and their call failed. A merge now requires both ends of both calls to be active or held.
5. Workspace Isolation Had to Be Enforced in the Directory
Every workspace is its own SIP domain, which isolates registrations and calls. Presence, the feature that shows which colleagues are busy, exposed a gap our tests caught before launch. In our configuration the switch authenticated a SUBSCRIBE against the domain in the subscriber’s own From header, not the domain it was subscribing to, so a phone in one workspace could subscribe to an extension in another and see its calls.
The fix sits in the directory lookup the switch uses to authenticate every request. A login whose target is another workspace is refused, and SUBSCRIBE, PUBLISH and MESSAGE must name the login’s own domain. The same lookup is what keeps the twelve-hour browser password usable only from the browser path.
What the Network Needed
Signalling worked from day one; audio did not. The hosting provider’s firewall dropped all unsolicited UDP, including the RTP media range, and nothing inside the server could show it. Opening UDP 16384 to 32768 at the provider gave calls audio in both directions.
We also tested whether a TURN relay on port 443 could avoid that rule. A relay allocated from inside the server reached Google’s STUN server and back, but a capture on the host saw no packet at all delivered to the server’s own public address, which is where the switch listens for media. So TURN was not the fix here. TURN for customers whose networks block UDP is a separate piece of work, and it has to start by proving relay-to-switch delivery.
How It Was Tested
- A three-browser end-to-end suite covering register, ring, answer, hang up, hold and resume, blind and warm transfer, three-way calls, auto-answer and call-back: 48 of 48 checks on three consecutive runs, with the switch logging a three-member conference room in every run.
- Presence, browser call pickup and intercom: 16 of 16 checks.
- A two-browser audio test confirming media in both directions.
- 39 unit tests for the phone’s call-state logic, which run as part of the platform’s QA suite.
Two switch faults turned up on the way, and both stopped every call to an extension, desk phones included. The dialplan bridged calls to a tenant identifier instead of the workspace’s SIP domain, and a forced registration domain filed every phone under the switch’s own address, so no lookup by workspace found anyone. Neither was a browser problem; the softphone simply exercised paths nothing else had.
A browser softphone on FreeSWITCH is less about the SIP library and more about three facts the browser hides: a proxy changes the transport the switch sees, an answered WebRTC leg is not joined until its handshakes finish, and SIP isolation between tenants is something you enforce rather than inherit. Gate call controls on real media state, verify everything on the switch, and test with more than one browser. If you are building calling into a web product, our WebRTC team does this work end to end, or you can hire a WebRTC developer into your own team.