I'm a software engineer, and I've been working on a voice and video calling API for developers. Here's how you can add 1:1 voice calls to a web app.
Adding a "Call" button to a web app sounds like a weekend job. Then you meet WebRTC.
The browser part, RTCPeerConnection, is the easy bit. Around it you need:
- Signaling: some server that lets two browsers find each other and swap offers, answers and ICE candidates. WebRTC deliberately doesn't define one.
- STUN and TURN: STUN tells a browser its public address. TURN relays the audio when two networks refuse a direct connection (corporate firewalls, some mobile carriers). Without TURN, a noticeable share of real-world calls simply never connect.
- Call state: ringing, accepted, rejected, busy, hung up, reconnecting. Both sides have to agree on all of it.
- Auth: who is allowed to call whom, and in which room.
This post walks through a 1:1 voice call where all of that is handled by a hosted service, so your code is just UI and a token endpoint. I'll use RTCExpress, which I built, but the token pattern in step 2 is worth copying whatever you use.
How the pieces fit
Browser A ──┐ ┌── Browser B
│ WebSocket signaling │
├──────► RTCExpress ◄─────┤
│ │
└──── audio (P2P, or via TURN relay) ────┘
Your backend ──(App Secret)──► RTCExpress → short-lived user token
Your App Secret never reaches the browser. Your backend signs the user in the way it already does, then asks RTCExpress for a short-lived token for that user and room. The browser only ever sees the token.
1. Install the SDK
npm install rtcexpress
It's a single package with TypeScript types included, and it works with Vite, webpack or any bundler.
2. Add a token endpoint to your backend
Here's an Express version. The important part is the access check before you request a token:
// server.js
import express from "express";
const app = express();
app.use(express.json());
app.post("/api/rtc-token", requireLogin, async (req, res) => {
const userId = req.user.id; // from your own session/auth
const { roomId } = req.body;
// Your rule: may this user join this room?
if (!(await canJoinRoom(userId, roomId))) {
return res.status(403).json({ error: "Not allowed in this room" });
}
const r = await fetch("https://api.devsquarelabs.com/v1/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
appId: process.env.RTC_APP_ID,
appSecret: process.env.RTC_APP_SECRET, // server-side only
userId,
roomId, // scope the token to one room
}),
});
if (!r.ok) return res.status(502).json({ error: "Token request failed" });
const { token, expiresIn } = await r.json(); // expiresIn: 3600 seconds
res.json({ token, expiresIn });
});
app.listen(3000);
A room-scoped token means a leaked token can't be used to wander into other conversations.
3. Connect and join a room in the browser
import { RTCExpress } from "rtcexpress";
const rtc = new RTCExpress();
// Register listeners *before* joining
rtc.on("roomJoined", () => enableCallButton());
rtc.on("callInvite", ({ fromUserId }) => showIncomingCall(fromUserId));
rtc.on("callState", ({ state }) => updateCallUi(state));
rtc.on("error", ({ message }) => showError(message));
const { token } = await fetch("/api/rtc-token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ roomId: "support-42" }),
}).then((r) => r.json());
await rtc.init({
serverUrl: "https://api.devsquarelabs.com",
appId: "YOUR_APP_ID",
userId: currentUser.id,
token,
mediaMode: "p2p",
});
rtc.joinRoom("support-42");
One gotcha: joinRoom sends a join request; it doesn't wait for the server to confirm it. Enable your Call button in the roomJoined handler, not right after joinRoom.
4. Call, accept, mute, hang up
// Caller
callButton.onclick = () => rtc.callUser("bob", { callType: "voice" });
// Recipient (after the callInvite event)
acceptButton.onclick = () => rtc.acceptCall();
rejectButton.onclick = () => rtc.rejectCall();
// Either side
muteButton.onclick = () => rtc.muteMicrophone(true);
endButton.onclick = () => rtc.endCall();
// Clean up when the page goes away
window.addEventListener("pagehide", () => rtc.destroy());
The SDK negotiates the peer connection, falls back to TURN when it has to, and plays the remote audio for you. callState moves through states like ringing, connected and ended, so drive your UI from that one event.
If you use TypeScript, event payloads are typed: rtc.on("callState", (s) => s.state) autocompletes, and a typo in an event name is a compile error.
5. Test it properly
- Two tabs are fine for checking buttons and state, but they share one microphone and one network, so they prove very little about audio.
- Two devices on different networks (laptop on Wi-Fi, phone on mobile data) is the real test. That's where TURN earns its keep.
-
HTTPS is required for microphone access everywhere except
localhost. - Use headphones, or you'll spend an afternoon debugging echo that's just your speakers.
-
Test the unhappy paths: denied mic permission (
NotAllowedError), rejected calls, an expired token, and closing a tab mid-call.
What's left before production
- Replace any manual token entry with the authenticated endpoint above.
- Handle reconnects and permission errors with clear messages.
- Decide what "busy" means in your app (already in a call? in a meeting?).
- Add accessible call controls (keyboard focus, labels, a visible ringing state).
Try it
- Live demo: open https://api.devsquarelabs.com/demo/ on two devices.
-
Starter project: a ready-to-run version of this code is linked from the tutorial (
npm install && npm run dev). -
SDK:
rtcexpresson npm.
Full disclosure: I'm a solo developer and built RTCExpress because per-minute pricing was hard to predict for small apps, and self-hosting signaling + TURN + an SFU was a lot to maintain. The free plan includes chat and 1:1 voice calls (2,000 minutes a month), so you can run everything in this article without paying. Questions about WebRTC in general are welcome too. I'm happy to help in the comments.
Top comments (0)