@didlogic/voice-sdk
v1.6.0
Published
DIDLogic Voice SDK — drop-in WebRTC dialer widget and headless SIP client for browser-based softphone integration.
Maintainers
Readme
@didlogic/voice-sdk
Drop-in WebRTC dialer widget and headless SIP client for embedding on a DIDLogic partner's website. The partner's customers place calls straight from the page; the SIP infrastructure and billing live on the DIDLogic side, under the partner's account.
Contents
- What each version adds
- Architecture
- Install
- Quickstart — ESM
- Quickstart — React
- PBX session provider
- Setup on the DIDLogic portal
- Setup on your backend (proxy)
- Routing numbers from your backend
<dl-dialer>attributes- Events
- Receiving calls with the widget
- Call history widget
- Theming
- Headless API
- Click-to-Call
- Browser support
- Versioning
- License
What each version adds
Every section below names the release it arrived in (Since 1.x.0); anything without a marker works on every version. Check the version you have installed before relying on a feature:
npm ls @didlogic/voice-sdk| Version | Released | Adds |
| ------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| 1.6.0 | 2026-10-07 | Call history: <dl-call-history> widget, history proxy command, fetchCallHistory, CommandError, Dialer.dial |
| 1.5.0 | 2026-09-16 | Caller ID selection: caller_ids proxy command, caller-id attribute, Settings selector, fetchCallerIds, Device.callerId |
| 1.4.0 | 2026-09-06 | Dialer.sessionProvider; built-in incoming call screen (incoming-calls); visible transport loss and registration retry |
| 1.3.0 | 2026-09-05 | DeviceOptions.sessionProvider — passwordless registration from short-lived session grants |
| 1.2.0 | 2026-07-23 | DeviceOptions.audioProcessing — echo cancellation, noise suppression, auto gain, mono |
| 1.1.0 | 2026-07-21 | Inbound calls: Device incomingCall event, VoiceCall.answer() / reject(), calledNumber, <dl-dialer> inboundCallHandler |
| 1.0.1 | 2026-05-28 | number attribute to prefill the dialpad |
| 1.0.0 | 2026-05-07 | <dl-dialer> widget, headless Device / VoiceCall, CallCredentialsManager, Click-to-Call, theming |
Inbound calls work from 1.1.0 with your own UI (inboundCallHandler on the
widget, or incomingCall on Device). The ready-made Answer/Reject screen needs
1.4.0. The full history is in CHANGELOG.md.
Architecture
┌──────────┐ POST {command:"call"} ┌────────────────┐ POST + api_token ┌──────────────────┐
│ Browser │ ────────────────────────► │ Your backend │ ───────────────────► │ DIDLogic API │
│ <dl-...> │ │ (proxy) │ │ /mobile/api/sdk │
└──────────┘ ◄──────────────────────── └────────────────┘ ◄─────────────────── └──────────────────┘
{session_id, session_pwd, {session_id, session_pwd,
wss_url, expires_in} wss_url, expires_in}The key principle: the api_token (a.k.a. SdkApiToken) lives on your backend only. The widget talks exclusively to your proxy; the proxy adds the token and forwards the request to DIDLogic. The widget gets back ephemeral SIP credentials (TTL ~1 hour), registers over WSS, and places the call.
Install
npm install @didlogic/voice-sdkQuickstart — ESM
import '@didlogic/voice-sdk'
document.body.innerHTML = `
<dl-dialer
proxy-url="https://your-backend.example.com/webrtc"
brand-name="Acme Phone"
></dl-dialer>
`The side-effect import registers every custom element. After that, <dl-dialer> renders from any framework as a regular web component. In the legacy credential mode, the proxy-url attribute is required — it points to your backend endpoint that issues short-lived SIP credentials (see Setup on your backend).
Quickstart — React
import '@didlogic/voice-sdk'
export function PhoneWidget() {
return <dl-dialer proxy-url="https://your-backend.example.com/webrtc" brand-name="Acme Phone" />
}The package automatically augments JSX.IntrinsicElements for React 18 (global JSX) and React 19 (the react module's JSX), so <dl-dialer> is recognised in TSX with no extra declaration.
For other JSX runtimes (Vue / Svelte / Solid / Preact), add your own augmentation against the widget's exported classes.
PBX session provider
Since 1.4.0 on
<dl-dialer>;DeviceOptions.sessionProviderfor the headlessDevicesince 1.3.0.
sessionProvider is a JavaScript property of type WebRtcSessionProvider.
It takes precedence over proxy-url: the widget creates
Device({ sessionProvider }) and never starts CallCredentialsManager in this
mode. Voice Core obtains a fresh one-use grant on startup and reconnect, and
validates the grant before connecting. No SIP password is required.
import { Dialer } from '@didlogic/voice-sdk'
import type { WebRtcSessionGrant, WebRtcSessionProvider } from '@didlogic/voice-sdk'
const sessionProvider: WebRtcSessionProvider = async () => {
const response = await fetch('/demo/session', {
method: 'POST',
credentials: 'same-origin',
cache: 'no-store',
signal: AbortSignal.timeout(10_000),
})
if (!response.ok) throw new Error(`Connection unavailable (${response.status})`)
return (await response.json()) as WebRtcSessionGrant
}
const dialer = new Dialer()
dialer.brandName = 'PBX phone'
dialer.sessionProvider = sessionProvider
dialer.incomingCalls = true
document.body.append(dialer)/demo/session is an example host-owned BFF route, not an SDK endpoint. The BFF
holds the PBX L4 key, fixes the permitted tenant/user and application Origin,
and returns only the grant fields id, sip_uri, wss_url, connect_before
with Cache-Control: no-store. The provider must return a newly issued grant
on every invocation, use a bounded request, and throw a sanitized error on
failure. Never cache grants or keys in logs or browser persistent storage.
The provider can be assigned before mounting or after element creation. Lit
coalesces configuration changes and maintains one Device for the effective
auth configuration. An identical provider assignment, brand update, or ignored
proxy-url update does not reconnect. Changing the provider function, changing
the effective legacy proxy URL, or switching auth modes ends the old session
and starts a new Device. Use a stable provider function (for example
useCallback in React 19); React 18 assigns it through a ref.
Removing the element destroys its Device, detaches widget-owned call listeners and cancels delayed call-screen resets. Reattaching starts a fresh session; an obsolete pending grant cannot create a connection. A provider request that is already in flight may finish, but Core discards its result. The provider owns its HTTP timeout. Registration failures show an error and Retry uses Core revalidation to request a fresh grant. A transport connection alone never shows Ready; SIP registration does.
To receive calls in this mode, see Receiving calls with the widget.
Setup on the DIDLogic portal
To let the widget place calls, you need to create an SDK token in your DIDLogic account — a long-lived bearer token your backend will inject into requests.
What an SDK token is
- 1 token = 1 DIDLogic user (the user calls are placed and billed against).
- Each token gets its own SIP account, created with the token and labelled
SDK Instance: <token name>in the portal. Every widget orDevicestarted with that token registers as this account. - Can be enabled / disabled / revoked at any time — deactivation tears down active sessions immediately.
How to get one
In the DIDLogic portal open API & Webhooks → SDK Tokens and create a token. The token value is shown once; copy it straight into your backend secrets.
Once you have the token:
- Put it in your backend secrets (env, vault — anything, as long as it's not in the browser and not in git).
- Make sure the user the token belongs to has an active DID with outbound calling enabled.
- DIDLogic-side rate limits:
- 60 requests per minute per token
- 120 requests per minute per IP
Exceeding either →
429 Too Many Requests.
- If the user is blocked (negative balance, suspended), DIDLogic returns
403 Forbidden.
How API errors surface in the widget
| HTTP from DIDLogic | What <dl-dialer> shows |
| ----------------------- | --------------------------------------------------------------- |
| 401 Unauthorized | "Authentication failed" (token invalid / revoked) |
| 403 Forbidden | "Access denied" (user blocked, limits) |
| 429 Too Many Requests | "Too many requests, please retry later" |
| Any 5xx | Auto-retry with exponential backoff (cap 5 min, up to 10 tries) |
Setup on your backend (proxy)
The proxy is a single HTTP endpoint on your side that:
- Accepts a
POSTfrom the widget with JSON{ "command": "call" }— or{ "command": "caller_ids" }, which the widget sends after the credentials to fill its Caller ID selector (again on the next credentials refresh if it failed). Your own code may also send{ "command": "history" }(since 1.6.0) to read call history. - Adds an
api_tokenfield (value from env) to the body. - Forwards the request to
https://<DIDLogic-host>/mobile/api/sdk/command. - Returns the DIDLogic response as is.
Contract
From the widget to your proxy:
POST /webrtc HTTP/1.1
Content-Type: application/json
{ "command": "call" }From your proxy to DIDLogic:
POST /mobile/api/sdk/command HTTP/1.1
Content-Type: application/json
{ "command": "call", "api_token": "<SDK_API_TOKEN>" }Add "agent_uid": "<your user's id>" to name which of your end-users this
session belongs to, so one number can ring one of them instead of every browser
holding the token. It is 1 to 32 letters, digits, underscores or hyphens, and it
is refused rather than trimmed. The widget never sends it: your backend knows
who is logged in, the browser does not.
The pairing is exact in both directions. A session that names an agent receives only calls whose route names the same agent, and a session that names none receives only calls whose route names none — so a number stays unreachable until one side matches the other.
DIDLogic response (forwarded to the widget verbatim):
{
"session_id": "<32 hex characters>",
"session_pwd": "<short-lived secret>",
"wss_url": "<WebSocket URL to register on>",
"expires_in": 3600
}Use wss_url exactly as returned rather than hardcoding a host: DIDLogic
chooses where the session registers.
The caller_ids command (since 1.5.0) — same request shape with
{ "command": "caller_ids" }; the response lists the numbers the SDK token's
token may present — the DIDs you purchased on DIDLogic (every number you own,
verified foreign ones included, when the account has CLI passthrough):
{
"caller_ids": [
{ "callerid": "15551234567", "country": "United States", "area": "New York" },
{ "callerid": "442012345678", "country": "United Kingdom", "area": null }
]
}A proxy that allow-lists commands must admit caller_ids; when it does not,
the widget simply shows no Caller ID selector.
The history command (since 1.6.0). The widget never sends it;
fetchCallHistory does, from your own UI. It returns the calls of the token's SIP
account, newest first, one page at a time:
POST /webrtc HTTP/1.1
Content-Type: application/json
{ "command": "history", "type": "all", "limit": 25, "before": "<next_before of the previous page>" }type is all (default), outbound, inbound or missed. limit is 10, 25
(default), 50 or 100. Omit before for the newest calls. Optional filters: number
finds calls whose number contains those digits (in from, to or the DID), and
from / to are ISO 8601 date-times bounding the call start.
{
"calls": [
{
"id": "[email protected]",
"direction": "outbound",
"status": "answered",
"from": "15551234567",
"to": "442012345678",
"did_number": null,
"started_at": "2026-10-07T10:30:00Z",
"duration": 120
}
],
"next_before": "eyJ0cyI6MTc5MTM2ODIwMCwiaWRzIjpbXX0"
}idis the call'scallidas your webhooks carry it: encrypted for an inbound call, plain for an outbound one. It isnullwhen it cannot be given.statusisanswered,missed(inbound, not answered) ornot_connected(outbound, not connected).outboundlists only connected outgoing calls andinboundonly answered incoming ones; unanswered incoming calls are undermissed, andallholds every call.did_numberisnullfor a call that did not come in on one of your DIDs.next_beforeis opaque: pass it back unchanged. It isnullon the last page.- History belongs to the token, not to an agent: every session of a token sees all of
its calls, whatever
agent_uidit named.
history errors follow DIDLogic's public API error format, one object with a
machine-readable code:
{
"error": {
"code": "validation_failed",
"message": "The request contains invalid parameters",
"details": [
{
"code": "not_included",
"field": "limit",
"message": "limit must be one of 10, 25, 50, 100"
}
]
}
}| Status | code | When |
| ------ | -------------------------- | ----------------------------------------------------- |
| 422 | validation_failed | type, limit or before is invalid; see details |
| 409 | sip_account_missing | The token has no SIP account |
| 503 | call_history_unavailable | Call history is temporarily unavailable; retry |
Refusals that happen before any command runs (401, 403, 429) keep the
{ "error": "<message>" } shape every command shares.
Reference proxy in Node.js (zero dependencies)
// proxy.mjs — node 18+
import { createServer } from 'node:http'
const PORT = process.env.PORT ?? 3000
const SDK_API_TOKEN = process.env.SDK_API_TOKEN
const UPSTREAM = process.env.UPSTREAM_URL ?? 'https://app.didlogic.com/mobile/api/sdk/command'
const ALLOW_ORIGIN = process.env.ALLOW_ORIGIN ?? '*'
if (!SDK_API_TOKEN) {
console.error('SDK_API_TOKEN env is required')
process.exit(1)
}
const cors = {
'Access-Control-Allow-Origin': ALLOW_ORIGIN,
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
}
createServer(async (req, res) => {
if (req.method === 'OPTIONS') return res.writeHead(204, cors).end()
if (req.method !== 'POST' || req.url !== '/webrtc') {
return res.writeHead(404, cors).end()
}
const chunks = []
for await (const chunk of req) chunks.push(chunk)
const body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')
const upstream = await fetch(UPSTREAM, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...body, api_token: SDK_API_TOKEN }),
})
const text = await upstream.text()
res.writeHead(upstream.status, {
'Content-Type': upstream.headers.get('content-type') ?? 'application/json',
...cors,
})
res.end(text)
}).listen(PORT, () => console.log(`proxy on :${PORT}/webrtc`))Run it:
SDK_API_TOKEN=<token> ALLOW_ORIGIN=https://your-app.example.com node proxy.mjs⚠️ Never put
SDK_API_TOKENin the browser. Not in an HTML attribute, not in a JS bundle, not in a frontend env file. Any leak of the token into client code = compromise. Keep it in server-side env only.
What the proxy must do (minimum)
- Accept only
POSTon its public path. - Handle the CORS preflight (
OPTIONS) for the domain hosting the widget. - Inject
api_tokenfrom a server-side secret. - Forward DIDLogic's body and status code as is — otherwise the widget can't handle
401/403/429correctly.
What the proxy may do (optional)
- Authenticate the call via your existing session cookie / JWT — to issue SIP credentials only to logged-in users.
- Log, rate-limit per your own users, or rewrite
commandbased on business rules —callandcaller_idsare the two commands the widget sends;historyis sent byfetchCallHistory. - Audit who's hitting the widget and how often.
Routing numbers from your backend
Your backend can point your numbers at your agents itself, instead of anyone
opening the DIDLogic portal. These are not proxy commands and the widget
never calls them — they live on their own path, /mobile/api/sdk/routes,
authenticated with the same X-Sdk-Api-Token header:
GET /mobile/api/sdk/routes # every route of this token's account
POST /mobile/api/sdk/routes # { "number": "19995550334", "agent_uid": "operator-7" }
DELETE /mobile/api/sdk/routes/:id # the id a create or a list returnedA create answers 201 with { "id", "number", "agent_uid" }. Omit agent_uid
and the number rings the sessions that named no agent. Adding a route joins the
number's existing destinations rather than replacing them, so a number can ring
several agents, and each of them is removed on its own.
Refusals come back 422 with an error string: a number that is not yours is
Number not found, the same number and agent twice is Route already exists, a
malformed agent_uid says so, and an id belonging to another token is
Route not found.
Keep these off the path your proxy forwards. A proxy relays whatever command
the browser sends, so a write placed there would be reachable from an operator's
devtools with a token that is meant to be yours alone.
<dl-dialer> attributes
| Attribute | Type | Default | Since | Description |
| ------------------------ | --------- | --------- | ----- | -------------------------------------------------------------------- |
| proxy-url | string | "" | 1.0.0 | Required without sessionProvider; URL of your proxy endpoint |
| brand-name | string | "Phone" | 1.0.0 | Title shown in the widget header |
| click-to-call | boolean | false | 1.0.0 | Scan the host page and intercept clicks on tel: links |
| click-to-call-selector | string | "" | 1.0.0 | CSS selector limiting the click-to-call scan area |
| number | string | "" | 1.0.1 | Optional initial number to prefill the dialpad on first paint |
| incoming-calls | boolean | false | 1.4.0 | Enable built-in incoming Answer/Reject UI |
| caller-id | string | "" | 1.5.0 | Number to present on outgoing calls; overrides the remembered choice |
JavaScript-only properties (assign them on the element, not as attributes):
| Property | Type | Since | Description |
| -------------------- | ------------------------------------ | ----- | ------------------------------------------------------------ |
| inboundCallHandler | InboundCallHandler \| null | 1.1.0 | Receive inbound calls in your own UI |
| sessionProvider | WebRtcSessionProvider \| undefined | 1.4.0 | Passwordless registration; takes precedence over proxy-url |
Caller ID selection
Since 1.5.0.
The widget's Settings tab offers the numbers the caller_ids command
returned. The choice is put in the SIP From of every call placed afterwards;
DIDLogic's outbound proxy presents it only when it is a DID you purchased and
otherwise picks one of the account's caller IDs at random — so a number you
do not hold is never presented. "Any of the account" leaves the pick to
the proxy.
The choice is remembered in the browser's localStorage under
dl-dialer:caller-id:<proxy-url>, so an operator keeps it across reloads and
credential refreshes; a caller-id attribute overrides it, and a remembered
number that is no longer yours is dropped. To reset, remove that key or set
the attribute.
Caller ID without the widget
Since 1.5.0.
The selector is a convenience, not the API. fetchCallerIds is the same
caller_ids command the widget sends, and it takes your transport:
import { CallerIdsError, fetchCallerIds } from '@didlogic/voice-sdk'
const controller = new AbortController()
try {
const ids = await fetchCallerIds('/webrtc', {
signal: controller.signal,
fetch: myFetchWithAuthHeaders,
})
device.callerId = ids[0].callerid
} catch (err) {
if (err instanceof CallerIdsError && err.status === 401) reauthenticate()
}CallerIdsError carries status (absent on a network failure) and the
message the proxy returned; an abort surfaces as the browser's own
AbortError, not as a CallerIdsError. device.callerId keeps the digits of
what you assign ('+1 (555) 111-0002' reads back as '15551110002'), clears
on null or blank, and throws a TypeError on a value with no digits in it.
Nothing obliges you to use CallerIdChoice — remember the operator's pick
wherever your app keeps its state.
For per-element autocomplete in IDEs that consume the Custom Elements Manifest (VS Code + lit-plugin, WebStorm, Storybook), use the custom-elements.json shipped with the package.
Events
<dl-dialer> is self-contained: it owns its UI state and talks to the SIP stack internally. No custom events are emitted on the element itself.
If you need call lifecycle events (registered, accepted, ended, failed, …), use the Headless API. Device and VoiceCall expose typed event emitters.
Receiving calls with the widget
The widget refuses inbound calls with 486 Busy Here until you choose how they
are shown. There are two ways; if both are set, the handler wins.
| Option | Since | Who draws the ringing UI |
| -------------------------- | ----- | --------------------------------------------------------- |
| inboundCallHandler | 1.1.0 | You — your own ringing screen, ringtone and notifications |
| incoming-calls attribute | 1.4.0 | The widget — a built-in screen with Answer and Reject |
Calls reach the widget only when the DID is routed to the token's SIP account:
in the portal, give the number a destination of type SIP account and pick
SDK Instance: <token name> (after saving it shows as SDK). A
Webphone destination rings the DIDLogic web phone, not your widget, and a
destination pointing at another token's account rings only the widgets started
with that token.
A route may also name one of your end-users, and then it rings only the session that named the same one — see Routing numbers from your backend, which also lets you create and remove routes without the portal.
Built-in incoming call screen
Since 1.4.0.
<dl-dialer proxy-url="https://your-backend.example.com/webrtc" incoming-calls></dl-dialer>Set incoming-calls (HTML) or incomingCalls = true (JavaScript) to show the
built-in incoming call screen with Answer and Reject. Answer uses the selected
microphone; answering disables repeated accepts while retaining Hang up.
Connected calls reuse the existing duration, mute, keypad and hangup controls.
Microphone and call failures remain visible in the header.
Your own incoming call UI
Since 1.1.0.
Assign inboundCallHandler on the element to own the incoming UI. It takes priority over the built-in incoming-calls screen.
const dialer = document.querySelector('dl-dialer')
dialer.inboundCallHandler = (call) => {
// Your ringing UI, ringtone and notifications.
showRinging({ from: call.remoteIdentity, to: call.calledNumber })
answerButton.onclick = () => call.answer()
rejectButton.onclick = () => call.reject()
call.on('accepted', () => showInCall())
call.on('ended', (e) => hideCallUi(e.cause))
call.on('failed', (e) => hideCallUi(e.cause))
}inboundCallHandler is a property, not an attribute — assign it on the element instance. It is read when a call arrives, not when the widget starts, so it does not matter whether you assign it before or after the SDK module loads.
In React
React 19 assigns unknown props on custom elements as properties, so the JSX form works directly:
<dl-dialer
proxy-url="https://your-backend.example.com/webrtc"
inboundCallHandler={(call) => showRinging(call)}
/>React 18 and earlier have no property path for custom elements — every prop goes through setAttribute and is stringified, so a function silently never arrives. There, use a ref:
const ref = useRef<Dialer>(null)
useEffect(() => {
const el = ref.current
if (!el) return
el.inboundCallHandler = (call) => showRinging(call)
return () => {
el.inboundCallHandler = null
}
})
return <dl-dialer ref={ref} proxy-url="https://your-backend.example.com/webrtc" />Note the effect has no dependency array — it reassigns on every render, which is both cheap and correct here because the widget reads the handler when the call arrives. With [] the handler would capture the first render's state and go stale.
The property is deliberately not called onInboundCall: React treats any custom-element prop starting with on as an event registration, stripping the prefix and calling addEventListener instead of assigning the property — so an on* name would silently never be wired up, with no error or warning.
The call you get is the same VoiceCall the headless API hands out; everything in Receiving calls below applies, including that both ended and failed are terminal.
Call history widget
Since 1.6.0.
<dl-call-history> shows the token's call history, newest first, with tabs for All,
Outbound, Inbound and Missed, a number search, a date range and a Load more
button. It reads through the same
proxy as <dl-dialer>, using fetchCallHistory, so the proxy must forward the
history command.
<dl-dialer id="phone" proxy-url="https://your-backend.example.com/webrtc"></dl-dialer>
<dl-call-history
proxy-url="https://your-backend.example.com/webrtc"
dialer="phone"
></dl-call-history>| Attribute | Type | Default | Since | Description |
| ----------- | -------- | ------- | ----- | ---------------------------------------------------------------------------- |
| proxy-url | string | "" | 1.6.0 | Required; URL of your proxy endpoint |
| dialer | string | "" | 1.6.0 | id of a <dl-dialer> to call back through when a row is clicked; optional |
Clicking a row dispatches a dl-call-request event (detail: { number }, bubbling and
composed) with the other party's number. With dialer set, the widget also calls that
number on the linked <dl-dialer> through its public dial(number) method (since
1.6.0), which you can call yourself as well. refresh() reloads the first page, for
example after a call ends.
The widget uses the same theme variables as <dl-dialer> (see Theming).
Theming
CSS custom properties applied to <dl-dialer> or any of its ancestors:
| Variable | Default | Purpose |
| --------------------- | ---------------------------------------- | ------------------------------ |
| --dl-primary | #447bf2 | Accent (digits on the display) |
| --dl-success | #66d391 | Call button background |
| --dl-danger | #ed2643 | Hangup button background |
| --dl-danger-hover | #d41e38 | Hangup hover state |
| --dl-bg | #ffffff | Widget background |
| --dl-surface | #f0f0f0 | Inner surface tint |
| --dl-surface-hover | #e4e4e4 | Inner surface hover |
| --dl-text | #121011 | Primary text |
| --dl-text-secondary | #9298a3 | Secondary text |
| --dl-border | #e7eaf0 | Borders, separators |
| --dl-font-family | KlavikaBasicRegular, Arial, sans-serif | Default UI font |
| --dl-font-mono | PTMonoRegular, Arial, serif | Monospace (digits) |
| --dl-radius | 6px | Default corner radius |
| --dl-radius-round | 50% | Round button radius |
| --dl-width | 360px | Widget width |
| --dl-height | 680px | Widget height |
| --dl-sheet-height | 100% | Bottom-sheet (DTMF) height |
dl-dialer {
--dl-primary: #ff6b00;
--dl-success: #00aa44;
--dl-width: 320px;
--dl-height: 600px;
}The widget ships with its own fallback fonts. To restyle, override --dl-font-family / --dl-font-mono with your own stack.
Headless API
For non-widget integrations the SDK exposes the underlying classes:
import { Device, CallCredentialsManager } from '@didlogic/voice-sdk'
const credentials = new CallCredentialsManager({
proxyUrl: 'https://your-backend.example.com/webrtc',
})
const device = new Device()
credentials.onCredentials = (creds) => device.applyCredentials(creds)
credentials.onError = (err) => console.error('credentials error', err)
await credentials.fetch()
device.on('registered', () => console.log('SIP registered'))
device.on('registrationFailed', (e) => console.error('register failed:', e.cause))
device.callerId = '15551234567' // since 1.5.0: one of the account's caller IDs; null = the proxy's pick
const call = await device.call('+15551234567')
call.on('accepted', () => console.log('connected'))
call.on('ended', () => console.log('hung up'))
call.on('failed', () => console.log('call failed'))
call.mute()
call.sendDTMF('5')
call.hangup()Receiving calls
Since 1.1.0. Before it, inbound calls were refused with
405 Method Not Allowed.
Subscribe to incomingCall and the SDK hands you the ringing call. It owns
none of the experience — the ringing UI, ringtone, browser notifications and
agent availability stay yours:
device.on('incomingCall', (call) => {
// Your UI, your ringtone, your notifications.
showRingingScreen({ from: call.remoteIdentity, to: call.calledNumber })
answerButton.onclick = () => call.answer()
rejectButton.onclick = () => call.reject()
call.on('accepted', () => showInCallScreen())
call.on('ended', (e) => hideCallUi(e.cause))
call.on('failed', (e) => hideCallUi(e.cause))
})call.answer()accepts the call,call.answer({ inputDeviceId })pins a specific microphone,call.reject()refuses it with486 Busy Here, andcall.hangup()ends a call already in progress. All three are safe to call twice — a repeat is ignored rather than throwing.call.calledNumberis the DID the caller dialled;call.remoteIdentityis the caller.
Both ended and failed are terminal. A rejected or cancelled call
arrives as failed (cause Rejected, Canceled, …) and never as ended.
Code that tears its UI down only on ended leaves the ringing screen up
forever.
If nothing is subscribed to incomingCall, inbound calls are refused with
486 Busy Here. For <dl-dialer>, enable incoming-calls or assign inboundCallHandler to receive calls.
Worth knowing before shipping inbound:
- The tab must stay open and SIP-registered. There is no push wake-up for browser sessions — a closed tab means the caller is not reached at all.
- Microphone permission must already be granted on the origin.
answer()requests the microphone, so a first-time prompt lands mid-call. - Every registered browser rings at once; the first to answer wins and the others see the call end.
Call history
Since 1.6.0.
fetchCallHistory sends the history command through your proxy and pages
with its cursor:
import { CommandError, fetchCallHistory } from '@didlogic/voice-sdk'
let before: string | null = null
do {
const page = await fetchCallHistory('/webrtc', { type: 'all', limit: 25, before })
render(page.calls)
before = page.nextBefore
} while (before && userWantsMore())It takes { type, limit, before, number, from, to, signal, fetch }, like fetchCallerIds. A failure
rejects with a CommandError carrying status (absent on a network failure),
code and details from the error object (absent on a refusal from before the
command ran), and the message. CommandError is the shared failure of every proxy
command; CallerIdsError extends it. An abort passes through as the browser's
AbortError.
Audio preprocessing
Since 1.2.0.
DeviceOptions accepts an optional audioProcessing block that controls the
browser's native audio pipeline on the captured microphone track. It applies to
both outbound and inbound calls:
const device = new Device({
audioProcessing: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
channelCount: 1, // mono — roughly halves the upstream audio bandwidth
},
})All four fields are optional; anything you omit is left to the browser.
Limitations, worth knowing before you rely on this:
echoCancellation,noiseSuppressionandautoGainControlare alreadytrueby default in Chromium and Firefox. Setting themtruetherefore changes nothing — the real use of this option is to disable one (noiseSuppression: falsewhen you route music or run your own DSP) or to request mono viachannelCount: 1.- These are constraints the browser applies to
getUserMedia; support and exact behaviour vary by browser and OS, and a browser may ignore a field it does not implement. Safari in particular honours fewer of them. - It is a device-wide setting, read when a call's media is set up. There is no per-call override.
CallCredentialsManager on its own:
- caches the current credentials and refreshes them automatically
refreshBeforeExpirySec(default60) beforeexpires_in; - retries refresh failures with exponential backoff (cap 5 min, up to 10 attempts);
- supports
pauseRefresh()/resumeRefresh()(the widget pauses while a call is active so a re-register does not drop the line).
Full export list
Runtime classes:
| Export | Since | Purpose |
| -------------------------------------------------------------------------------- | ----- | -------------------------------------------------------------------------------- |
| Device, VoiceCall, AudioManager | 1.0.0 | The low-level SIP/WebRTC stack (re-exported from @didlogic/voice-core) |
| CallCredentialsManager | 1.0.0 | Proxy-backed authentication |
| Dialer | 1.0.0 | The widget class itself, in case you want to subclass it |
| CallHistory | 1.6.0 | The <dl-call-history> widget class |
| Dialpad, Keypad, CallScreen, CallControls, CallerInfo, SettingsPanel | 1.0.0 | Screen classes |
| AppBar, Select, IconButton, BottomSheet | 1.0.0 | Reusable components |
| Header | 1.6.0 | The <dl-header> bar shared by <dl-dialer> and <dl-call-history> |
| ClickToCallScanner | 1.0.0 | Programmatic page scanning |
| fetchCallerIds(proxyUrl, options?), CallerIdsError, CallerIdChoice | 1.5.0 | The caller_ids command, its typed failure, the remembered choice per proxy URL |
| fetchCallHistory(proxyUrl, options?), CommandError | 1.6.0 | The history command, and the typed failure shared by every proxy command |
Type-only:
| Since | Types |
| ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1.0.0 | DeviceOptions, DeviceCredentials, AudioDeviceInfo, DeviceEventMap, CallEventMap, CallCredentials, CallCredentialsError, CallCredentialsManagerOptions, CallStatus, SelectOption, IconButtonVariant, ClickToCallOptions |
| 1.1.0 | VoiceCallOptions, InboundCallHandler |
| 1.2.0 | AudioProcessingOptions |
| 1.3.0 | WebRtcSessionGrant, WebRtcSessionProvider |
| 1.4.0 | DlDialerProps, DlDialerJSX — the <dl-dialer> JSX prop types, for your own JSX runtime augmentation |
| 1.5.0 | CallerId, FetchCallerIdsOptions |
| 1.6.0 | CallHistoryEntry, CallHistoryPage, CallHistoryType, CommandErrorDetail, FetchCallHistoryOptions, CallRequestDetail, DlCallHistoryProps, DlCallHistoryJSX |
Click-to-Call
The click-to-call attribute turns on host-page scanning and intercepts clicks on tel: links and elements carrying data-phone / data-tel / data-number:
<dl-dialer
proxy-url="https://your-backend.example.com/webrtc"
click-to-call
click-to-call-selector=".article-body"
></dl-dialer>
<a href="tel:+15551234567">Call us</a>
<span data-phone="+15551234567">+1 555 123-4567</span>The scanner:
- catches
tel:hrefs and elements withdata-phone | data-tel | data-number; - finds bare phone numbers in text nodes via a regex;
- uses
MutationObserver— works in SPAs without page reloads; - injects a
<style id="dl-click-to-call-styles">with a CSS class.dl-click-to-call(with a hover state). Colors are configurable via--dl-click-to-call-color/--dl-click-to-call-hover-color.
For programmatic use, see ClickToCallScanner in the Headless API.
Browser support
Modern evergreen browsers with WebRTC and Web Components:
- Chrome / Edge ≥ 100
- Firefox ≥ 100
- Safari ≥ 15
WebRTC requires HTTPS (or localhost). Microphone access is requested on the first call.
Versioning
Semantic Versioning. Breaking changes ship in major releases only. The documented attributes, events and CSS variables are the public API; anything not described here is internal and may change at any time.
License
MIT — see LICENSE.
