Skip to main content

Softphone

Embed a fully-functional WebRTC phone — dial pad, incoming-call answer/decline, in-call controls (mute, hold, transfer, DTMF), 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.

Everything below imports from the React entry point:

import {
SoftphoneProvider,
Softphone,
DialPad,
OngoingCall,
IncomingStack,
EmergencyBanner,
useSoftphone,
} from '@dialstack/sdk/react';
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 (connectingconnectedreconnecting → …),
  • 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.

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

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.
childrenReactNodeYesThe softphone UI (or anything consuming the context).

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

Getting a token

Mint the session on your server so your sk_live_* key never reaches the browser. Use the server SDK's userSessions.create:

// app/api/session/route.ts (Next.js route handler)
import { NextResponse } from 'next/server';
import { DialStack } from '@dialstack/sdk/server';

export async function POST() {
const dialstack = new DialStack(process.env.DIALSTACK_SECRET_KEY!, {
apiUrl: process.env.DIALSTACK_API_BASE_URL,
});
const session = await dialstack.userSessions.create({ user: process.env.DIALSTACK_USER_ID! });
return NextResponse.json({
token: session.client_secret,
apiBaseUrl: process.env.DIALSTACK_API_BASE_URL,
});
}

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

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

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>:

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

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.

Modular composition

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 controls, hang up.
EmergencyBannerThe E911 "set your location" prompt. Self-hides when it doesn't apply.
import {
SoftphoneProvider,
DialPad,
IncomingStack,
OngoingCall,
EmergencyBanner,
useSoftphone,
useActiveCall,
useIncomingCall,
useCallDuration,
} from '@dialstack/sdk/react';

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):

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

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

Hooks

All hooks must be called inside a <SoftphoneProvider> (they throw otherwise).

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.

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

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

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 }.

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

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.

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

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

useCalls(options)

The low-level "brain" the provider is built on — it constructs and owns the phone, tracks every leg, and exposes the full call API (connection, calls, activeCall, placeCall, answerCall, switchToCall, the attended-transfer methods, and the E911 methods). Returns UseCallsResult.

Most apps use <SoftphoneProvider> (which calls useCalls internally) rather than this hook directly. Reach for it only when you're building a provider-free integration; otherwise prefer useSoftphone().

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.

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

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.

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

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; use it directly only in a provider-free integration.

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

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.

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

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