Skip to content

Browser SDK

@sautipbx/voice-sdk embeds a working softphone in your web app. Drop in a <script> tag (or npm install in a bundler app), authenticate with a short-lived token minted by your backend, and you have a phone. Call, answer, hold, mute, DTMF, device selection, and rich local call events. It runs entirely in the browser over WebRTC; no plugin, no native app.

Script tag. No build step; exposes a VoiceSDK browser global:

<script src="https://cdn.jsdelivr.net/npm/@sautipbx/voice-sdk@0.2.0/dist/voice.iife.js"></script>

Bundler (React / Vue / Vite / webpack):

Terminal window
npm install @sautipbx/voice-sdk
import { Phone } from "@sautipbx/voice-sdk";

JsSIP is bundled into the package, so a single script tag is a complete, working phone: nothing else to load. Pin the exact version while you test; CDNs cache aggressively.

Your secret API key never touches the browser. The browser only ever holds a short-lived, single-extension, revocable phone token.

  1. Your backend calls POST /phone-tokens/mint with your secret API key, passing the end-user’s uuid.
  2. The mint response returns a token and an iceServers config (STUN/TURN for NAT traversal). Your backend hands only those to the browser.
  3. The browser passes them to phone.authenticate(...).

Minting on your server (never in the browser):

Terminal window
# your backend: the secret key stays here
curl https://api.sautipbx.com/api/phone-tokens/mint \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "uuid": "the-end-user-uuid" }'
{
"token": "eyJhbGc...",
"phone_token": { "uuid": "...", "expires_at": "..." },
"iceServers": [
{ "urls": "stun:..." },
{ "urls": ["turn:..."], "username": "...", "credential": "..." }
]
}

The response keys line up with the SDK, so you can hand the browser the token and iceServers and pass them straight to authenticate(). No field remapping.

A complete phone. Outbound and inbound. In one script tag:

<script src="https://cdn.jsdelivr.net/npm/@sautipbx/voice-sdk@0.2.0/dist/voice.iife.js"></script>
<script>
const phone = new VoiceSDK.Phone({ logLevel: "info" });
// { token, iceServers } came from YOUR backend's mint call
await phone.authenticate({ token, iceServers });
// outbound
const call = phone.call("+254711111111", { customPayload: '{"caseId":"CASE-0042"}' });
call.on("ringing", () => console.log("ringing…"));
call.on("accepted", () => console.log("connected"));
call.on("ended", (r) => console.log("ended", r));
// inbound
phone.on("incoming", (incoming) => {
console.log("call from", incoming.remoteIdentity);
incoming.answer(); // or incoming.reject()
});
</script>

Create one Phone, authenticate it once, then place and receive calls on it.

Option Default Notes
logLevel 'none' 'error' | 'info' | 'debug'. Logs to the console prefixed [voice-sdk]; 'debug' also enables JsSIP wire tracing.
onLog - Custom sink (level, ...args) => void. When set, log lines go here instead of the console.
iceGatheringTimeout 3000 Fallback cap (ms) on ICE gathering. Normally the call sends the instant a TURN relay candidate appears. 0 waits for full gathering.
iceServers public STUN Fallback ICE config. In production pass the per-session config from your mint response to authenticate instead.
wssUrl production Override only when testing against another box.
realm production SIP domain.
  • authenticate(token | { token, iceServers }) → Promise<void>. Registers over WSS; resolves on success, rejects if registration fails.
  • call(destination, { customPayload? }) → Call: destination is a bare extension or number ('2002', '+254711111111') or a full SIP URI.
  • unregister(): Unregister and close the connection.
  • listDevices() → { inputs, outputs }
  • setInputDevice(id) / setOutputDevice(id) / setVolume(0..1)
  • Getters: extension, account, isRegistered

registered, unregistered, registrationFailed, connected, disconnected, incoming. The incoming event hands you a Call.

Returned by phone.call(...) (outbound) and by the incoming event (inbound). One Call is one call leg.

  • answer() / reject(). Answer or decline an incoming call (reject sends SIP 486 Busy).
  • hangup(): End an active or outgoing call.
  • hold() / unhold()
  • mute() / unmute(). Toggles the local microphone.
  • sendDigit(tone): DTMF (RFC 2833), e.g. navigating an IVR.
  • Getters: direction, remoteIdentity, isOnHold, isMuted

ringing, accepted, ended, failed, hold, unhold, muted, unmuted. On ended / failed the SIP status line and cause are surfaced so you can show why a call dropped.

Besides Phone and Call, the package exports a few helpers:

  • describeFailure(reason): Turns a Call’s ended / failed reason into a one-line human summary, e.g. "402 Insufficient balance · cause=Rejected".
  • decodeToken(token) / isExpired(token). Inspect a phone token’s claims and expiry client-side, without a network round-trip.
  • DEFAULT_ICE_SERVERS: The built-in public-STUN fallback Phone uses when you do not pass iceServers.

Events: local here, platform truth on your backend

Section titled “Events: local here, platform truth on your backend”

The SDK surfaces local call events. The lifecycle above, observed directly in the browser on the phone’s own leg, with no server round-trip. That covers everything a phone UI needs.

Platform-truth events the browser cannot know. Call cost, recording ready and its URL, billing, and bridged / far-leg state. Are delivered to your backend via voice webhooks or a backend event-stream subscription, where your CDR and billing logic lives.

That split is deliberate: it keeps the client lean and avoids exposing one end-user’s call data in another’s browser tab.

For NAT traversal, always pass the per-session iceServers from your mint response. The built-in default is a public STUN server. Enough on cooperative networks, but symmetric-NAT and mobile need the TURN relay that the mint response provides.

Symptom Likely cause
authenticate rejects immediately Token expired or malformed. Mint a fresh one. Tokens are short-lived by design.
registrationFailed Token revoked, the account is over budget, or the origin is not permitted. Check the surfaced SIP reason.
Registers, but calls have no audio Insecure context (not HTTPS), or no reachable ICE candidate. Serve over HTTPS and pass iceServers from the mint.
Long stall before a call connects UDP-restricted network with no TURN relay. Ensure your mint response includes TURN iceServers.
Nothing in the console Default logLevel is 'none'. Set 'info' or 'debug', or wire an onLog sink.