@datavoice/webphone-sdk
v0.4.6
Published
Framework-agnostic TypeScript SDK for integrating DataVoice WebPhone, SIP/WebRTC calling, and real-time PBX events through SignalR.
Maintainers
Readme
@datavoice/webphone-sdk
Enterprise-grade TypeScript SDK for SIP/WebRTC telephony and Asterisk PBX integration in the browser.
Provides SIP registration, bidirectional audio calls, hold/mute/transfer/DTMF/conference, real-time PBX event streaming via the DataVoice Dispatcher hub, and call history — behind a typed, event-driven API with no jQuery or framework dependencies.
Requirements
| | Minimum |
|---|---|
| Browser | Chrome 80+ / Edge 80+ / Firefox 78+ |
| Node.js (build) | 18.0.0+ |
| Page context | HTTPS or localhost — required for getUserMedia and WSS |
| SIP peer dep | sip.js ^0.21.0 |
| Dispatcher peer dep | @microsoft/signalr ^8.0.0 (optional) |
Installation
npm install @datavoice/webphone-sdk sip.js@^0.21.0
# Only if using PBX Dispatcher event streaming:
npm install @microsoft/signalr@^8.0.0REST-only usage (DataVoicePlatform — Users, DialPlan, Telephony), no SIP calling:
import from the /platform subpath instead of the package root. It has zero peer
dependencies — neither sip.js nor @microsoft/signalr is ever resolved:
npm install @datavoice/webphone-sdkimport { DataVoicePlatform } from '@datavoice/webphone-sdk/platform';Importing DataVoicePlatform from the package root also works and is fully
backward compatible, but the root entry bundles DataVoiceWebPhone alongside it,
which does require sip.js/@microsoft/signalr to be installed even if you never
use them. Use the root entry only when you also need SIP calling in the same app.
Quick Start
import { DataVoiceWebPhone } from '@datavoice/webphone-sdk';
const phone = new DataVoiceWebPhone({
sip: {
extension: '3402',
password: 'your-sip-password', // never log or persist
realm: 'pbx.datavoice.com.mx',
wsServer: 'wss://pbx.datavoice.com.mx:8089/ws',
displayName: 'Agent Name',
},
media: {
iceServers: [{ urls: 'stun:ns3.datavoice.com.mx:3478' }],
},
// Optional: PBX event streaming
dispatcher: {
url: 'https://dvlite.datavoice.com.mx:2443',
},
logging: { level: 'warn' },
});
// Attach remote audio to an <audio> element
//
// Every event handler receives the full { type, timestamp, payload } envelope,
// not the payload flattened onto the handler argument — destructure `payload`
// first, then read fields off it.
phone.on('media:remote:stream', ({ payload: { stream } }) => {
const audio = document.getElementById('remoteAudio') as HTMLAudioElement;
audio.srcObject = stream;
audio.play().catch(console.error);
});
// Connect (register SIP + connect Dispatcher)
await phone.connect();
// Make a call
const call = await phone.call('7000');
// Handle incoming calls
phone.on('call:incoming', async ({ payload: { call } }) => {
await phone.answer(call.id);
});
// Hang up
await phone.hangup(call.id);
// Clean disconnect
await phone.disconnect();Required HTML element:
<audio id="remoteAudio" autoplay></audio>Configuration
sip (required)
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| extension | string | — | SIP extension number |
| password | string | — | SIP auth password. Never log or persist. |
| realm | string | — | SIP domain, e.g. "pbx.datavoice.com.mx" |
| wsServer | string | — | WebSocket URL: "wss://pbx.datavoice.com.mx:8089/ws" |
| displayName | string | extension | Caller ID name |
| registerExpires | number | 600 | REGISTER expiry in seconds |
| traceSip | boolean | false | Log raw SIP messages (debug only) |
| logLevel | 0–3 | 0 | SIP.js verbosity (0=errors, 3=debug) |
| routeSet | string | — | Route header for WebRTC-to-SIP gateway proxies |
media (optional)
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| audio | boolean | true | Request microphone |
| video | boolean | false | Request camera |
| iceServers | RTCIceServer[] | DataVoice STUN | STUN/TURN servers |
| iceCheckingTimeout | number | 1000 | ICE gathering timeout in ms |
dispatcher (optional — PBX event streaming)
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| url | string | — | Dispatcher hub URL |
| accessToken | string | — | JWT bearer token (if hub requires auth) |
| staleStateThresholdMs | number | 30000 | ms before disconnected state is considered stale |
signalR (optional — legacy command hub)
For the legacy ASP.NET SignalR 1.x hub (agentConnectHub). Requires jquery.signalR-2.4.0 loaded via CDN and the four workspace context IDs (IdEspacioTrabajo, IdEquipoTrabajo, IdUsuario, IdTipoUsuario) from the DataVoice backend.
storage (optional)
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| callHistory | CallHistoryProvider | LocalStorageCallHistoryProvider | Swap the storage backend |
| localStorageKey | string | "sipCalls" | localStorage key |
| maxHistoryEntries | number | 20 | Maximum retained history entries |
Available providers: LocalStorageCallHistoryProvider, IndexedDBCallHistoryProvider, InMemoryCallHistoryProvider, NullCallHistoryProvider.
ui (optional)
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| answerMode | 'ring' \| 'auto-answer' \| 'do-not-disturb' | 'ring' | Initial answer mode |
| maxConcurrentCalls | number | 0 (unlimited) | Max simultaneous calls |
Events
Subscribe with phone.on(event, handler) or wildcard phone.onPattern('call:*', handler).
Every handler receives the full { type, timestamp, payload } envelope — not the
payload flattened onto the handler argument. Destructure payload (or nested-destructure
straight through it, as below) to get at the event's actual fields.
Registration & Transport
phone.on('registration:registered', ({ payload: { extension } }) => {});
phone.on('registration:unregistered', () => {});
phone.on('registration:failed', ({ payload: { cause } }) => {});
phone.on('system:transport:sip:connected', () => {});
phone.on('system:transport:sip:disconnected', ({ payload: { error } }) => {});Call Lifecycle
phone.on('call:incoming', ({ payload: { call } }) => {}); // incoming INVITE
phone.on('call:created', ({ payload: { call } }) => {}); // outgoing INVITE sent
phone.on('call:ringing', ({ payload: { call } }) => {}); // 180 Ringing
phone.on('call:answered', ({ payload: { call } }) => {}); // session established
phone.on('call:connected', ({ payload: { call, hasVideo } }) => {}); // media flowing
phone.on('call:held', ({ payload: { call } }) => {});
phone.on('call:resumed', ({ payload: { call } }) => {});
phone.on('call:completed', ({ payload: { call, historyEntry } }) => {}); // BYE / ended
phone.on('call:failed', ({ payload: { call, cause } }) => {});
phone.on('call:dtmf:sent', ({ payload: { callId, digit, method } }) => {}); // confirmed; method: 'rfc4733' | 'info'
phone.on('call:dtmf:failed', ({ payload: { callId, digit, statusCode } }) => {}); // far end rejected (SIP INFO path)
phone.on('call:dtmf:received', ({ payload: { callId, digit } }) => {});
phone.on('call:transfer:blind:started', ({ payload: { call, destination } }) => {});
phone.on('call:transfer:blind:completed', ({ payload: { callId, destination } }) => {});
phone.on('call:transfer:blind:failed', ({ payload: { call, cause } }) => {});Media
phone.on('media:remote:stream', ({ payload: { callId, stream, hasVideo } }) => {
audioEl.srcObject = stream; // always wire this
});PBX Events (Dispatcher hub)
phone.on('pbx:agent:connected', ({ payload: { agentExtension, queue, callerIdNum, callGlobalId, pbxId } }) => {});
phone.on('pbx:agent:completed', ({ payload: { agentExtension, queue, talkTimeMs, callGlobalId } }) => {});
phone.on('pbx:call:hangup', ({ payload: { channel, cause, causeText, callGlobalId } }) => {});
phone.on('pbx:event:unknown', ({ payload: { eventType, rawPayload } }) => {});
phone.on('pbx:transport:connected', () => {});
phone.on('pbx:transport:disconnected', () => {});
phone.on('pbx:transport:reconnecting', () => {});
phone.on('pbx:state:reset', () => {}); // state stale after long disconnectSystem
phone.on('system:error', ({ payload: { code, message, detail } }) => {});
phone.on('system:state:changed', ({ payload: { state, previous } }) => {});Wildcard
onPattern handlers also receive the full envelope as a single argument (not
(event, payload) — the event is the payload-bearing envelope):
phone.onPattern('call:*', (event) => { console.log(event.type, event.payload); });
phone.onPattern('pbx:*', (event) => { console.log(event.type, event.payload); });
phone.onPattern('*', (event) => { console.log(event.type, event.payload); }); // every eventCall Control
// Lifecycle
await phone.connect();
await phone.disconnect();
await phone.destroy(); // permanent; create new instance to reconnect
// Calls
const call = await phone.call('1001');
await phone.answer(callId);
await phone.reject(callId);
await phone.hangup(callId);
// Mid-call
await phone.hold(callId);
await phone.unhold(callId); // automatically holds all other active calls
await phone.mute(callId);
await phone.unmute(callId);
await phone.sendDTMF(callId, '5');
// Transfer
await phone.transfer(callId, '1002'); // blind
await phone.transfer(callId, '1003', 'attended', consultId); // attended
// Conference (client-side Web Audio mixing; one bridge at a time)
await phone.conference([callIdA, callIdB]); // every call must be 'active'
await phone.leaveConference();
// State
const state = phone.getState();
const call = phone.getCall(callId);
// History
const entries = await phone.getCallHistory();
await phone.clearCallHistory();
// Answer mode
phone.setAnswerMode('ring'); // manual answer (default)
phone.setAnswerMode('auto-answer'); // immediate answer when idle
phone.setAnswerMode('do-not-disturb'); // reject all incoming callsNext.js Integration
SIP.js and WebRTC require a browser context — they cannot run server-side. The canonical Next.js pattern is:
// app/page.tsx
import dynamic from 'next/dynamic';
// Excludes WebPhone + entire SIP.js import graph from the server bundle
const WebPhone = dynamic(() => import('../components/WebPhone'), { ssr: false });
export default function Page() {
return <WebPhone />;
}// components/WebPhone.tsx
'use client'; // ← required for useState, useEffect, useRef
import { useRef, useEffect } from 'react';
import { DataVoiceWebPhone } from '@datavoice/webphone-sdk';
export default function WebPhone() {
const phoneRef = useRef<DataVoiceWebPhone | null>(null);
// Cleanup on unmount (and React Strict Mode double-invoke)
useEffect(() => {
return () => { phoneRef.current?.destroy().catch(() => {}); };
}, []);
// ...
}Also add to next.config.ts as a safety net:
webpack: (config, { isServer }) => {
if (isServer) {
config.externals = [...(config.externals ?? []), 'sip.js', '@datavoice/webphone-sdk'];
}
return config;
},HTTPS is required. Start dev with
next dev --experimental-httpsor use mkcert. Seeexamples/nextjs/for the complete working example.
Blazor (.NET) Integration
For Blazor WebAssembly and Blazor Server applications, use the provided interop layer
instead of importing the SDK directly. The SDK is a JavaScript library — Blazor
consumes it via IJSRuntime.
Copy these files into your Blazor project:
wwwroot/js/webphone-interop.js ← JS module bridge
WebPhoneSDK/WebPhoneService.cs ← C# DI service
WebPhoneSDK/WebPhoneConfig.cs ← C# config models
WebPhoneSDK/WebPhoneEvents.cs ← C# event records
Components/WebPhonePanel.razor ← Example Razor componentRegister the service:
// Program.cs
builder.Services.AddScoped<WebPhoneService>();Use in a component:
@inject WebPhoneService Phone
@code {
protected override void OnInitialized()
{
Phone.OnRegistered += async p => { ... };
Phone.OnCallIncoming += async p => await Phone.AnswerAsync(p.Call.Id);
Phone.OnCallCompleted += async p => { ... };
}
private async Task ConnectAsync() =>
await Phone.ConnectAsync(new WebPhoneConfig { Sip = new SipConfig { ... } });
}See examples/blazor/ for the complete working example including the interop bridge,
event models, and a full WebPhonePanel.razor component with call controls.
Advanced: Direct Transport Access
Bypass the facade to access the transports directly (useful for diagnostics). Note that
SipJsTransport/DispatcherTransport handlers receive the event's fields directly —
not the { type, timestamp, payload } envelope that DataVoiceWebPhone's public
on() uses (see Events above). These are two distinct event systems.
import { SipJsTransport, DispatcherTransport } from '@datavoice/webphone-sdk';
const sip = new SipJsTransport(sipConfig, mediaConfig);
sip.on('call:tracks:remote', ({ stream }) => { audioEl.srcObject = stream; });
await sip.connect();
await sip.register();Call Correlation
The Dispatcher's callGlobalId (format: "pbx71-1781901741.2139") is the correct cross-service identifier. The uniqueId field is per-PBX only — do not use it for cross-system correlation.
To correlate Dispatcher events with a SIP dialog, inject ${UNIQUEID} into a custom SIP header from the Asterisk dial plan:
; extensions.conf
exten => _X.,n,SIPAddHeader(X-DataVoice-CallId,${UNIQUEID})Then read it from invitation.request.getHeader('X-DataVoice-CallId') in a custom SipTransport subclass.
Security
- Never pass SIP passwords as URL query parameters. Use the constructor config object.
- Never log
sip.password. The SDK redacts it automatically in all internal logging. - TURN credentials must be time-limited. Generate them server-side using HMAC-SHA1 (RFC 8489 §9.2). Do not use static TURN credentials.
- The SDK holds no credentials in any persistent store. All credential fields exist only in memory for the lifetime of the
DataVoiceWebPhoneinstance.
HTTPS
getUserMedia() and WebSocket upgrade to wss:// both require a secure context. Serve this SDK from HTTPS or localhost.
Local development with mkcert:
mkcert -install
mkcert localhost 127.0.0.1
# produces: localhost-key.pem localhost.pemBuild & Test
npm install
npm run build # tsup → dist/index.{mjs,cjs,global.js} + .d.ts
npm run typecheck # tsc --noEmit
npm run test # vitest unit tests
npm run test:coverage # coverage report
# Development example page (https://localhost:3000)
npm run dev:examplesWhat is not yet in this release
- Device enumeration and explicit, consumer-triggered mid-call microphone switching UI (the audio resilience subsystem can rebind automatically on device disconnect, but there's no
getDevices()/setMicrophone()-style API yet). - Legacy ASP.NET SignalR 1.x hub full integration (requires
jquery.signalRCDN + four workspace context IDs from the DataVoice backend). The interface is complete; the test tool supports it via configurable fields.
Changelog
Full history: CHANGELOG.md
0.2.3 — 2026-07-20
- Added
AlreadyQueueMemberError—sdk.telephony.queues.addAgent()now throws this typed error (instead of a genericServerError) when the interface is already a member of the queue, matching the production Telephony API Gateway's actual behavior of returning502instead of409for this case. Catch alongsideConflictErrorfor idempotent queue joins — see Queue Integration for the known limitation and full pattern.removeAgent()and other502responses are unaffected.
0.2.2 — 2026-07-16
- Fixed
sdk.telephony.queues.addAgent()sendingPausedas a JSON number (0/1) instead of a JSON boolean — rejected400by any backend with a strongly typedbool Pausedfield. Confirmed and fixed against the production Telephony API Gateway. Audited the rest of the SDK for the same class of bug; this was isolated. Added regression tests that previously didn't exist for this module.
0.2.1 — 2026-07-13
- Fixed all event-handling examples in this README — handlers receive the full
{ type, timestamp, payload }envelope, not the payload flattened onto the handler argument (e.g.({ payload: { extension } }) => {}, not({ extension }) => {}). Also fixedsystem:transport:sip:disconnected's documented field name (error, notreason) andonPattern's single-argument signature. No code changes — docs only.
0.2.0 — 2026-07-01
DataVoicePlatform— new REST-only facade (Users, DialPlan, Telephony/Queues modules), independent of SIP callingAuthProviderhierarchy (BearerTokenAuthProvider,ApiKeyAuthProvider,NoAuthProvider) and typed API error hierarchy (ApiError+ 9 subclasses)- Multilingual Getting Started guide (English/Spanish/Portuguese) — see
docs/getting-started/
0.1.0-alpha — 2026-06-22
- SIP registration over WSS (SIP.js 0.21.x)
- Incoming and outgoing audio calls
- Hold / Unhold / Mute / DTMF / Blind Transfer / Attended Transfer
- Auto-answer and Do-Not-Disturb answer modes
- HoldAll — automatic hold of other calls when one becomes active
- Dispatcher hub PBX event streaming (AgentConnect / AgentComplete / Hangup)
- Call history with pluggable storage (localStorage, IndexedDB, in-memory, null)
- Exponential backoff reconnection for Dispatcher hub with stale-state detection
- TypeScript declarations, ESM + CJS + IIFE builds
License
UNLICENSED — DataVoice internal / client use only.
