Subchapter 149.3
references/web.mdMarkdown26 KBView on GitHub
The Zoom Video SDK for Web enables fully customized video applications using Zoom’s infrastructure. You control the UI, branding, and user experience.
npm install @zoom/videosdkNote: Some networks/ad blockers can block
source.zoom.us. Prefer allowlisting the domain in managed environments. If you need a fallback, consider mirroring/self-hosting only if permitted and you can keep versions in sync.
# Download SDK locally
curl "https://source.zoom.us/videosdk/zoom-video-1.12.0.min.js" -o public/js/zoom-video-sdk.min.js<!-- Use local copy instead of CDN -->
<script src="js/zoom-video-sdk.min.js"></script>Note: Remember to update your local copy when new SDK versions are released.
import ZoomVideo from '@zoom/videosdk';
const client = ZoomVideo.createClient();
await client.init('en-US', 'Global', { patchJsMedia: true });
await client.join(topic, signature, userName, password);
// CRITICAL: getMediaStream() ONLY works AFTER join()
const stream = client.getMediaStream();
await stream.startVideo();
await stream.startAudio();// CDN exports as WebVideoSDK, NOT ZoomVideo
// Must use .default property
const ZoomVideo = WebVideoSDK.default;
const client = ZoomVideo.createClient();
await client.init('en-US', 'Global', { patchJsMedia: true });
await client.join(topic, signature, userName, password);
// CRITICAL: getMediaStream() ONLY works AFTER join()
const stream = client.getMediaStream();
await stream.startVideo();
await stream.startAudio();When using <script type="module"> with CDN, the SDK may not be loaded yet:
function waitForSDK(timeout = 10000) {
return new Promise((resolve, reject) => {
if (typeof WebVideoSDK !== 'undefined') {
resolve();
return;
}
const start = Date.now();
const check = setInterval(() => {
if (typeof WebVideoSDK !== 'undefined') {
clearInterval(check);
resolve();
} else if (Date.now() - start > timeout) {
clearInterval(check);
reject(new Error('SDK failed to load'));
}
}, 100);
});
}
// Usage
await waitForSDK();
const ZoomVideo = WebVideoSDK.default;
const client = ZoomVideo.createClient();The SDK has a strict lifecycle. Violating it causes silent failures.
1. Create client: client = ZoomVideo.createClient()
2. Initialize: await client.init('en-US', 'Global', options)
3. Join session: await client.join(topic, signature, userName, password)
4. Get stream: stream = client.getMediaStream() ← ONLY AFTER JOIN
5. Start media: await stream.startVideo() / await stream.startAudio()Common Mistake:
// ❌ WRONG: Getting stream before joining
const stream = client.getMediaStream(); // Returns undefined!
await client.join(...);
// ✅ CORRECT: Get stream after joining
await client.join(...);
const stream = client.getMediaStream(); // Works!These show up repeatedly in forum “high CPU”, “freezing”, and “Safari/Firefox rendering” threads.
isSupportMultipleVideos() is false (mobile Safari, lower-end devices).Enable WebRTC mode for direct peer-to-peer streaming with HD video support:
await client.init('en-US', 'Global', {
patchJsMedia: true,
webrtc: true // Enable WebRTC mode
});Benefits:
Check support before using:
const stream = client.getMediaStream();
// Always check support first
if (stream.isSupportVirtualBackground()) {
// Blur background
await stream.updateVirtualBackgroundImage('blur');
// Custom image background
await stream.updateVirtualBackgroundImage('https://example.com/bg.jpg');
// Remove virtual background
await stream.updateVirtualBackgroundImage(undefined);
} else {
console.log('Virtual backgrounds not supported on this device');
}Always check if HD is supported before enabling:
const stream = client.getMediaStream();
// Check if 720p is supported
const hdSupported = stream.isSupportHDVideo();
console.log('HD (720p) supported:', hdSupported);
// Get maximum video quality (returns VideoQuality enum)
const maxQuality = stream.getVideoMaxQuality();
// 0=90P, 1=180P, 2=360P, 3=720P, 4=1080P
// Check SharedArrayBuffer (required for HD)
const sabAvailable = typeof SharedArrayBuffer === 'function';
if (!sabAvailable) {
console.warn('HD requires SharedArrayBuffer - enable COOP/COEP headers');
}// Start video with HD quality (720p)
await stream.startVideo({ hd: true });
// Start video with Full HD (1080p)
await stream.startVideo({ hd: true, fullHd: true });
// Subscribe to specific quality
await stream.attachVideo(userId, VideoQuality.Video_720P);// Check if gallery view is possible
const multipleVideosSupported = stream.isSupportMultipleVideos();
// Get max renderable videos
const maxRenderable = stream.getMaxRenderableVideos();
console.log('Can render up to', maxRenderable, 'videos');async function checkHDCapability(client) {
// 1. Check SharedArrayBuffer
const sabAvailable = typeof SharedArrayBuffer === 'function';
// 2. Check system requirements
const compatibility = ZoomVideo.checkSystemRequirements();
// 3. Check feature requirements
const features = ZoomVideo.checkFeatureRequirements();
// 4. After joining, check stream capabilities
const stream = client.getMediaStream();
return {
sharedArrayBuffer: sabAvailable,
videoCompatible: compatibility.video,
hdSupported: stream.isSupportHDVideo(),
maxQuality: stream.getVideoMaxQuality(),
maxRenderable: stream.getMaxRenderableVideos(),
multipleVideos: stream.isSupportMultipleVideos(),
virtualBackground: stream.isSupportVirtualBackground()
};
}Resolution tiers:
Concurrent HD limits:
Multiple rendering options available:
The VideoProcessor class allows you to intercept and modify video frames before transmission. Use this for custom overlays, effects, face detection, and more.
VideoProcessor classprocessFrame() to modify each frameOffscreenCanvas// video-processor-worker.js
class MyVideoProcessor extends VideoProcessor {
constructor(port, options) {
super(port, options);
}
processFrame(input, output) {
const ctx = output.getContext('2d');
// Draw original frame
ctx.drawImage(input, 0, 0);
// Add overlay (e.g., text, graphics)
ctx.fillStyle = 'white';
ctx.font = '24px Arial';
ctx.fillText('Live', 20, 40);
return true;
}
}Combine VideoProcessor with face-api.js for face detection overlays:
// video-processor-worker.js
import * as faceapi from 'face-api.js';
class FaceDetectionProcessor extends VideoProcessor {
async processFrame(input, output) {
const ctx = output.getContext('2d');
// Draw original frame
ctx.drawImage(input, 0, 0);
// Detect faces
const detections = await faceapi.detectAllFaces(input);
// Draw bounding boxes
detections.forEach(detection => {
const box = detection.box;
ctx.strokeStyle = '#00ff00';
ctx.lineWidth = 2;
ctx.strokeRect(box.x, box.y, box.width, box.height);
});
return true;
}
}| Use Case | Description |
|---|---|
| Face detection | Bounding boxes, landmarks |
| AR effects | Glasses, hats, masks |
| Beauty filters | Skin smoothing, color correction |
| Overlays | Text, logos, watermarks |
| Real-time translation | OCR + translation overlay |
The SDK is event-driven. You must listen for events and render/detach videos accordingly.
renderVideo() is deprecated. Use attachVideo() which returns a VideoPlayer element to append to DOM.
import { VideoQuality } from '@zoom/videosdk';
const stream = client.getMediaStream();
// Start your camera
await stream.startVideo();
// Attach video - returns a VideoPlayer element
const videoElement = await stream.attachVideo(userId, VideoQuality.Video_360P);
// Append to your container
document.getElementById('video-container').appendChild(videoElement);import { VideoQuality } from '@zoom/videosdk';
VideoQuality.Video_90P // 0
VideoQuality.Video_180P // 1
VideoQuality.Video_360P // 2 (recommended for most cases)
VideoQuality.Video_720P // 3
VideoQuality.Video_1080P // 4// Detach and remove from DOM
const elements = await stream.detachVideo(userId);
if (Array.isArray(elements)) {
elements.forEach(e => e.remove());
} else {
elements.remove();
}You MUST listen to these events to properly render participant videos:
// When another participant's video state changes
client.on('peer-video-state-change', async (payload) => {
const { action, userId } = payload;
if (action === 'Start') {
// Participant turned on video - attach it
const element = await stream.attachVideo(userId, VideoQuality.Video_360P);
container.appendChild(element);
} else if (action === 'Stop') {
// Participant turned off video - detach it
await stream.detachVideo(userId);
}
});
// When participants join/leave
client.on('user-added', (payload) => {
// New participant joined - check if their video is on
const users = client.getAllUser();
// Render videos for users with bVideoOn === true
});
client.on('user-removed', (payload) => {
// Participant left - clean up their video element
const { userId } = payload;
stream.detachVideo(userId);
});
// Participant state updates (mute, video, etc)
client.on('user-updated', (payload) => {
// Re-check participant states
const users = client.getAllUser();
});const users = client.getAllUser();
users.forEach(user => {
user.userId // Unique user ID
user.displayName // User's display name
user.bVideoOn // Boolean - is video enabled?
user.muted // Boolean - is audio muted?
user.audio // '' | 'computer' | 'phone'
});// When your video turns on
useEffect(() => {
if (isVideoOn && stream && currentUserId) {
const attach = async () => {
const element = await stream.attachVideo(currentUserId, VideoQuality.Video_360P);
containerRef.current?.appendChild(element);
};
attach();
}
}, [isVideoOn, stream, currentUserId]);
// Listen for other participants
useEffect(() => {
if (!client) return;
const handleVideoChange = async (payload) => {
const { action, userId } = payload;
if (action === 'Start') {
const element = await stream.attachVideo(userId, VideoQuality.Video_360P);
// Append to appropriate container
} else {
await stream.detachVideo(userId);
}
};
client.on('peer-video-state-change', handleVideoChange);
return () => client.off('peer-video-state-change', handleVideoChange);
}, [client, stream]);Note:
renderVideo()is deprecated. UseattachVideo()instead.
If you must use canvas rendering:
// CORRECT: Canvas exists before rendering
const canvas = document.getElementById('my-canvas'); // Already in DOM
await stream.renderVideo(canvas, userId, 640, 360, 0, 0, 3);
// WRONG: Creating canvas but not adding to DOM
const canvas = document.createElement('canvas');
await stream.renderVideo(canvas, userId); // Won't display!For performance, use ONE shared rendering control for all video streams, NOT one control per video.
// CORRECT: Single video container for all participants
const videoContainer = document.getElementById('video-container');
const stream = client.getMediaStream();
// Render all participants to the same container
await stream.renderVideo(videoContainer, userId, width, height, x, y, quality);// WRONG: Creating separate controls per participant
// This degrades performance significantly!
participants.forEach(p => {
const container = document.createElement('div'); // DON'T do this
stream.renderVideo(container, p.id, ...);
});Why:
Existing participants’ videos won’t auto-render when you join mid-session.
You must manually iterate all users and attach their video:
import { VideoQuality } from '@zoom/videosdk';
// After joining, render existing participants' videos
const renderExistingVideos = async () => {
await new Promise(resolve => setTimeout(resolve, 500));
const stream = client.getMediaStream();
const users = client.getAllUser();
const currentUserId = client.getCurrentUserInfo().userId;
for (const user of users) {
if (user.bVideoOn && user.userId !== currentUserId) {
const element = await stream.attachVideo(user.userId, VideoQuality.Video_360P);
document.getElementById(`video-${user.userId}`).appendChild(element);
}
}
};Key points:
user.bVideoOn to see if video is enabledclient.getCurrentUserInfo().userId)attachVideo() not renderVideo()For optimal performance, configure these headers on your server:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corpNote: As of v1.11.2, SharedArrayBuffer is elective (not strictly required).
The SDK is event-driven. You MUST listen for these events:
// Participant joined
client.on('user-added', (payload) => {
console.log('User joined:', payload);
// payload contains user info
});
// Participant left
client.on('user-removed', (payload) => {
console.log('User left:', payload);
// Clean up their video element
stream.detachVideo(payload.userId);
});
// Participant state changed (mute, video, etc)
client.on('user-updated', (payload) => {
console.log('User updated:', payload);
});
// CRITICAL: Other participant's video turned on/off
client.on('peer-video-state-change', async (payload) => {
const { action, userId } = payload;
// action: 'Start' | 'Stop'
if (action === 'Start') {
const element = await stream.attachVideo(userId, VideoQuality.Video_360P);
// Append element to container
} else {
await stream.detachVideo(userId);
}
});
// Connection state changed
client.on('connection-change', (payload) => {
// payload.state: 'Connected' | 'Closed' | 'Reconnecting' | etc
});Always remove listeners when component unmounts:
// React pattern
useEffect(() => {
const handler = (payload) => { /* ... */ };
client.on('peer-video-state-change', handler);
return () => {
client.off('peer-video-state-change', handler);
};
}, [client]);await stream.startVideo();
await stream.stopVideo();await stream.startAudio();
await stream.muteAudio();
await stream.unmuteAudio();Listen to active-share-change event and render when active:
client.on('active-share-change', async (payload) => {
const stream = client.getMediaStream();
if (payload.state === 'Active') {
// Add small delay to ensure DOM element exists
await new Promise(resolve => setTimeout(resolve, 100));
// Check if should use video element or canvas
if (stream.isStartShareScreenWithVideoElement()) {
const video = document.getElementById('share-video');
await stream.startShareView(video as unknown as HTMLCanvasElement, payload.userId);
} else {
const canvas = document.getElementById('share-canvas');
await stream.startShareView(canvas, payload.userId);
}
} else if (payload.state === 'Inactive') {
await stream.stopShareView();
}
});Check rendering mode before starting:
const stream = client.getMediaStream();
// Check which element type to use
if (stream.isStartShareScreenWithVideoElement()) {
// Use video element
const video = document.getElementById('share-video');
await stream.startShareScreen(video as unknown as HTMLCanvasElement);
} else {
// Use canvas element
const canvas = document.getElementById('share-canvas');
await stream.startShareScreen(canvas);
}await stream.stopShareScreen();SDK types expect HTMLCanvasElement even for video elements. Cast when needed:
// When using HTMLVideoElement where SDK expects HTMLCanvasElement
const video = document.getElementById('share-video') as HTMLVideoElement;
await stream.startShareView(video as unknown as HTMLCanvasElement, userId);// Leave session (others stay)
await client.leave();
// End session for ALL participants (host only)
await client.leave(true);const chatClient = client.getChatClient();
// Wait for chat to be ready
chatClient.on('chat-on', () => {
console.log('Chat is ready');
});// Send to everyone
await chatClient.send('Hello, everyone!');
// Send to specific user
await chatClient.sendToUser(userId, 'Private message');chatClient.on('chat-on-message', (payload) => {
const { message, sender, timestamp } = payload;
console.log(`${sender.name}: ${message}`);
});| Error | Cause | Solution |
|---|---|---|
Invalid signature | JWT expired or malformed | Generate new signature |
Session does not exist | Host hasn’t started yet | Show “waiting” message, retry |
Permission denied | User denied camera/mic | Request permission again |
try {
await client.join(topic, signature, userName, password);
} catch (error) {
if (error.reason?.includes('signature')) {
// Regenerate signature and retry
} else if (error.reason?.includes('Session')) {
// Show "Waiting for host..." and poll
} else if (error.reason?.includes('Permission')) {
// Guide user to enable permissions
}
console.error('Join failed:', error);
}Only the host (role=1) can start/stop recording:
const recordingClient = client.getRecordingClient();
// Start cloud recording
await recordingClient.startCloudRecording();
// Stop recording
await recordingClient.stopCloudRecording();
// Listen for recording status changes
client.on('recording-change', (payload) => {
console.log('Recording status:', payload.status);
});const transcriptionClient = client.getLiveTranscriptionClient();
// Start live transcription
await transcriptionClient.startLiveTranscription();
// Stop live transcription
await transcriptionClient.stopLiveTranscription();
// Listen for captions
client.on('caption-message', (payload) => {
console.log(`${payload.displayName}: ${payload.text}`);
});// Get available devices
const devices = await ZoomVideo.getDevices();
console.log('Cameras:', devices.cameras);
console.log('Microphones:', devices.microphones);
console.log('Speakers:', devices.speakers);
// Get currently active devices
const stream = client.getMediaStream();
const activeCamera = stream.getActiveCamera();
const activeMic = stream.getActiveMicrophone();
const activeSpeaker = stream.getActiveSpeaker();
// Switch devices
await stream.switchCamera(deviceId);
await stream.switchMicrophone(deviceId);
await stream.switchSpeaker(deviceId);Monitor network quality in real-time:
client.on('network-quality-change', (payload) => {
// payload.type = 'uplink' or 'downlink'
// payload.level = 0-5 (5 is best)
if (payload.level < 2) {
console.warn(`Poor ${payload.type} network quality: ${payload.level}`);
}
});const chatClient = client.getChatClient();
// Send file to everyone (receiverId = 0)
await chatClient.sendFile(file, 0);
// Send file to specific user
await chatClient.sendFile(file, userId);Video SDK uses “Subsessions” instead of native breakout rooms:
const subsessionClient = client.getSubsessionClient();
// Create subsessions
const roomNames = ['Room 1', 'Room 2', 'Room 3'];
await subsessionClient.createSubsessions(roomNames);
// Open subsessions
const rooms = subsessionClient.getSubsessionList();
await subsessionClient.openSubsessions(rooms);
// Broadcast message to all rooms
await subsessionClient.broadcast('Please return to main session in 5 minutes');
// Close all subsessions
await subsessionClient.closeAllSubsessions();Use the command channel for custom messages like reactions:
const commandClient = client.getCommandClient();
// Send reaction
const reaction = { type: 'reaction', emoji: '👍' };
await commandClient.send(JSON.stringify(reaction));
// Receive reactions
client.on('command-channel-message', (payload) => {
try {
const data = JSON.parse(payload.text);
if (data.type === 'reaction') {
console.log(`${payload.senderName} reacted with ${data.emoji}`);
}
} catch (e) {
console.log('Non-JSON message:', payload.text);
}
});CORS errors to log-external-gateway.zoom.us are harmless.
These are caused by COOP/COEP headers blocking telemetry requests. They don’t affect SDK functionality.
// These console errors can be safely ignored:
// Access to fetch at 'https://log-external-gateway.zoom.us/...' has been blocked by CORS policyconst stream = client.getMediaStream();
// Mute all participants
await stream.muteAllAudio();
// Mute/unmute specific participant
await stream.muteAudio(userId);
await stream.unmuteAudio(userId);
// Remove participant (host only)
await client.removeUser(userId);
// Transfer host
await client.makeHost(userId);
// Make co-host
await client.makeManager(userId);const stream = client.getMediaStream();
// Toggle mirror (useful for self-view)
await stream.mirrorVideo(true); // Enable mirror
await stream.mirrorVideo(false); // Disable mirrorconst stream = client.getMediaStream();
const shareElement = document.getElementById('share-element');
// Share with system audio
await stream.startShareScreen(shareElement, {
secondaryAudio: true
});Causes:
source.zoom.us CDNSolutions:
source.zoom.us in your environment, or use a permitted fallback (mirror/self-host) if you can keep versions in syncwaitForSDK() function for ES modulesCauses:
Solutions:
Cause: Called getMediaStream() before join() completed
Solution: Follow the SDK lifecycle order exactly (see SDK Lifecycle section)
Cause: Frontend (HTTPS) calling backend (HTTP) or different origin
Solutions:
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
| Video | ✅ 80+ | ✅ 75+ | ✅ 14+ | ✅ 80+ |
| Audio | ✅ 80+ | ✅ 75+ | ✅ 14+ | ✅ 80+ |
| Screen Share | ✅ 80+ | ✅ 75+ | ⚠️ 15+ | ✅ 80+ |
| Virtual BG | ✅ 80+ | ✅ 90+ | ❌ | ✅ 80+ |
Safari Notes:
This file