Our browser calling overview explained what WebRTC calling is and where it fits your product. This post is the build. You will stand up a production browser softphone on Sautikit: a scoped API key, a token endpoint on your server built with @sautikit/node, a browser client built with @sautikit/webrtc, microphone and device handling, working outbound-dial and inbound-ring flows, and the refresh, reconnect, and billing details that keep it alive on a real support desk.
Browser ↔ Sautikit WebRTC/SIP gateway. The browser opens a secure WebSocket to the gateway, authenticates with a short-lived token, and negotiates audio over standard WebRTC (SDP offer/answer, ICE, SRTP). @sautikit/webrtc owns this hop; your app never touches RTP.
Gateway ↔ PSTN. The gateway translates between the browser's media session and the phone network, negotiating G.711 (PCMU) toward the telephony side. This is where a +254 mobile number becomes reachable from a browser tab.
Your server ↔ Sautikit API. The only Sautikit REST call in the whole flow: your backend mints the token the browser uses. This endpoint is the contract between your front end and Sautikit.
SIP token mint flow
The API key never leaves your server. The browser only receives a short-lived SIP token (5-minute TTL) for SIP gateway authentication.
Keep that last point in mind as you read. The token mint is the piece you own, and it is the boundary that keeps your API key out of the browser.
client.webrtc.mintToken() mints a short-lived token for one browser session. Three parameters matter:
clientName — a label for this client session, useful when you are telling agents' sessions apart.
role — what this client is, for example "agent" or "dialer".
tenantNumberIdorphoneNumber — bind the session to a number your workspace owns, by UUID or E.164. Binding associates the browser session with that number: the identity the softphone acts as.
Here is the complete token endpoint as an Express handler. Note what it does not do: it never forwards your API key, and it never mints tokens for anonymous visitors.
import express from "express";import { SautikitClient, SautikitError } from "@sautikit/node";const app = express();const sautikit = new SautikitClient({ apiKey: process.env.SAUTIKIT_API_KEY });// requireLogin is your existing session middleware.// Only your authenticated users get softphone tokens.app.post("/api/webrtc-token", requireLogin, async (req, res) => { try { const minted = await sautikit.webrtc.mintToken({ clientName: `agent-${req.user.id}`, role: "agent", phoneNumber: "+254700000001", // a number you own; or tenantNumberId }); res.set("Cache-Control", "no-store"); // Hand the browser the token, where to connect, and how long it lasts. res.json({ token: minted.token, endpoint: minted.endpoint, expiresIn: minted.expiresIn, }); } catch (err) { if (err instanceof SautikitError) { // A just-claimed number whose WebRTC profile is still provisioning is // retryable; a binding mismatch is a config problem. Log and translate. console.error("mintToken failed", err); return res.status(502).json({ error: "token_mint_failed" }); } throw err; }});
The mint result carries the token itself plus connection hints — the gateway endpoint, the client name it registered as, and expiresIn. Pass through only what the browser needs.
Three failure conditions are worth distinguishing in your own error mapping, because they mean very different things:
Condition
What it means
Number belongs to another workspace
You bound by tenantNumberId and the id is not yours. Fix the id.
No owned number matches
You bound by phoneNumber and no owned number matches that E.164. Check the format.
Profile still provisioning
The number was just claimed and its WebRTC profile has not finished setup. Retry with backoff.
Catch SautikitError rather than inspecting HTTP status codes by hand — it is the SDK's typed error, and the package README documents the fields it carries so you can branch on the platform's error code and log its request id.
Anything you ship to the browser is public. Your SAUTIKIT_API_KEY can place calls, claim numbers, and spend from your prepaid wallet — in devtools, that is one copy-paste from disaster. The token model contains the blast radius twice over.
Short-lived. WebRTC tokens expire quickly. Read expiresIn from the mint result rather than assuming a fixed TTL, and renew before it lapses. A leaked token is a problem measured in minutes, not months.
Scoped, on both sides. The token itself only authenticates a softphone session against the gateway; it is not an API credential. And on the minting side, Sautikit API keys carry scopes — mint a dedicated key with just the webrtc.token scope for the service that runs your token endpoint — a credential that declares exactly the one job it exists to do. Keys are minted in the dashboard at app.sautikit.com under Settings → API keys.
One more rule from the same threat model: gate /api/webrtc-token behind your own login, always. A token can originate billable outbound calls, so an unauthenticated mint endpoint is an open tab on your wallet.
The package exports a Client class and a WebRTCError type. You construct a Client with the gateway endpoint and the token your server just minted:
import { Client, WebRTCError } from "@sautikit/webrtc";async function connect() { const r = await fetch("/api/webrtc-token", { method: "POST" }); if (!r.ok) throw new Error(`token fetch failed: ${r.status}`); const minted = await r.json(); const client = new Client({ endpoint: minted.endpoint ?? "wss://sip.sautikit.com", // Token configuration goes here, per ClientConfig — pass the short-lived // token your server minted. See the @sautikit/webrtc README for the exact // config shape in the version you pinned. }); return client;}
endpoint is your workspace's SIP gateway WebSocket URL. Prefer the endpoint your mint call returned over a hard-coded string, so a gateway move does not require a front-end deploy.
Do not share one token across tabs or users. Each browser session should mint its own — that keeps sessions individually attributable and individually revocable.
Softphone UX lives or dies on audio plumbing, and all of it is standard web platform API:
Secure origin, user gesture.getUserMedia requires HTTPS (localhost excepted) and browsers tie the permission prompt to user interaction. Request the mic when the agent opens the softphone panel — not on page load, and definitely not for the first time while an inbound call is already ringing.
Ask once, early. For an agent desk, prompt for the microphone at login or on first opening the dialer. An agent fumbling with a permission dialog while a customer hears ringing is the single most common self-inflicted softphone bug.
Sensible constraints. Echo cancellation, noise suppression, and auto gain are what make a laptop mic usable on a call:
async function prepareAudio() { const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true, }, }); // Device labels are only populated after permission is granted. const devices = await navigator.mediaDevices.enumerateDevices(); const mics = devices.filter((d) => d.kind === "audioinput"); const speakers = devices.filter((d) => d.kind === "audiooutput"); // Headsets get plugged and unplugged mid-shift. React to it. navigator.mediaDevices.addEventListener("devicechange", refreshDeviceList); return { stream, mics, speakers };}
Device pickers.enumerateDevices returns useful labels only after permission is granted — another reason to ask early. Offer input and output pickers, remember the choice in localStorage, and use setSinkId on your audio element to route call audio to the chosen output. Listen for devicechange: agents unplug headsets mid-shift, and your softphone should fall back to the next device instead of going silent.
With a connected Client and mic permission already granted, an outbound call is one await:
async function dial(client, raw) { // Kenyan numbers arrive as "0722 000 001" and must leave as E.164. const to = normalizeKE(raw); // "+254722000001" try { await client.call(to); setState("connected"); } catch (err) { if (err instanceof WebRTCError) { setState("failed"); showToast("Could not place the call. Check your connection."); return; } throw err; }}// In-call controls, wired to your UI:muteBtn.onclick = () => client.mute(); // cuts the microphonekeypadBtn.onclick = (digit) => client.dtmf(digit); // e.g. client.dtmf("1")hangupBtn.onclick = () => client.hangup();
client.dtmf("1") is what makes your softphone useful against the outside world: agents constantly land in someone else's IVR ("press 1 for accounts") and need a keypad that actually sends tones. client.mute() cuts the microphone; check the README for the exact signature in the version you pinned when you wire the un-mute half of the toggle.
Gate the dial button. Model the call as a small state machine — idle → dialing → ringing → connected → wrap-up — and only enable the dial control when three things are true: the client is connected, the token is fresh, and mic permission is granted. Click-to-call from a CRM record is the highest-value entry point; our Salesforce integration post shows that pattern end to end.
Wrap-up matters. After hangup, hold a short wrap-up state before returning to idle — it is where notes get written, and it stops accidental immediate redials.
Register an incoming handler before you need it — inbound calls that route to this client will ring the tab, and the handler is where your ring UI lives:
client.accept() answers; client.reject() declines. Both are instant — provided mic permission was granted long before, which is the whole reason the permission step comes first in this post.
If your agents cover shifts, decide which session is "on the phones": one registered session per agent, one agent live per number, and a visible state toggle beats mystery double-ringing.
Tear the client down when the agent logs out or the component unmounts:
If your desk needs more than voice — SMS, WhatsApp, and a shared ticket queue in one pane — that is a full agent desk, and Helloduty, the multi-channel CX platform Sautikit is part of, layers those channels on the same voice stack.
Token refresh. Renew off expiresIn from the mint result, never off an assumed TTL. Schedule the refetch comfortably before expiry, and treat a failed renewal as a session-degrading event your UI surfaces before the next call attempt fails.
Reconnect with a fresh token. Wi-Fi drops and laptops sleep. On disconnect, do not retry the old token — it has likely expired. Call your /api/webrtc-token endpoint again, rebuild the client with the new token, back off exponentially with jitter, and show a clear "reconnecting" state so agents do not answer a phantom ring.
Handle a provisioning number gracefully. A number claimed seconds ago can fail the mint while its WebRTC profile provisions. Retry with backoff rather than failing onboarding on the first attempt.
Know what the PSTN leg costs. Sautikit pricing (as of 2026-06-30): outbound KES 3.00/min — KES 0.05/sec, billed per second after connect; inbound is free at KES 0/min. See /pricing for current rates. Per-second billing is kind to softphone workloads: a 45-second confirmation call bills 45 × KES 0.05 ≈ KES 2.25, not a rounded-up minute; a 3-minute support call is about KES 9. Calls debit your prepaid wallet, and calls are blocked at zero balance — so subscribe to the wallet.low_balance webhook and set your threshold with PUT /v1/wallet/threshold before your busiest desk hits a silent wall.
Observability. Set events_url on the number's routing to receive call.started, call.answered, and call.completed events, and reconcile with sautikit.calls.list() from the node SDK — the same client you built for the token endpoint — to check what agents report against what the platform saw.