Build a video chat app with Node.js where your server can end the call for every participant without joining it. Express controls who gets access, and VideoSDK's Node.js video calling API carries the live media. Here, "chat" means video and audio, not text chat.
The full source for this Node.js video chat app is on GitHub. It runs locally with npm and has no build step.
Build a video chat app with Node.js and Express
You'll build a group video call on one page. Whoever starts it becomes the host and gets an invite link. Anyone with that link joins as a guest. Every participant gets camera and mic controls and a Leave button.
Only the host sees End for everyone. That button asks the server to disconnect every browser and disable the room. The old link then stops admitting participants.
The server is one 73-line file with three API routes:
-
POST /api/roomscreates a room and returns a host token. -
POST /api/rooms/:roomId/tokenreturns a guest token, 404 for an unknown room, or 410 for an ended room. -
POST /api/rooms/:roomId/endends the call for everyone after checking the host token.
The Server SDK manages rooms and tokens from Node.js, so you don't write REST requests or token-signing code. The JavaScript SDK joins from the browser.
Use a video chat API to keep media off your server
WebRTC carries live audio and video between browsers. A WebRTC signaling server helps browsers find each other and agree on a connection. You'd write that relay yourself, for example over Socket.IO. Connections may also need help crossing firewalls and home routers. Group video calls usually add a media server to forward participants' feeds.
Running those components yourself means operating and scaling the servers and keeping them healthy. Choose a hosted API to avoid that maintenance. This comparison of video calling APIs covers the options. VideoSDK runs the signaling and media, while Express handles access and call controls.
Set up the Node.js video call server with Express
Use Node 20.11 or newer. The app uses --env-file and import.meta.dirname, and it needs both. Its dependencies are pinned to Express 5.3.0, Server SDK 0.1.1, and JavaScript SDK 1.2.1.
Signup includes $20 of free credit, with no credit card required, per VideoSDK pricing, checked 2 Nov 2026. Clone the repo and install its dependencies:
git clone https://github.com/videosdk-community/videosdk-nodejs-video-calling
cd videosdk-nodejs-video-calling
npm install
cp .env.example .env
For an existing Express project, install the same pinned versions:
npm install --save-exact express@5.3.0 @videosdk.live/server-sdk@0.1.1 @videosdk.live/js-sdk@1.2.1
Open the VideoSDK dashboard, select API Keys, and copy your key and secret into .env:
VIDEOSDK_API_KEY=your-api-key
VIDEOSDK_SECRET=your-secret
Run npm run dev, then open http://localhost:3000. The script runs node --watch --env-file=.env server.js, which loads the environment file without dotenv. Express serves the page and browser JavaScript without a bundler.
The default port is 3000. Set PORT in your environment to use another port. The repo also provides npm start, which loads the same .env file without watch mode.
At the top of server.js, create one VideoSDK client with the server's credentials:
import { randomUUID } from "node:crypto";
import express from "express";
import { VideoSDK, Grant } from "@videosdk.live/server-sdk";
// Your API key and secret never leave this process.
const videosdk = new VideoSDK({
apiKey: process.env.VIDEOSDK_API_KEY,
secret: process.env.VIDEOSDK_SECRET,
});
Create a room and mint a scoped token
Three terms explain the access model:
- A room identifies where participants join. Its ID goes in the invite link.
- A token is a JSON Web Token (JWT) the browser presents to VideoSDK. Your server signs it with the API secret.
- A grant is a permission in that token.
allow_joinpermits joining, andallow_modgrants moderator permission.
The mintToken helper generates a participant ID and scopes the token to that participant and room. The host gets both grants for 2 hours. Guests get only allow_join for 10 minutes:
// A token that works for one room and one participant only.
// The host can moderate and needs the token for the whole call (it proves
// who may end it). Guests only need theirs long enough to join.
function mintToken(roomId, { host }) {
const participantId = randomUUID();
const token = videosdk
.accessToken()
.setParticipant(participantId)
.forRoom(roomId)
.grant(...(host ? [Grant.AllowJoin, Grant.AllowMod] : [Grant.AllowJoin]))
.expiresIn(host ? "2h" : "10m")
.toJwt();
return { roomId, participantId, token, host };
}
The access token builder signs locally. Calling toJwt() makes no network request. The browser must later use the participant ID returned alongside the token.
These participant tokens have roles: ["rtc"]. They authorize joining a room, not calling the management API. Even the host asks your server to end the call. Your server holds the API credentials and checks the host's signed permissions before making the management calls.
The shorter guest expiry gives the guest time to join. Token expiry doesn't disconnect a participant who is already in the call. The host token also authorizes the end request, so its expiry must outlast the call.
Pass Grant.AllowJoin explicitly for guests. With no grants, the builder defaults to allow_join plus allow_mod. Omitting the guest grant would give that participant moderator permission.
Create the room, then return credentials for its host:
// Create a room. Whoever creates it is the host.
app.post("/api/rooms", async (req, res) => {
const room = await videosdk.rooms.create();
res.json(mintToken(room.roomId, { host: true }));
});
A guest supplies the room ID from the invite link. Validate it before issuing credentials, and return a different error for an unknown room and a disabled one:
// Let a guest in, but only into a room that exists and is still open.
app.post("/api/rooms/:roomId/token", async (req, res) => {
const { valid, room } = await videosdk.rooms.validate(req.params.roomId);
if (!valid) return res.status(404).json({ error: "No call with this link." });
if (room.disabled) return res.status(410).json({ error: "This call has ended." });
res.json(mintToken(room.roomId, { host: false }));
});
Add your login or invite check before this route issues a token. The demo admits anyone with the link. Token issuance is the point where your application decides who can join. The JavaScript SDK server setup guide covers the same backend token step.
The server stores no room or host state. The signed token carries the room ID and permissions that the end route checks later. The response's host field controls button visibility, while the signed grants enforce access.
Join the video call from the browser
The browser uses plain HTML and one JavaScript file. VideoSDK's video calling SDK loads from the installed package, so the same npm install covers both SDKs. Express serves its bundle at /vendor/videosdk.js:
app.get("/vendor/videosdk.js", (req, res) =>
res.sendFile(import.meta.dirname + "/node_modules/@videosdk.live/js-sdk/videosdk.js"),
);
Submitting the form asks your server for credentials. On the start page, that request creates a room. From an invite link, it requests a guest token unless the browser already has saved host credentials:
const roomId = roomInUrl();
// A host who refreshes keeps their host token. Everyone else asks the server.
session = roomId
? (JSON.parse(sessionStorage.getItem(roomId)) ?? (await api(`/api/rooms/${roomId}/token`)))
: await api("/api/rooms");
The host's credentials live in sessionStorage under the room ID. Refreshing the host tab retains its host token. Guests request a fresh token when they join.
After creating the room, the browser updates its URL to /room/:roomId with history.replaceState. That URL is the invite link. It contains the room ID, while the host token stays in sessionStorage. Opening the link as a guest doesn't copy the host's permissions.
Pass the credentials to the JavaScript SDK to join the room:
async function join(name) {
VideoSDK.config(session.token);
meeting = VideoSDK.initMeeting({
meetingId: session.roomId,
participantId: session.participantId, // must match the id inside the token
name,
micEnabled: true,
webcamEnabled: true,
});
meeting.on("meeting-joined", () => {
meeting.participants.forEach(addTile);
$("join-form").hidden = true;
$("call").hidden = false;
$("end").hidden = !session.host;
$("room-label").textContent = session.roomId;
$("room-label").hidden = $("copy-link").hidden = false;
});
meeting.on("participant-joined", addTile);
meeting.on("participant-left", (participant) => $(`tile-${participant.id}`)?.remove());
meeting.on("meeting-left", onMeetingLeft);
addTile(meeting.localParticipant);
await meeting.join();
}
The JavaScript SDK calls the room ID meetingId. Pass session.roomId there, and keep participantId identical to the ID in the token. The display name comes from the form and isn't part of the token.
The event handlers add participants already in the call and those who join later. They remove a tile when its participant leaves. Each tile receives media through stream events:
const render = (stream, on) => {
const media = on ? new MediaStream([stream.track]) : null;
if (stream.kind === "video") {
video.srcObject = media;
tile.classList.toggle("cam-off", !on);
}
if (stream.kind === "audio" && audio) audio.srcObject = media;
};
participant.on("stream-enabled", (stream) => render(stream, true));
participant.on("stream-disabled", (stream) => render(stream, false));
A MediaStream holds tracks, and each track carries one audio or video feed. Enabling a stream attaches its track to a media element. Disabling it clears that element. The app never plays the local participant's audio back to them.
The repo's full addTile function also renders tracks already present in participant.streams. It checks whether a tile exists before adding one, so repeated participant handling doesn't duplicate tiles.
Camera-off tiles show the participant's initial. The controls call meeting.muteMic() or meeting.unmuteMic(), and meeting.disableWebcam() or meeting.enableWebcam(). Leave calls meeting.leave() and removes only that participant.
Open http://localhost:3000, enter a name, and press Start call. Copy the invite link and open it in a second tab. Join as a guest and check that the guest has no End for everyone button.
Turn the guest's camera off and confirm that its tile shows an initial in the host tab. Turn it back on to restore video. Mute and unmute the guest to check the audio controls.
End the video call for everyone
A session is one running instance of a room. Disconnecting that session and disabling the room are separate operations. The host's end route performs both.
Pressing End for everyone sends the host token in the Authorization header. This route expects the raw JWT, without a Bearer prefix:
await api(`/api/rooms/${session.roomId}/end`, { Authorization: session.token });
The server verifies the token before it changes the call:
// End the call for everyone. Only a token this server minted with
// allow_mod for this exact room can do it.
app.post("/api/rooms/:roomId/end", async (req, res) => {
let claims;
try {
claims = videosdk.verifyToken(req.get("Authorization") ?? "");
} catch {
return res.status(401).json({ error: "Invalid or expired token." });
}
if (claims.roomId !== req.params.roomId || !claims.permissions?.includes(Grant.AllowMod)) {
return res.status(403).json({ error: "Only the host can end this call." });
}
// Ending the live session disconnects everyone in the call.
// Ending the room then makes sure nobody can start it again.
await videosdk.sessions.end({ roomId: req.params.roomId });
await videosdk.rooms.end(req.params.roomId);
res.sendStatus(204);
});
verifyToken checks the signature and expiry. An invalid or expired token gets 401. A valid token for another room, or one without allow_mod, gets 403. Hiding the guest's button doesn't enforce this restriction; the route does.
After authorization, the route disconnects the live session, disables the room, and returns 204. The host browser doesn't call meeting.end() or meeting.leave() in this flow. VideoSDK disconnects the host along with everyone else.
End the live session and disable the room
I tested these operations on 29 Oct 2026 with Server SDK 0.1.1 and JavaScript SDK 1.2.1. The test used two Chromium windows with fake camera and mic devices:
-
sessions.enddisconnected every participant within a second. The room stayed enabled. -
rooms.endalone disabled the room but left live participants connected for more than 60 seconds. - A guest token sent directly to the end route got 403.
So call sessions.end first, then rooms.end. The first call disconnects everyone in the live session. The second disables the room, so nobody can start a new call in it. The Server SDK sessions reference lists the other sessions.end options.
Every browser, including the host's, receives meeting-left with code 1009. The app checks that code to choose its message:
// The server's sessions.end() arrives here as MEETING_END_API (1009).
const endedByServer = reason?.code === VideoSDK.Constants.leaveReason.MEETING_END_API;
Code 1009 displays "The host ended this call for everyone." Other leave reasons display "You left the call." A server-ended call also clears the saved host credentials. Opening the old link and trying to join returns 410 and displays "This call has ended."
Add video calling to your own Express app
Install the pinned packages and set VIDEOSDK_API_KEY and VIDEOSDK_SECRET in your environment. Copy the VideoSDK client, mintToken, and the three API routes. Include /room/:roomId so invite links serve your page. Then serve the SDK bundle and connect the browser's join and tile code.
You can also give the repo to your AI coding agent and ask it to integrate these routes and browser controls. If you sign tokens without the Server SDK, VideoSDK's Node.js API server example shows an Express token server built on plain JWT code.
Before deploying, account for the demo's limits:
- It has no login. Add authentication middleware to
POST /api/roomsandPOST /api/rooms/:roomId/token. - Camera access needs localhost or HTTPS. The repo has no deployment configuration, so configure HTTPS before sharing a link with another machine.
- The host token expires after 2 hours. Later end requests return 401, even if the call is running. Increase the host expiry in
mintTokenfor longer calls.
Node.js video chat app FAQ
How do I build a video chat app with Node.js?
To make a video calling app with Node.js, use Node.js to create rooms and issue participant tokens, then join from the browser. VideoSDK's Server SDK handles room management and token signing, while its JavaScript SDK connects participants. VideoSDK carries the audio and video.
Does a Node.js video chat app need WebRTC code?
A Node.js video chat app built with VideoSDK needs no WebRTC code in its application files. VideoSDK's JavaScript SDK handles browser connections, while the Server SDK manages rooms and tokens in Node.js.
What is a video call API?
A video call API adds live audio and video to your application through a hosted service. Your application creates rooms and controls access, while the service handles signaling and media.
Can I end a video call for everyone from the server?
Yes. Call sessions.end to disconnect every participant in a VideoSDK call, then rooms.end to disable the room so nobody can start it again. In the 29 Oct 2026 test with Server SDK 0.1.1 and JavaScript SDK 1.2.1, rooms.end alone left live participants connected.
Build a video chat app with Node.js and test both exits
Clone videosdk-nodejs-video-calling on GitHub and get a free API key from the VideoSDK dashboard. Add your credentials to .env and run npm run dev. The Server SDK docs cover the room and token methods used here.
Join from two tabs. Try Leave as the guest and confirm the host stays connected. Rejoin, then press End for everyone as the host and confirm both browsers disconnect. Try the invite link once more to check that the room refuses entry.







Top comments (0)