Skip to main content

Softphone

Embed a fully-functional WebRTC phone — dial pad, incoming-call answer/decline, in-call controls (mute, hold, transfer, add call, DTMF, microphone/speaker selection), ringtone, audio, and the E911 flow — into your React app.

The softphone ships two ways to embed it, both driven by one provider:

  1. Batteries-included — drop <Softphone /> inside <SoftphoneProvider> and you have a working phone.
  2. Modular — compose the individual pieces (DialPad, IncomingCall / IncomingStack, OngoingCall, EmergencyBanner) and the state hooks under the same provider to build a bespoke layout.
Building a mobile app?

The same provider, hooks and components ship for React Native in @dialstack/sdk-native. See the Mobile Integration Guide for the React Native softphone, and for ringing through the phone's own call screen when the app is closed.

Everything below imports from @dialstack/sdk-react/softphone:

TypeScript
import {
SoftphoneProvider,
Softphone,
DialPad,
OngoingCall,
IncomingStack,
EmergencyBanner,
useSoftphone,
} from '@dialstack/sdk-react/softphone';
Server SDK for tokens

The provider needs a short-lived session token, minted on your server with @dialstack/sdk-server. Your secret key never reaches the browser — see Getting a token.

The provider​

<SoftphoneProvider> owns everything the phone needs to stay alive:

  • the connection and its lifecycle (connecting → connected → reconnecting → …),
  • the token and its in-band refresh (via onTokenExpiring),
  • the remote audio sink and the incoming ringtone,
  • the E911 (emergency address) flow.

Because the connection lives in the provider — not in any UI component — the phone stays connected while the UI mounts and unmounts (for example, when the softphone lives inside a drawer). Mount the provider once, high in your tree.

TypeScript
import { SoftphoneProvider, Softphone } from '@dialstack/sdk-react/softphone';

const Phone: React.FC<{ token: string; apiBaseUrl: string }> = ({ token, apiBaseUrl }) => (
<SoftphoneProvider
token={token}
apiBaseUrl={apiBaseUrl}
appearance={{ theme: 'light' }}
onError={(e) => console.error(`${e.code} — ${e.message}`)}
>
<Softphone />
</SoftphoneProvider>
);

Props​

PropTypeRequiredDescription
tokenstringYesWebRTC user-session token. The provider connects with it; changing it reconnects with the new credentials.
apiBaseUrlstringNoAPI base URL. Defaults to the SDK's production endpoint.
onTokenExpiring() => Promise<string>NoCalled ~60s before the token expires. Return a fresh token; the SDK delivers it in-band — no reconnect, no call disruption.
iceServersRTCIceServer[]NoOverride the ICE servers instead of fetching them from the API.
emergencyAddressIdstringNoE911 address to present on connect. When supplied, you manage E911 and the built-in location prompt is disabled.
autoConnectbooleanNoConnect automatically once mounted. Defaults to true. Set false to render before the token is ready.
appearanceAppearanceOptionsNoTheming — the same appearance surface used across the SDK (theme, variables).
localeLocaleNoLocale for the built-in UI strings.
formattingFormattingOptionsNoDisplay formatting (e.g. defaultCountry for phone-number rendering).
onConnectionStateChange(event: { state: SoftphoneConnectionState }) => voidNoFired when the connection lifecycle changes.
onIncomingCall(event: { from: string; fromName: string | null }) => voidNoFired when an inbound call arrives.
onCallStarted(event: { direction: 'inbound' | 'outbound'; peer: string }) => voidNoFired when a call (in or out) becomes the foreground call.
onCallEnded(event: { reason: CallEndReason }) => voidNoFired when the foreground call ends.
onError(event: { code: string; message: string }) => voidNoFired on a non-fatal or fatal phone error.
resolvePeer(call: Call) => PeerInfo | null | Promise<PeerInfo | null>NoName and avatar for the other party, from your own records. See Showing who's calling.
layoutVariant'auto' | 'full' | 'compact'NoSize tier. 'auto' (default) goes compact in small hosts. See Sizing.
childrenReactNodeYesThe softphone UI (or anything consuming the context).

SoftphoneConnectionState is one of 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'disconnected' | 'error'.

Getting a token​

Mint the token on your server so your secret key never reaches the browser. User tokens covers how to mint one; return its client_secret to the browser as token, along with the API base URL if you use a non-default one.

Fetch it in the browser and hand token + apiBaseUrl to the provider:

TypeScript
import { useState } from 'react';
import { SoftphoneProvider, Softphone } from '@dialstack/sdk-react/softphone';

type Session = { token: string; apiBaseUrl: string };

const PhoneApp: React.FC = () => {
const [session, setSession] = useState<Session | null>(null);

const connect = async () => {
const resp = await fetch('/api/session', { method: 'POST' });
setSession((await resp.json()) as Session);
};

if (!session) return <button onClick={connect}>Connect</button>;

return (
<SoftphoneProvider token={session.token} apiBaseUrl={session.apiBaseUrl}>
<Softphone />
</SoftphoneProvider>
);
};

The runnable web-softphone-example shows the full server-mint pattern, including the environment-pinned user.

Batteries-included: <Softphone />​

The drop-in. It renders the whole phone and switches between the dial pad, incoming-call cards, and the in-call screen based on live call state. It is a pure consumer of the provider — it takes no token; render it inside <SoftphoneProvider>:

TypeScript
import { SoftphoneProvider, Softphone } from '@dialstack/sdk-react/softphone';

const App: React.FC<{ token: string }> = ({ token }) => (
<SoftphoneProvider token={token}>
<Softphone />
</SoftphoneProvider>
);
PropTypeDefaultDescription
autoFocusDestinationbooleanfalseFocus the dial pad's number field on mount (e.g. when it opens in a drawer).

Rendering <Softphone /> outside a provider throws — the provider is where the connection and token live.

Sizing​

The softphone fits the box you give it. With the default layoutVariant="auto" it switches to a compact layout when its host is smaller than about 300×480 (narrow or short): smaller keys, buttons, chips and type, and no caller avatar. At or above that size it uses the full layout. It re-measures as the host resizes, so a drawer or a rotated phone gets the right one.

Pin a tier with layoutVariant="full" (always the full layout) or layoutVariant="compact" (always compact). A host too small even for the compact layout scrolls.

Showing who's calling​

By default the softphone titles a call with the caller-id name, or the number. To show your own record instead (the customer, patient or contact behind the number), pass resolvePeer to the provider. It may be async:

TypeScript
const resolvePeer = useCallback(async (call: Call) => {
const number = call.direction === 'inbound' ? call.from : call.to;
const patient = await lookupPatient(number);
if (!patient) return null; // keep the default display
return {
name: `${patient.firstName} ${patient.lastName}`,
avatar: { initials: patient.initials }, // or { uri: patient.photoUrl }
};
}, []);

<SoftphoneProvider token={token} resolvePeer={resolvePeer}>
<Softphone />
</SoftphoneProvider>;

The softphone asks once per call. When resolvePeer changes, it asks again for every live call and keeps the previous answer on screen until the new one arrives. So a resolver that reads data your app is still loading (a contacts list, say) should list that data in its useCallback dependencies: the call picks up the name as soon as the data is there. Keep the function stable otherwise; an inline arrow is a new function on every render and repeats the lookup each time.

The name becomes the call's title (the number stays under it), and the avatar is drawn above it on the ringing card and the in-call screen, in the softphone's own styles. Compact rows (held calls, transfer, stacked ringing) show the name only. Until the lookup resolves, and whenever it returns null, rejects or throws, the call keeps its default display. A custom UI can read the same result from useSoftphone().peerInfo(call).

Modular composition​

Drop-in: <Softphone />
Own screens on useSoftphone()

The same app and the same call, drawn two ways. In the first clip, the app renders the drop-in <Softphone /> and gets the SDK's screens, with its own patient card added below them. In the second, it renders its own screens on useSoftphone(): a front-desk console that shows the caller as a patient record, with its own controls. Nothing else changes between the two. Both clips come from the Spineline demo app.

Reach for the modular flow when you want a custom layout — a two-column desk-phone, a docked call bar, the dial pad in one panel and the active call in another — while keeping the SDK's call logic. Compose the pieces under the same <SoftphoneProvider> and add your own chrome around them.

The composable UI pieces are all pure consumers of the provider and each renders its own scoped wrapper, so you can drop any one of them directly under <SoftphoneProvider>:

ComponentRenders
DialPadThe idle/outbound screen: status chip, number field, 12-key pad, call button.
IncomingCallThe first ringing inbound call as a full-size card; nothing when none is ringing.
IncomingStackAll ringing inbound calls as stacked answer/decline cards (compact when several).
OngoingCallThe in-call screen: peer, live duration, mute/hold/keypad/transfer/add-call/audio controls, hang up.
EmergencyBannerThe E911 "set your location" prompt. Self-hides when it doesn't apply.
TypeScript
import {
SoftphoneProvider,
DialPad,
IncomingStack,
OngoingCall,
EmergencyBanner,
useSoftphone,
useActiveCall,
useIncomingCall,
useCallDuration,
} from '@dialstack/sdk-react/softphone';

const CustomSoftphone: React.FC = () => {
const { connection } = useSoftphone();
const { activeCall } = useActiveCall();
const incoming = useIncomingCall();
const duration = useCallDuration(activeCall);

return (
<div className="phone-grid">
{/* Custom status header built from the hooks */}
<header>
<span>{connection}</span>
{activeCall && <span>{duration}</span>}
</header>

{/* E911 prompt, hoisted to the top — decoupled from the dial pad.
It self-hides once a location is bound (or when the host manages E911). */}
<EmergencyBanner />

{/* Left: the dial pad */}
<section>
<DialPad autoFocusDestination />
</section>

{/* Right: whatever is "on the line" right now */}
<section>
{incoming && !activeCall ? (
<IncomingStack />
) : activeCall ? (
<OngoingCall />
) : (
<p>No active call</p>
)}
</section>
</div>
);
};

const App: React.FC<{ token: string }> = ({ token }) => (
<SoftphoneProvider token={token}>
<CustomSoftphone />
</SoftphoneProvider>
);

DialPad, OngoingCall, IncomingStack, and EmergencyBanner all read from context — none take a call prop. OngoingCall and IncomingStack render nothing when there's no matching call, so an idle placeholder covers the empty case.

Placing a call from anywhere​

Any component under the provider can place a call — you don't have to route through the dial pad. useSoftphone().placeCall(destination) starts an outbound call (it cleans the input and reports invalid/empty input via onError):

TypeScript
import { useSoftphone } from '@dialstack/sdk-react/softphone';

const CallButton: React.FC<{ number: string }> = ({ number }) => {
const { placeCall } = useSoftphone();
return <button onClick={() => void placeCall(number)}>Call {number}</button>;
};

Audio devices​

<OngoingCall /> includes an Audio control for picking a microphone and speaker — for shared workstations, where someone sits down, chooses their headset, and it sticks.

Both apply to the call already in progress, and switching the microphone preserves mute. Call audio, the incoming ringtone, and the outbound ringback all follow the chosen speaker. If the microphone is unplugged mid-call, the picker says so and offers a one-click re-pick.

Hooks​

All hooks must be called inside a <SoftphoneProvider> (they throw otherwise). If you want calling without the provider — or without React — use the headless core directly: DialStackPhone and Call from @dialstack/sdk-webrtc expose the whole call lifecycle, including the emergency-address methods.

useSoftphone()​

The full context accessor — everything the UI pieces read. Returns:

FieldTypeDescription
connectionSoftphoneConnectionStateConnection lifecycle state.
callsCall[]Every live call leg (active, held, and ringing inbound).
activeCallCall | nullThe call the user is talking to, or null.
incomingCallsCall[]Ringing inbound calls not yet answered.
heldCallsCall[]Answered calls currently on hold.
answerCall(call: Call) => voidAnswer a specific ringing call (auto-holds the active call).
switchToCall(call: Call) => voidSwitch the active call to an already-answered held call.
actionsUseCallActionsAction callbacks for the foreground call (see useCallActions).
durationstringLive m:ss duration of the active call.
placeCall(destination: string) => Promise<void>Place an outbound call. Cleans the input (trims/normalizes), reports invalid/empty input via onError, and resolves when the attempt settles.
lastError{ code: string; message: string } | nullThe last error surfaced to the user.
clearError() => voidDismiss the current error.
displayNumber(value: string) => stringPretty-print a number for display.
peerInfo(call: Call) => PeerInfo | nullYour resolvePeer result for a call, or null while it resolves or when there is none.

Plus the attended-transfer surface (consultCall, transferOriginal, startAttendedTransfer, completeAttendedTransfer, cancelAttendedTransfer) and the E911 binding (emergency).

TypeScript
import { useSoftphone } from '@dialstack/sdk-react/softphone';

const StatusBar: React.FC = () => {
const { connection, activeCall, duration } = useSoftphone();
return (
<div>
{connection === 'connected' ? 'Online' : connection}
{activeCall && ` · on a call (${duration})`}
</div>
);
};

useActiveCall()​

The foreground call and its actions — a focused slice of useSoftphone(). Returns { activeCall: Call | null; actions: UseCallActions }.

TypeScript
import { useActiveCall } from '@dialstack/sdk-react/softphone';

const HangupButton: React.FC = () => {
const { activeCall, actions } = useActiveCall();
if (!activeCall) return null;
return <button onClick={actions.hangup}>Hang up</button>;
};

useIncomingCall()​

The single currently-ringing inbound call, or null. Useful for driving a custom ringing indicator; render the ringing calls themselves with IncomingCall / IncomingStack.

TypeScript
import { useIncomingCall } from '@dialstack/sdk-react/softphone';

const Ringing: React.FC = () => {
const call = useIncomingCall();
return call ? <p>Incoming call…</p> : null;
};

useCallActions(call, options?)​

Imperative call-control callbacks for a given call (usually the active one). This is call control only — no view-state — so a custom layout gets the actions without any built-in-UI plumbing. Returns a UseCallActions:

FieldTypeDescription
answer() => voidAnswer the call.
reject() => voidReject a ringing call.
hangup() => voidHang up.
toggleMute() => voidMute / unmute.
toggleHold() => voidHold / resume.
sendDtmf(digit: string) => voidSend a DTMF digit.
transfer(destination: string) => booleanBlind-transfer the call (hand off immediately). Returns true when the transfer was initiated (so you can close your own transfer UI), false on empty input or a synchronous failure reported via onError. true means initiated, not confirmed — the outcome arrives later as the call ending (transferred) or an onError.
callActionsFor(call: Call | null) => CallActionsBuild the action subset for a specific call.

A custom layout owns its own presentation state (which panels are open, its transfer input) and simply calls sendDtmf / transfer directly — closing its transfer UI when transfer returns true.

Errors thrown by the core surface through options.onError rather than escaping to the caller. Inside a provider, the ready-bound actions are on useSoftphone().actions / useActiveCall().actions — you rarely call useCallActions yourself.

TypeScript
import { useActiveCall } from '@dialstack/sdk-react/softphone';

const CallControls: React.FC = () => {
const { activeCall, actions } = useActiveCall();
if (!activeCall) return null;
return (
<div>
<button onClick={actions.toggleMute}>Mute</button>
<button onClick={actions.toggleHold}>Hold</button>
<button onClick={() => actions.transfer('+15551234567')}>Transfer</button>
<button onClick={actions.hangup}>Hang up</button>
</div>
);
};

useCallDuration(call, intervalMs?)​

The live m:ss duration string for call, re-rendering as the call runs (default tick 500ms). Returns '0:00' when there is no active call.

TypeScript
import { useActiveCall, useCallDuration } from '@dialstack/sdk-react/softphone';

const Timer: React.FC = () => {
const { activeCall } = useActiveCall();
const duration = useCallDuration(activeCall);
return <span>{duration}</span>;
};

useLastError(onError?)​

Owns the "last error" state that drives the built-in error banner. Returns { lastError, handleError, clearError }. The provider wires this internally and exposes the result as lastError/clearError on useSoftphone(), so reach for this hook only if you are driving error state yourself.

TypeScript
import { useSoftphone } from '@dialstack/sdk-react/softphone';

const ErrorBanner: React.FC = () => {
const { lastError, clearError } = useSoftphone();
if (!lastError) return null;
return (
<div role="alert" onClick={clearError}>
{lastError.message}
</div>
);
};

useDialInput(setValue)​

Shared cleaning for a dial-string input. Returns { onType, onPasteText }: wire onType to your field's change event (strips display separators only, so typing stays natural) and onPasteText to its paste event (fully normalizes a pasted number to E.164). Used to build a custom number field that behaves like the built-in dial pad.

TypeScript
import { useState } from 'react';
import { useDialInput, useSoftphone } from '@dialstack/sdk-react/softphone';

const CustomDialField: React.FC = () => {
const { placeCall } = useSoftphone();
const [value, setValue] = useState('');
const { onType, onPasteText } = useDialInput(setValue);

return (
<input
value={value}
onChange={(e) => onType(e.target.value)}
onPaste={(e) => {
e.preventDefault();
onPasteText(e.clipboardData.getData('text'));
}}
onKeyDown={(e) => {
if (e.key === 'Enter') void placeCall(value);
}}
/>
);
};

Full working example​

The web-softphone-example is a runnable Next.js app that mints a session server-side and renders the softphone. It has a labeled toggle that switches between the batteries-included <Softphone /> and a hand-composed modular layout under one <SoftphoneProvider> — swapping the UI without ever reconnecting — so it's the complete, working version of both flows described here.

Next steps​