For AI agents: a documentation index is available at the root level at /llms.txt and /llms-full.txt. Append /llms.txt to any URL for a page-level index, or .md for the markdown version of any page.
Log inSign up
Support
GuidesReferenceClick-to-Call
GuidesReferenceClick-to-Call
  • Getting Started
    • Overview
    • Authentication
    • RxJS Primer
    • Migrate from v3
  • Web Components
    • Overview
    • Click-to-Call
    • Theming
    • Customization
  • Build Voice & Video Apps
    • Overview
    • Outbound Calls
    • Inbound Calls
    • Device Management
    • Screen Sharing
    • Call Controls
    • Layouts
    • Messaging & Chat
  • Manage Resources
    • Overview
    • Users
    • Address Book
    • Client Preferences
    • Capabilities
  • Deploy
    • Overview
    • Framework Integration
    • SSR & Next.js
    • Troubleshooting
LogoLogoSignalWire Docs
Log inSign up
Support
On this page
  • Listen for incoming calls
  • Accept or decline
  • Attach the streams
  • End the call
  • Try it: receive a call
  • Next steps
Build Voice & Video Apps

Inbound Calls

|View as Markdown|Open in Claude|
Was this page helpful?
Edit this page
Previous

Device Management

Pick the mic, camera, and speaker — before a call and on the fly

Next
Built with

To receive calls in a web app, bring a signed-in user online, show a ringing UI when someone calls them, and let them accept or decline. The result is a receiver you can call from any phone, SIP endpoint, or another browser tab.

Browser
1import { SignalWire, StaticCredentialProvider } from "@signalwire/js";
2import { filter, take } from "rxjs";
3
4// Constructing the client authenticates and registers the user
5// automatically. `await register()` here gives a sync point before we
6// subscribe — see the Authentication guide for the credential lifecycle.
7const client = new SignalWire(new StaticCredentialProvider({ token: SAT }));
8await client.register();
9
10// `showIncomingCall` / `hideIncomingCall` are your own UI helpers; `localVideo`
11// and `remoteVideo` are references to your `<video>` elements. The SDK doesn't
12// ship a UI — render the ringing state however fits your app.
13client.session.incomingCalls$.subscribe((calls) => {
14 const ringing = calls.find((c) => c.status === "ringing");
15 if (!ringing) return;
16
17 // Use fromName when it's a real display name; otherwise fall back to
18 // from. SignalWire sends "_undef_" as a placeholder when the
19 // originating leg didn't supply a name.
20 const callerName =
21 ringing.fromName && ringing.fromName !== "_undef_"
22 ? ringing.fromName
23 : ringing.from;
24
25 showIncomingCall({
26 from: callerName,
27 onAccept: () => ringing.answer({ audio: true, video: true }),
28 onDecline: () => ringing.reject(),
29 });
30
31 // Tear the UI down when the call leaves the "ringing" state.
32 ringing.status$
33 .pipe(filter((s) => s !== "ringing"), take(1))
34 .subscribe(() => hideIncomingCall());
35
36 // Attach media once the call connects (these only emit after accept).
37 ringing.localStream$.subscribe((stream) => (localVideo.srcObject = stream));
38 ringing.remoteStream$.subscribe((stream) => (remoteVideo.srcObject = stream));
39});

Before you start. Inbound calls require a Subscriber Access Token (SAT) issued for a specific user. Embed tokens and guest tokens are outbound-only and can’t receive calls.

Listen for incoming calls

Subscribe to client.session.incomingCalls$. The stream emits the current list of inbound calls every time it changes, not one event per call — filter by status === "ringing" to find calls that still need a decision.

Browser
1client.session.incomingCalls$.subscribe((calls) => {
2 const ringing = calls.find((c) => c.status === "ringing");
3 if (ringing) showIncomingCall(ringing); // your UI helper for the ringing state
4});

Each entry is a Call with direction: "inbound". Display the caller from these properties:

PropertyWhat it is
fromThe caller’s address (e.g. /private/alice)
fromNameDisplay name, if the caller supplied one
toThe address that was dialed (useful when one user has aliases)
directionAlways "inbound" here

The SDK hands you the raw list — it doesn’t queue, dedupe, or pick a call for you. Two simultaneous callers land in the same emission, and calls stay in the array through every status transition (only dropping out when destroyed), so the status === "ringing" filter is what tells you which entries still need a decision.

Accept or decline

A ringing call ends one of three ways: the user accepts, the user declines, or the caller gives up. Use answer() to accept and reject() to decline; subscribe to status$ to detect any of the three so the ringing UI tears down from a single place.

Accept the call. answer() takes a MediaOptions object that controls which tracks the user sends back — audio defaults to true, video defaults to false:

Audio + video
Audio only
Video, mic muted
Browser
1import { SignalWire, StaticCredentialProvider } from "@signalwire/js";
2
3const client = new SignalWire(new StaticCredentialProvider({ token: SAT }));
4await client.register();
5
6client.session.incomingCalls$.subscribe((calls) => {
7 const ringing = calls.find((c) => c.status === "ringing");
8 if (!ringing) return;
9 ringing.answer({ audio: true, video: true });
10});

Standard video call. Both tracks acquired from the selected mic and camera.

Decline the call. reject() declines before any media negotiates — the caller sees a normal decline; the session never picks up:

Browser
1import { SignalWire, StaticCredentialProvider } from "@signalwire/js";
2
3const client = new SignalWire(new StaticCredentialProvider({ token: SAT }));
4await client.register();
5
6client.session.incomingCalls$.subscribe((calls) => {
7 const ringing = calls.find((c) => c.status === "ringing");
8 if (!ringing) return;
9 ringing.reject();
10});

Dismiss the ringing UI. Subscribe to the call’s status$ and dismiss on the first emission that isn’t "ringing". That single handler covers all three outcomes — accepted, declined, or caller-gave-up:

Browser
1import { SignalWire, StaticCredentialProvider } from "@signalwire/js";
2import { filter, take } from "rxjs";
3
4const client = new SignalWire(new StaticCredentialProvider({ token: SAT }));
5await client.register();
6
7client.session.incomingCalls$.subscribe((calls) => {
8 const ringing = calls.find((c) => c.status === "ringing");
9 if (!ringing) return;
10
11 ringing.status$
12 .pipe(filter((s) => s !== "ringing"), take(1))
13 .subscribe(() => hideIncomingCall()); // your UI helper to dismiss the ringing UI
14});

After ringing, the call walks connecting → connected → disconnected. React to connected for the in-call UI, and disconnected / destroyed for the final cleanup.

Attach the streams

Once the call is connected, attach the local and remote media to <video> elements. The shape is identical to an outbound call — bind the localStream$ and remoteStream$ observables to each element’s srcObject:

Browser
1ringing.localStream$.subscribe((stream) => (localVideo.srcObject = stream));
2ringing.remoteStream$.subscribe((stream) => (remoteVideo.srcObject = stream));
1<video id="localVideo" autoplay muted playsinline></video>
2<video id="remoteVideo" autoplay playsinline></video>

The local element needs muted so the user doesn’t echo their own voice; the remote element must not be muted or no one is heard. Both need playsinline for mobile Safari.

End the call

Call hangup() when the user clicks the hang-up button or navigates away. The call transitions through disconnecting → disconnected → destroyed; any subscriptions on the call complete naturally.

Browser
1hangupButton.onclick = () => ringing.hangup();

If the user closes the tab without calling hangup(), the SDK still tears the call down when the page unloads. Calling hangup() explicitly gives you a clean point to dismiss the in-call UI before the connection drops.

To leave the page but keep the call alive on the platform — a transfer-and-disappear flow — use transfer() instead.

Try it: receive a call

The fastest way to verify inbound calls end-to-end is to issue a SAT, load the demo as your user, then place the call yourself with a server-side dial that runs inline SWML. The demo surfaces a ringing UI you can accept with Answer, then the SWML script plays a public test MP4 into the call — you see real audio and video on the receiving side without needing a second browser or a phone.

1

Issue a Subscriber Access Token

Pick a reference for the user the call will arrive on — usually an email, but any stable ID works. If a user (Subscriber) with that reference doesn’t exist yet, this endpoint creates one (set first_name, last_name, or password in the same body if you want); otherwise it returns a fresh token for the existing user.

The response token is the SAT you’ll plug into the demo. For the production version of this flow — where your backend issues SATs to clients — see the Authentication guide.

POST
/api/fabric/subscribers/tokens
1curl -X POST https://{your_space_name}.signalwire.com/api/fabric/subscribers/tokens \
2 -H "Content-Type: application/json" \
3 -u "<project_id>:<api_token>" \
4 -d '{
5 "reference": "john.doe@example.com"
6}'
Try it
2

Open the demo and come online

Save the page below as inbound-demo.html and open it over HTTPS (or localhost). Paste the SAT and click Come online.

Once registered, the log prints the user’s dialable /private/<name> address(es) — copy one for the next step. (You can also grab it from the Dashboard Resources page if you prefer the UI.)

Leave the page open — when the call arrives, the Caller line populates and the Answer / Decline buttons enable. After you accept, Hang up enables so you can end the call.

inbound-demo.html — full source
1<!doctype html>
2<html lang="en">
3 <head>
4 <meta charset="utf-8" />
5 <title>SignalWire SDK inbound demo</title>
6 <style>
7 /* Shared demo shell — identical across the inbound, outbound, and
8 device-management guides. Per-demo extras go below this block. */
9 body { font: 14px/1.5 system-ui, sans-serif; max-width: 720px; margin: 2rem auto; padding: 0 1rem; }
10 label { display: block; margin: 0.75rem 0 0.25rem; font-weight: 600; }
11 input, select { width: 100%; padding: 0.5rem; font: 13px ui-monospace, monospace; box-sizing: border-box; }
12 button { margin: 0.5rem 0.5rem 0 0; padding: 0.5rem 1rem; font: 14px system-ui; cursor: pointer; }
13 button[disabled] { opacity: 0.5; cursor: not-allowed; }
14 .videos { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; margin-top: 1rem; }
15 video { width: 100%; background: #000; border-radius: 4px; aspect-ratio: 4/3; }
16 #log { margin-top: 1rem; padding: 1rem; background: #111; color: #0f0; font: 13px ui-monospace, monospace; min-height: 6rem; white-space: pre-wrap; border-radius: 4px; }
17 /* Inbound-specific */
18 .caller { margin: 1rem 0 0.25rem; font-weight: 600; }
19 </style>
20 </head>
21 <body>
22 <h1>SignalWire SDK inbound demo</h1>
23
24 <label for="token">Subscriber Access Token</label>
25 <input id="token" type="password" placeholder="Paste your SAT here" />
26 <button id="online">Come online</button>
27
28 <p class="caller">Caller: <span id="caller">—</span></p>
29 <button id="answer" disabled>Answer</button>
30 <button id="decline" disabled>Decline</button>
31 <button id="hangup" disabled>Hang up</button>
32
33 <div class="videos">
34 <video id="local" autoplay muted playsinline></video>
35 <video id="remote" autoplay playsinline></video>
36 </div>
37
38 <pre id="log"></pre>
39
40 <script type="module">
41 import { SignalWire, StaticCredentialProvider } from "https://esm.sh/@signalwire/js@dev";
42
43 const $ = (id) => document.getElementById(id);
44 const log = (msg) => ($("log").textContent += msg + "\n");
45
46 let currentCall = null;
47
48 $("online").addEventListener("click", async () => {
49 const token = $("token").value.trim();
50 if (!token) return log("Paste a token first.");
51
52 $("online").disabled = true;
53 log("Coming online...");
54
55 const client = new SignalWire(new StaticCredentialProvider({ token }));
56
57 try {
58 await client.register();
59 log("Online — ready for inbound calls.");
60 } catch (err) {
61 log("Failed: " + (err.name || "Error") + " — " + err.message);
62 $("online").disabled = false;
63 return;
64 }
65
66 // The SDK exposes the authenticated user on `client.user$`.
67 // The User object carries `.addresses`.
68 //
69 // Each `address.channels` value is the full dialable URI for that
70 // channel (e.g. "/private/john-doe?channel=video" for a user,
71 // "/user/<name>?channel=audio" for an app). Don't hand-roll the
72 // prefix from `address.name` — the prefix varies by `address.type`.
73 client.user$.subscribe((sub) => {
74 if (!sub || !sub.addresses?.length) return;
75 log("Dialable address(es) for this user:");
76 for (const a of sub.addresses) {
77 const uris = Object.entries(a.channels || {});
78 if (!uris.length) continue;
79 log(" " + a.name + " (" + a.type + ")");
80 for (const [channel, uri] of uris) log(" " + channel + ": " + uri);
81 }
82 });
83
84 client.session.incomingCalls$.subscribe((calls) => {
85 const ringing = calls.find((c) => c.status === "ringing");
86 if (!ringing || ringing === currentCall) return;
87 currentCall = ringing;
88
89 // Use fromName when it's a real display name; otherwise fall back
90 // to from. SignalWire sends "_undef_" as a placeholder when the
91 // originating leg didn't supply a name.
92 const name =
93 ringing.fromName && ringing.fromName !== "_undef_"
94 ? ringing.fromName
95 : ringing.from || "Unknown";
96 $("caller").textContent = name;
97 $("answer").disabled = false;
98 $("decline").disabled = false;
99 log("Ringing from " + name);
100
101 ringing.status$.subscribe((s) => {
102 log("Status: " + s);
103 if (s !== "ringing") {
104 $("answer").disabled = true;
105 $("decline").disabled = true;
106 }
107 if (s === "connected") {
108 $("hangup").disabled = false;
109 }
110 if (s === "disconnected" || s === "destroyed") {
111 $("hangup").disabled = true;
112 $("caller").textContent = "—";
113 if (currentCall === ringing) currentCall = null;
114 }
115 });
116
117 ringing.localStream$.subscribe(
118 (s) => ($("local").srcObject = s)
119 );
120
121 // `remoteStream$` re-emits a *new* MediaStream each time the SDK
122 // adds a track (see `new MediaStream([...t, e.track])` in the SDK
123 // bundle). Bind `srcObject` on every emission so the <video>
124 // element always renders the latest stream, but dedupe per-track
125 // log lines by tracking which MediaStreamTrack.id's we've seen.
126 const seenTrackIds = new Set();
127 ringing.remoteStream$.subscribe((stream) => {
128 $("remote").srcObject = stream;
129 for (const t of stream.getTracks()) {
130 if (seenTrackIds.has(t.id)) continue;
131 seenTrackIds.add(t.id);
132 log("Remote " + t.kind + " track arrived");
133 }
134 });
135 });
136 });
137
138 $("answer").addEventListener("click", () => {
139 if (!currentCall) return;
140 log("Answering...");
141 currentCall.answer({ audio: true, video: true });
142 });
143
144 $("decline").addEventListener("click", () => {
145 if (!currentCall) return;
146 log("Declining...");
147 currentCall.reject();
148 });
149
150 $("hangup").addEventListener("click", () => {
151 if (!currentCall) return;
152 log("Hanging up...");
153 currentCall.hangup();
154 });
155 </script>
156 </body>
157</html>
3

Place a test call

There are several ways to dial the user — another browser tab signed in as a different user, a SIP softphone configured against the user’s SIP endpoint, a PSTN call from a phone, or a server-side dial via the Calling REST API. This guide uses the Calling API so you can verify the flow from a single terminal. The body below carries inline SWML that plays a public test MP4 — click Answer in the demo when the call rings and the platform plays that file into the call so the remote <video> tile shows real audio and video.

POST
/api/calling/calls
1curl -X POST https://{your_space_name}.signalwire.com/api/calling/calls \
2 -H "Content-Type: application/json" \
3 -u "<project_id>:<api_token>" \
4 -d '{
5 "command": "dial",
6 "params": {
7 "from": "+15551234567",
8 "to": "+15559876543",
9 "caller_id": "+15551234567",
10 "status_url": "https://example.com/status_callback",
11 "status_events": [
12 "answered",
13 "ended"
14 ],
15 "codecs": [
16 "PCMU",
17 "PCMA"
18 ],
19 "timeout": 30,
20 "max_price_per_minute": 0.05,
21 "url": "https://example.com/swml"
22 }
23}'
Try it

Set from to any phone number or SIP credential on your project — server-side dials originate from those. Set to to the address the demo log printed in step 2 (e.g. /private/john-doe). Paste the body below into the request above:

Request body example
1{
2 "command": "dial",
3 "params": {
4 "from": "<your-phone-or-sip-credential>",
5 "to": "/private/<address-from-demo-log>",
6 "swml": {
7 "version": "1.0.0",
8 "sections": {
9 "main": [
10 { "play": { "url": "https://www.w3schools.com/html/mov_bbb.mp4" } }
11 ]
12 }
13 }
14 }
15}

The demo logs the ringing call and enables the Answer / Decline buttons. Click Answer to accept (the demo answers with audio: true, video: true) — the streams attach, the local tile shows your webcam preview, and the remote tile shows whatever the SWML leg sends back. The SWML script hangs up automatically when playback finishes, or click Hang up to end the call from your side.

Next steps

Outbound Calls

Dial users, rooms, or PSTN destinations with client.dial().

Device Management

Choose the mic, camera, and speaker.

Call interface reference

Every property and method on a Call.