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:
- Batteries-included — drop
<Softphone />inside<SoftphoneProvider>and you have a working phone. - 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';
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.
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
| Prop | Type | Required | Description |
|---|---|---|---|
token | string | Yes | WebRTC user-session token. The provider connects with it; changing it reconnects with the new credentials. |
apiBaseUrl | string | No | API base URL. Defaults to the SDK's production endpoint. |
onTokenExpiring | () => Promise<string> | No | Called ~60s before the token expires. Return a fresh token; the SDK delivers it in-band — no reconnect, no call disruption. |
iceServers | RTCIceServer[] | No | Override the ICE servers instead of fetching them from the API. |
emergencyAddressId | string | No | E911 address to present on connect. When supplied, you manage E911 and the built-in location prompt is disabled. |
autoConnect | boolean | No | Connect automatically once mounted. Defaults to true. Set false to render before the token is ready. |
appearance | AppearanceOptions | No | Theming — the same appearance surface used across the SDK (theme, variables). |
locale | Locale | No | Locale for the built-in UI strings. |
formatting | FormattingOptions | No | Display formatting (e.g. defaultCountry for phone-number rendering). |
onConnectionStateChange | (event: { state: SoftphoneConnectionState }) => void | No | Fired when the connection lifecycle changes. |
onIncomingCall | (event: { from: string; fromName: string | null }) => void | No | Fired when an inbound call arrives. |
onCallStarted | (event: { direction: 'inbound' | 'outbound'; peer: string }) => void | No | Fired when a call (in or out) becomes the foreground call. |
onCallEnded | (event: { reason: CallEndReason }) => void | No | Fired when the foreground call ends. |
onError | (event: { code: string; message: string }) => void | No | Fired on a non-fatal or fatal phone error. |
children | ReactNode | Yes | The 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>
);
| Prop | Type | Default | Description |
|---|---|---|---|
autoFocusDestination | boolean | false | Focus 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>:
| Component | Renders |
|---|---|
DialPad | The idle/outbound screen: status chip, number field, 12-key pad, call button. |
IncomingCall | The first ringing inbound call as a full-size card; nothing when none is ringing. |
IncomingStack | All ringing inbound calls as stacked answer/decline cards (compact when several). |
OngoingCall | The in-call screen: peer, live duration, mute/hold/keypad/transfer controls, hang up. |
EmergencyBanner | The 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:
| Field | Type | Description |
|---|---|---|
connection | SoftphoneConnectionState | Connection lifecycle state. |
calls | Call[] | Every live call leg (active, held, and ringing inbound). |
activeCall | Call | null | The call the user is talking to, or null. |
incomingCalls | Call[] | Ringing inbound calls not yet answered. |
heldCalls | Call[] | Answered calls currently on hold. |
answerCall | (call: Call) => void | Answer a specific ringing call (auto-holds the active call). |
switchToCall | (call: Call) => void | Switch the active call to an already-answered held call. |
actions | UseCallActions | Action callbacks for the foreground call (see useCallActions). |
duration | string | Live 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 } | null | The last error surfaced to the user. |
clearError | () => void | Dismiss the current error. |
displayNumber | (value: string) => string | Pretty-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:
| Field | Type | Description |
|---|---|---|
answer | () => void | Answer the call. |
reject | () => void | Reject a ringing call. |
hangup | () => void | Hang up. |
toggleMute | () => void | Mute / unmute. |
toggleHold | () => void | Hold / resume. |
sendDtmf | (digit: string) => void | Send a DTMF digit. |
transfer | (destination: string) => boolean | Blind-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) => CallActions | Build 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
- Theming — customize the softphone's appearance
- i18n — localize the built-in UI strings
- WebRTC: Calling — the underlying call model
- WebRTC: Emergency (E911) — the emergency-address flow