npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@sentdm/voice-client

v0.2.1

Published

Sent Voice Client for the web: calls and incoming calls inside your app

Readme

Sent Voice TypeScript SDK

This library lets a web app place and receive Sent Voice calls in the browser. React bindings ship separately as @sentdm/voice-client-react.

Your backend mints a short-lived voice token for each user with the Sent API. The SDK registers the browser with it, then places calls, receives them and reports how they go.

Installation

npm install @sentdm/voice-client

Quickstart

1. Mint voice tokens on your backend

Mint tokens on your server with your Sent API key, never in the browser, for the user who is signed in:

export async function createVoiceToken(identity: string): Promise<string> {
  const response = await fetch('https://api.sent.dm/v3/voice/tokens', {
    method: 'POST',
    headers: { 'x-api-key': process.env['SENT_DM_API_KEY'] ?? '', 'Content-Type': 'application/json' },
    body: JSON.stringify({ identity }),
  });
  if (!response.ok) throw new Error(`Minting a voice token failed with status ${response.status}`);
  const { data } = (await response.json()) as { data: { token: string } };
  return data.token;
}

identity names the user in calls: letters, digits, - and _, up to 200 characters. The user is bound to your default voice number unless you pass number, one of the numbers you turned voice on for with POST /v3/channels/voice, and the token lasts ttl seconds (600 by default, at most 3600). Return the token as text from an endpoint of your own, such as /api/voice-token.

2. Serve the service worker

Incoming calls reach the browser as push messages, through a service worker your app serves. Copy the one this package ships next to your pages, for example:

cp node_modules/@sentdm/voice-client/sw.js public/sw.js

The SDK registers sw.js relative to the page. If you serve it elsewhere, or your app already has a service worker, give it a path and scope of its own so it does not replace yours:

import SentVoice from '@sentdm/voice-client';

const client = new SentVoice({
  tokenProvider: () => fetch('/api/voice-token').then((response) => response.text()),
  serviceWorker: { url: '/voice/sw.js', scope: '/voice/' },
});

3. Register and call

import SentVoice from '@sentdm/voice-client';

const client = new SentVoice({
  tokenProvider: () => fetch('/api/voice-token').then((response) => response.text()),
});

document.querySelector('#call')?.addEventListener('click', async () => {
  await client.register();
  const call = await client.connect({ to: '+14155551234' });
  call.on('connected', () => console.log('Connected'));
  call.on('disconnected', ({ state }) => console.log(`The call ended as ${state}`));
});

The first register() asks the user to allow notifications, which the browser needs to deliver incoming calls. Firefox and Safari only show that prompt after a user gesture, so call it from a click, as above. It rejects when notifications are blocked or the service worker cannot be registered.

Token lifecycle

tokenProvider is how the SDK gets every token. It must return the token that POST /v3/voice/tokens responded with, unchanged and fetched fresh each time, because the SDK calls it again before each token expires. The token is opaque: read the identity, number and expiry from the minting response, not from the token. The SDK exchanges it with Sent for what it registers with, so nothing your backend or app handles belongs to the calling provider.

  • register() calls it and resolves once the client is registered. The client moves from unregistered to registering to registered, emitting each state as an event.
  • A token provider that throws or rejects, or a token exchange that cannot reach Sent or is rate limited, is retried after a short backoff, 2 more times by default (registerRetries); register() then rejects with a NetworkError. A value that is not a voice token, or a token Sent refuses, rejects with a TokenInvalidError at once.
  • The token is refreshed at 80% of its lifetime, or 30 seconds before it expires if that is earlier, but never before half of its lifetime has passed. tokenWillExpire fires first, then tokenProvider is called with the same retries. Nothing else changes for your app.
  • If the retries run out, the client emits error with the cause and offline with a TokenExpiredError, then tries again about every 30 seconds until it is registered again; calling register() tries at once. Calls in progress go on, but connect() and joinConference() throw a NotRegisteredError while the client is offline.
  • unregister() stops receiving calls and leaves calls in progress alone. destroy() ends every call and tears the client down for good.

Tokens stay in memory and are redacted from log messages.

import SentVoice from '@sentdm/voice-client';

const client = new SentVoice({
  tokenProvider: async () => {
    const response = await fetch('/api/voice-token');
    if (!response.ok) throw new Error(`Fetching the voice token failed with status ${response.status}`);
    return response.text();
  },
});

client.on('registered', () => console.log('Ready for calls'));
client.on('tokenWillExpire', ({ expiresAt }) => console.log('Refreshing, expires', new Date(expiresAt)));
client.on('offline', (reason) => console.warn(`Offline (${reason.code}), trying again every 30 seconds`));

Calls

connect({ to }) calls a phone number in E.164 format, like '+14155551234', or another user of your app by identity, like 'ben'. joinConference({ name }) joins one of your account's rooms, named with letters, digits, - and _, up to 27 characters. Either throws a SentVoiceError with code INVALID_ADDRESS when to or name does not fit, and one with code CALL_IN_PROGRESS while a call is in progress or an incoming call is still waiting for an answer. Both open the microphone, and reject with a MediaPermissionError when the user refuses. to says who the user wants to reach; your backend's answer decides what rings.

A leg to a phone number, whether the answer connects the call to a number or a phone participant is added through the API, runs for at most what your account's balance affords at the destination's per-minute rate, capped at four hours by the provider. Legs to app users and rooms have no such limit because they cost nothing. A call that reaches the cap ends as completed.

import type SentVoice from '@sentdm/voice-client';

export async function callBen(client: SentVoice) {
  const call = await client.connect({ to: 'ben' });

  call.on('ringing', () => console.log('Ringing'));
  call.on('connected', () => call.sendDigits('1'));
  call.on('muteChanged', (isMuted) => console.log(isMuted ? 'Muted' : 'Unmuted'));
  call.on('disconnected', ({ state, error }) => console.log(`The call ended as ${state}`, error?.code));
  return call;
}

A call moves through initiated, ringing, answered and connected, and ends as completed, failed, busy or noAnswer, which disconnected reports. hangup() ends it, mute() toggles the microphone, sendDigits() sends DTMF, and getStats() reads its jitter, packet loss and round-trip time. client.activeCall is the call placed or answered last, until that call ends, and activeCallChanged reports each change. client.calls lists every call in progress.

The SDK plays the other party through an audio element it creates. Pass your own with the audio: { element } option.

Receiving calls

import type SentVoice from '@sentdm/voice-client';

export function receiveCalls(client: SentVoice) {
  client.on('incomingCall', (invite) => {
    console.log('Incoming call from', invite.from);
    invite.on('cancelled', () => console.log('The caller hung up'));

    document.querySelector('#answer')?.addEventListener(
      'click',
      async () => {
        const call = await invite.accept();
        call.on('disconnected', ({ state }) => console.log(`The call ended as ${state}`));
      },
      { once: true },
    );
    document.querySelector('#decline')?.addEventListener('click', () => invite.reject(), { once: true });
  });
}

The microphone opens when the call arrives, and accept() asks for permission again if that was refused, then resolves with the call. When the user refuses, it rejects with a MediaPermissionError and the invite stays pending, so the user can try again. reject() declines the call, and cancelled fires when the caller hangs up first. A call that arrives while another one is in progress still raises incomingCall; your handler decides whether to accept() it or reject() it. The call accepted last becomes activeCall.

Call quality and reconnection

qualityWarning fires when a network metric of a connected call crosses its threshold, and again with cleared: true once it recovers. Stats are sampled every half second.

| metric | Raised when | Cleared when | | ------------ | ------------------------------------------------------------------------- | ------------------------------ | | jitter | incoming audio jitter is above 30 ms in 3 of the last 4 samples | fewer than 3 of the last 4 are | | packetLoss | over 1% of the incoming audio packets are lost in 3 of the last 4 samples | fewer than 3 of the last 4 are | | rtt | the round-trip time is above 300 ms | it is 300 ms or less again |

The rtt warning follows every sample, so it can go on and off while the round-trip time hovers around 300 ms. A warning still raised when the call ends is not cleared.

import type SentVoice from '@sentdm/voice-client';

export function showCallQuality(call: SentVoice.Call) {
  call.on('qualityWarning', ({ metric, cleared }) => console.log(metric, cleared ? 'recovered' : 'poor'));
  call.on('reconnecting', () => console.log('Connection lost, reconnecting'));
  call.on('reconnected', () => console.log('Connection back'));
}

When the connection drops for 2 seconds, the call goes reconnecting, and reconnected fires when it comes back on the same network. A call cannot move to a new network: after a network switch its connection does not come back, and a call that has not reconnected within 5 minutes ends as failed with a NetworkError.

Handling errors

Every error the SDK throws or emits is a SentVoiceError with a stable code, a category (auth, media, signaling, network, validation or capability) and whether it is retriable. Check for the subclasses with instanceof:

import type SentVoice from '@sentdm/voice-client';
import { MediaPermissionError, SentVoiceError } from '@sentdm/voice-client/errors';

export async function answer(invite: SentVoice.CallInvite) {
  try {
    return await invite.accept();
  } catch (error) {
    if (error instanceof MediaPermissionError) console.warn('Allow microphone access to answer calls');
    else if (error instanceof SentVoiceError) console.error(error.code, error.message);
    throw error;
  }
}

| Error | code | category | When | | ---------------------------- | ------------------------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | TokenInvalidError | TOKEN_INVALID | auth | register(): the token provider returned something that is not a voice token, or a token Sent refuses | | TokenExpiredError | TOKEN_EXPIRED | auth | the offline event: the token could not be refreshed | | NotRegisteredError | NOT_REGISTERED | validation | connect() or joinConference() while not registered; registering, calling or choosing a device after destroy() | | MediaPermissionError | MEDIA_PERMISSION_DENIED | media | connect(), joinConference() or accept(): the microphone could not be opened | | CallFailedError | CALL_FAILED | signaling | accept() on a call that ended before it was answered; a call the provider could not set up, such as to an identity that is not registered, ends as failed with it | | CallRejectedError | CALL_REJECTED | signaling | not raised yet: a call the other side declines ends as busy | | NetworkError | NETWORK | network | register(), when the token provider or the token exchange keeps failing or the calling provider cannot be loaded; a call whose connection failed or was lost | | CapabilityUnsupportedError | CAPABILITY_UNSUPPORTED | capability | setOutputDevice() where the browser cannot choose the speaker; listing devices over plain HTTP | | SentVoiceError | INVALID_ADDRESS | validation | connect() or joinConference() with a to or name that does not fit | | SentVoiceError | CALL_IN_PROGRESS | validation | connect() or joinConference() while a call is in progress or an incoming call is waiting for an answer | | SentVoiceError | UNKNOWN | signaling | anything else, such as register() failing because notifications are blocked |

Errors that happen outside a method call arrive as events: the client's error when a token refresh fails, and a call's error, followed by disconnected with { state: 'failed', error }. The underlying provider error is not exposed on the error; the SDK reports it to Sent as telemetry.

Devices

import type SentVoice from '@sentdm/voice-client';

const isHeadset = (device: MediaDeviceInfo) => device.label.includes('Headset');

export async function pickHeadset(client: SentVoice) {
  const microphone = (await client.audio.inputDevices()).find(isHeadset);
  if (microphone) await client.audio.setInputDevice(microphone.deviceId);
  const speaker = (await client.audio.outputDevices()).find(isHeadset);
  if (speaker) await client.audio.setOutputDevice(speaker.deviceId);
}

export function followHeadset(client: SentVoice) {
  client.audio.on('deviceChanged', () => pickHeadset(client).catch(console.error));
}
  • inputDevices() and outputDevices() list the microphones and speakers. Browsers leave out labels, and may list a single device, until the user has allowed microphone access in the page, which the first call asks for.
  • setInputDevice() switches the microphone of the call in progress and of later calls. While the device is missing, the default one is used.
  • setOutputDevice() plays calls through a speaker. It throws a CapabilityUnsupportedError in browsers that cannot choose the audio output.
  • Both work before register(), and take effect when it runs. To start with saved devices, pass audio: { inputDeviceId, outputDeviceId }.
  • deviceChanged fires when a device is plugged in or out.

Ringtones

The SDK plays no ringtone or ringback. Play your own on incomingCall and when a placed call is ringing, and stop it when the call is answered or ends:

import type SentVoice from '@sentdm/voice-client';

const ringtone = new Audio('/sounds/ringtone.mp3');
ringtone.loop = true;

const play = () => ringtone.play().catch(() => {});
const stop = () => {
  ringtone.pause();
  ringtone.currentTime = 0;
};

export function ringForIncomingCalls(client: SentVoice) {
  client.on('incomingCall', (invite) => {
    play();
    invite.on('accepted', stop).on('rejected', stop).on('cancelled', stop);
  });
}

export async function callWithRingback(client: SentVoice, to: string) {
  const call = await client.connect({ to });
  call.on('ringing', play).on('connected', stop).on('disconnected', stop);
  return call;
}

React

For React 18 and 19, @sentdm/voice-client-react wraps one client in a provider, with hooks that re-render on its events.

Telemetry

The SDK reports usage and call quality data to Sent, authenticated with the voice token:

  • the SDK version, and the browser, its major version, the operating system, its version and the device type, as read from the user agent (the user agent itself is not sent)
  • how long register() took, its attempts and, when it failed, its error; how long unregister() took
  • for each call: its id, direction and outcome, how long it took to connect and how long it lasted, and at its end the average round-trip time, jitter and packet loss of samples taken every 10 seconds
  • the errors the client and its calls report: code, category, whether retriable, the message, and the underlying provider error with its name, message, stack and properties

No audio, phone numbers or identities are added by the SDK; the underlying provider error travels as the provider produced it. Batches go to /v3/voice/telemetry under baseURL (https://api.sent.dm by default) 3 seconds after the first event queued, so events that happen together share one request, and at once when a call ends, when the page is hidden and when the client is destroyed. A batch that fails, or that Sent refuses (for example with a 429 or a 5xx), is retried once, with the next batch or within 30 seconds, then dropped; telemetry never throws to your app and never delays a call. Turn it off with telemetry: { disabled: true }.

Browser realities

  • WebRTC requires a secure context: serve your app over HTTPS, or from localhost while developing. Over plain HTTP the browser hides the microphone and service worker APIs, so registering and calling fail. It is the first thing that breaks when you open the app from another device on your network.
  • A page refresh or tab close ends any live call: WebRTC media dies with the page, and there is no resuming a call across reloads.
  • Laptop sleep or a network switch does not unregister the client: incoming calls reach it through the browser's push service, which reconnects by itself, so the client stays registered, but calls that arrive while the device sleeps or is offline are missed. If a token refresh falls in that gap, the client goes offline and tries again about every 30 seconds until it is registered again (state passes offline → registered).
  • One SentVoice instance per tab. The same identity registered in several tabs/devices creates independent per-device registrations at the provider (documented provider behavior); which devices ring on an incoming call is the provider's delivery semantics — inherited as-is, pinned down empirically during implementation testing. Every device rings, the first to answer takes the call, and the others see their invite cancelled. Tabs of one browser share a registration, so they all ring.
  • Tokens are per-registration; each tab runs its own tokenProvider calls.