
Zoom Apps Sdk
- 1.4k installs
- 23.1k repo stars
- Updated July 28, 2026
- anthropics/knowledge-work-plugins
zoom-apps-sdk is an agent skill for reference skill for zoom apps sdk. use after routing to an in-client app workflow when building web apps that run inside zoom meetings, webinars, the main client, or zoom phone.
About
The zoom-apps-sdk skill is designed for reference skill for Zoom Apps SDK. Use after routing to an in-client app workflow when building web apps that run inside Zoom meetings, webinars, the main client, or Zoom Phone. Zoom Apps SDK Background reference for web apps that run inside the Zoom client. Prefer choose-zoom-approach first, then route here for Layers API, Collaborate Mode, in-client OAuth, and runtime constraints. Invoke when the user asks about zoom apps sdk or related SKILL.md workflows.
- API Reference - All SDK methods by category.
- Events Reference - All SDK event listeners.
- Layers API - Immersive and camera mode rendering.
- OAuth Reference - OAuth flows for Zoom Apps.
- Zoom Mail - Mail plugin integration.
Zoom Apps Sdk by the numbers
- 1,396 all-time installs (skills.sh)
- +80 installs in the week ending Jul 28, 2026 (Skillselion tracking)
- Ranked #273 of 1,896 Design & UI/UX skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Jul 28, 2026 (Skillselion catalog sync)
zoom-apps-sdk capabilities & compatibility
- Capabilities
- api reference all sdk methods by category · events reference all sdk event listeners · layers api immersive and camera mode rendering · oauth reference oauth flows for zoom apps
- Use cases
- frontend
What zoom-apps-sdk says it does
Reference skill for Zoom Apps SDK. Use after routing to an in-client app workflow when building web apps that run inside Zoom meetings, webinars, the main client, or Zoom Phone.
Reference skill for Zoom Apps SDK. Use after routing to an in-client app workflow when building web apps that run inside Zoom meetings, webinars, the main clien
npx skills add https://github.com/anthropics/knowledge-work-plugins --skill zoom-apps-sdkAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1.4k |
|---|---|
| repo stars | ★ 23.1k |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 28, 2026 |
| Repository | anthropics/knowledge-work-plugins ↗ |
How do I reference skill for zoom apps sdk. use after routing to an in-client app workflow when building web apps that run inside zoom meetings, webinars, the main client, or zoom phone?
Reference skill for Zoom Apps SDK. Use after routing to an in-client app workflow when building web apps that run inside Zoom meetings, webinars, the main client, or Zoom Phone.
Who is it for?
Developers using zoom apps sdk workflows documented in SKILL.md.
Skip if: Skip when the task falls outside zoom-apps-sdk scope or needs a different stack.
When should I use this skill?
User asks about zoom apps sdk or related SKILL.md workflows.
What you get
Completed zoom-apps-sdk workflow with documented commands, files, and expected deliverables.
- Zoom App frontend scaffold
- OAuth backend integration plan
- SDK initialization patterns
Files
Zoom Apps SDK
Background reference for web apps that run inside the Zoom client. Prefer choose-zoom-approach first, then route here for Layers API, Collaborate Mode, in-client OAuth, and runtime constraints.
Zoom Apps SDK
Build web apps that run inside the Zoom client - meetings, webinars, main client, and Zoom Phone.
Official Documentation: https://developers.zoom.us/docs/zoom-apps/ SDK Reference: https://appssdk.zoom.us/ NPM Package: https://www.npmjs.com/package/@zoom/appssdk
Quick Links
New to Zoom Apps? Follow this path:
1. [Architecture](concepts/architecture.md) - Frontend/backend pattern, embedded browser, deep linking 2. [Quick Start](examples/quick-start.md) - Complete working Express + SDK app 3. [Running Contexts](concepts/running-contexts.md) - Where your app runs (inMeeting, inMainClient, etc.) 4. [Zoom Apps vs Meeting SDK](concepts/meeting-sdk-vs-zoom-apps.md) - Stop mixing app types 4. [In-Client OAuth](examples/in-client-oauth.md) - Seamless authorization with PKCE 5. [API Reference](references/apis.md) - 100+ SDK methods 6. Integrated Index - see the section below in this file 7. [5-Minute Runbook](RUNBOOK.md) - Preflight checks before deep debugging
Reference:
- [API Reference](references/apis.md) - All SDK methods by category
- [Events Reference](references/events.md) - All SDK event listeners
- [Layers API](references/layers-api.md) - Immersive and camera mode rendering
- [OAuth Reference](references/oauth.md) - OAuth flows for Zoom Apps
- [Zoom Mail](references/zmail-sdk.md) - Mail plugin integration
Having issues?
- App won't load in Zoom → Check Domain Allowlist below
- SDK errors → Common Issues
- Local dev setup → Debugging Guide
- Version upgrade → Migration Guide
- Forum-derived FAQs → Forum Top Questions
Building immersive experiences?
- Layers Immersive Mode - Custom video layouts
- Camera Mode - Virtual camera overlays
Need help with OAuth? See the [zoom-oauth](../oauth/SKILL.md) skill for authentication flows.
SDK Overview
The Zoom Apps SDK (@zoom/appssdk) provides JavaScript APIs for web apps running in Zoom's embedded browser:
- Context APIs - Get meeting, user, and participant info
- Meeting Actions - Share app, invite participants, open URLs
- Authorization - In-Client OAuth with PKCE (no browser redirect)
- Layers API - Immersive video layouts and camera mode overlays
- Collaborate Mode - Shared app state across participants
- App Communication - Message passing between app instances (main client <-> meeting)
- Media Controls - Virtual backgrounds, camera listing, recording control
- UI Controls - Expand app, notifications, popout
- Events - React to meeting state, participants, sharing, and more
Prerequisites
- Zoom app configured as "Zoom App" type in Marketplace
- OAuth credentials (Client ID + Secret) with Zoom Apps scopes
- Web application (Node.js + Express recommended)
- Your domain whitelisted in Marketplace domain allowlist
- ngrok or HTTPS tunnel for local development
- Node.js 18+ (for the backend server)
Quick Start
Option A: NPM (Recommended for frameworks)
npm install @zoom/appssdkimport zoomSdk from '@zoom/appssdk';
async function init() {
try {
const configResponse = await zoomSdk.config({
capabilities: [
'shareApp',
'getMeetingContext',
'getUserContext',
'openUrl'
],
version: '0.16'
});
console.log('Running context:', configResponse.runningContext);
// 'inMeeting' | 'inMainClient' | 'inWebinar' | 'inImmersive' | ...
const context = await zoomSdk.getMeetingContext();
console.log('Meeting ID:', context.meetingID);
} catch (error) {
console.error('Not running inside Zoom:', error.message);
showDemoMode();
}
}Option B: CDN (Vanilla JS)
<script src="https://appssdk.zoom.us/sdk.js"></script>
<script>
// CRITICAL: Do NOT declare "let zoomSdk" - the SDK defines window.zoomSdk globally
// Using "let zoomSdk = ..." causes: SyntaxError: redeclaration of non-configurable global property
let sdk = window.zoomSdk; // Use a different variable name
async function init() {
try {
const configResponse = await sdk.config({
capabilities: ['shareApp', 'getMeetingContext', 'getUserContext'],
version: '0.16'
});
console.log('Running context:', configResponse.runningContext);
} catch (error) {
console.error('Not running inside Zoom:', error.message);
showDemoMode();
}
}
function showDemoMode() {
document.body.innerHTML = '<h1>Preview Mode</h1><p>Open this app inside Zoom to use.</p>';
}
document.addEventListener('DOMContentLoaded', () => {
init();
setTimeout(() => { if (!sdk) showDemoMode(); }, 3000);
});
</script>Critical: Global Variable Conflict
The CDN script defines window.zoomSdk globally. Do NOT redeclare it:
// WRONG - causes SyntaxError in Zoom's embedded browser
let zoomSdk = null;
zoomSdk = window.zoomSdk;
// CORRECT - use different variable name
let sdk = window.zoomSdk;
// ALSO CORRECT - NPM import (no conflict)
import zoomSdk from '@zoom/appssdk';This only applies to the CDN approach. The NPM import creates a module-scoped variable, no conflict.
Browser Preview / Demo Mode
The SDK only functions inside the Zoom client. When accessed in a regular browser:
window.zoomSdkexists butsdk.config()throws an error- Always implement try/catch with fallback UI
- Add timeout (3 seconds) in case SDK hangs
URL Whitelisting (Required)
Your app will NOT load in Zoom unless the domain is whitelisted.
1. Go to Zoom Marketplace 2. Open your app -> Feature tab 3. Under Zoom App, find Add Allow List 4. Add your domain (e.g., yourdomain.com for production, xxxxx.ngrok.io for dev)
Without this, the Zoom client shows a blank panel with no error message.
OAuth Scopes (Required)
Capabilities require matching OAuth scopes enabled in Marketplace:
| Capability | Required Scope |
|---|---|
getMeetingContext | zoomapp:inmeeting |
getUserContext | zoomapp:inmeeting |
shareApp | zoomapp:inmeeting |
openUrl | zoomapp:inmeeting |
sendAppInvitation | zoomapp:inmeeting |
runRenderingContext | zoomapp:inmeeting |
authorize | zoomapp:inmeeting |
getMeetingParticipants | zoomapp:inmeeting |
To add scopes: Marketplace -> Your App -> Scopes tab -> Add required scopes.
Missing scopes = capability fails silently or throws error. Users must re-authorize if you add new scopes.
Running Contexts
Your app runs in different surfaces within Zoom. The configResponse.runningContext tells you where:
| Context | Surface | Description |
|---|---|---|
inMeeting | Meeting sidebar | Most common. Full meeting APIs available |
inMainClient | Main client panel | Home tab. No meeting context APIs |
inWebinar | Webinar sidebar | Host/panelist. Meeting + webinar APIs |
inImmersive | Layers API | Full-screen custom rendering |
inCamera | Camera mode | Virtual camera overlay |
inCollaborate | Collaborate mode | Shared state context |
inPhone | Zoom Phone | Phone call app |
inChat | Team Chat | Chat sidebar |
See [Running Contexts](concepts/running-contexts.md) for context-specific behavior and APIs.
SDK Initialization Pattern
Every Zoom App starts with config():
import zoomSdk from '@zoom/appssdk';
const configResponse = await zoomSdk.config({
capabilities: [
// List ALL APIs you will use
'getMeetingContext',
'getUserContext',
'shareApp',
'openUrl',
'authorize',
'onAuthorized'
],
version: '0.16'
});
// configResponse contains:
// {
// runningContext: 'inMeeting',
// clientVersion: '5.x.x',
// unsupportedApis: [] // APIs not supported in this client version
// }Rules: 1. config() MUST be called before any other SDK method 2. Only capabilities listed in config() are available 3. Capabilities must match OAuth scopes in Marketplace 4. Check unsupportedApis for graceful degradation
In-Client OAuth (Summary)
Best UX for authorization - no browser redirect:
// 1. Get code challenge from your backend
const { codeChallenge, state } = await fetch('/api/auth/challenge').then(r => r.json());
// 2. Trigger in-client authorization
await zoomSdk.authorize({ codeChallenge, state });
// 3. Listen for authorization result
zoomSdk.addEventListener('onAuthorized', async (event) => {
const { code, state } = event;
// 4. Send code to backend for token exchange
await fetch('/api/auth/token', {
method: 'POST',
body: JSON.stringify({ code, state })
});
});See [In-Client OAuth Guide](examples/in-client-oauth.md) for complete implementation.
Layers API (Summary)
Build immersive video layouts and camera overlays:
// Start immersive mode - replaces gallery view
await zoomSdk.runRenderingContext({ view: 'immersive' });
// Position participant video feeds
await zoomSdk.drawParticipant({
participantUUID: 'user-uuid',
x: 0, y: 0, width: 640, height: 480, zIndex: 1
});
// Add overlay images
await zoomSdk.drawImage({
imageData: canvas.toDataURL(),
x: 0, y: 0, width: 1280, height: 720, zIndex: 0
});
// Exit immersive mode
await zoomSdk.closeRenderingContext();See [Layers Immersive](examples/layers-immersive.md) and [Camera Mode](examples/layers-camera.md).
Environment Variables
| Variable | Description | Where to Find |
|---|---|---|
ZOOM_APP_CLIENT_ID | App client ID | Marketplace -> App -> App Credentials |
ZOOM_APP_CLIENT_SECRET | App client secret | Marketplace -> App -> App Credentials |
ZOOM_APP_REDIRECT_URI | OAuth redirect URL | Your server URL + /auth |
SESSION_SECRET | Cookie signing secret | Generate random string |
ZOOM_HOST | Zoom host URL | https://zoom.us (or https://zoomgov.com) |
Common APIs
| API | Description |
|---|---|
config() | Initialize SDK, request capabilities |
getMeetingContext() | Get meeting ID, topic, status |
getUserContext() | Get user name, role, participant ID |
getRunningContext() | Get current running context |
getMeetingParticipants() | List participants |
shareApp() | Share app screen with participants |
openUrl({ url }) | Open URL in external browser |
sendAppInvitation() | Invite users to open your app |
authorize() | Trigger In-Client OAuth |
connect() | Connect to other app instances |
postMessage() | Send message to connected instances |
runRenderingContext() | Start Layers API (immersive/camera) |
expandApp({ action }) | Expand/collapse app panel |
showNotification() | Show notification in Zoom |
Complete Documentation Library
Core Concepts
- [Architecture](concepts/architecture.md) - Frontend/backend pattern, embedded browser, deep linking, X-Zoom-App-Context
- [Running Contexts](concepts/running-contexts.md) - All contexts, context-specific APIs, multi-instance communication
- [Security](concepts/security.md) - OWASP headers, CSP, cookie security, PKCE, token storage
Complete Examples
- [Quick Start](examples/quick-start.md) - Hello World Express + SDK app
- [In-Client OAuth](examples/in-client-oauth.md) - PKCE authorization flow
- [Layers Immersive](examples/layers-immersive.md) - Custom video layouts
- [Camera Mode](examples/layers-camera.md) - Virtual camera overlays
- [Collaborate Mode](examples/collaborate-mode.md) - Shared state across participants
- [Guest Mode](examples/guest-mode.md) - Unauthenticated/authenticated/authorized states
- [Breakout Rooms](examples/breakout-rooms.md) - Room detection and cross-room state
- [App Communication](examples/app-communication.md) - connect + postMessage between instances
Troubleshooting
- [Common Issues](troubleshooting/common-issues.md) - Quick diagnostics and error codes
- [Debugging](troubleshooting/debugging.md) - Local dev, ngrok, browser preview
- [Migration](troubleshooting/migration.md) - SDK version upgrade notes
References
- [API Reference](references/apis.md) - All 100+ SDK methods
- [Events Reference](references/events.md) - All SDK event listeners
- [Layers API Reference](references/layers-api.md) - Drawing and rendering methods
- [OAuth Reference](references/oauth.md) - OAuth flows for Zoom Apps
- [Zoom Mail](references/zmail-sdk.md) - Mail plugin integration
Sample Repositories
Official (by Zoom)
| Repository | Type | Last Updated | Status | SDK Version |
|---|---|---|---|---|
| zoomapps-sample-js | Hello World (Vanilla JS) | Dec 2025 | Active | ^0.16.26 |
| zoomapps-advancedsample-react | Advanced (React + Redis) | Oct 2025 | Active | 0.16.0 |
| zoomapps-customlayout-js | Layers API | Nov 2023 | Stale | ^0.16.8 |
| zoomapps-texteditor-vuejs | Collaborate (Vue + Y.js) | Oct 2023 | Stale | ^0.16.7 |
| zoomapps-serverless-vuejs | Serverless (Firebase) | Aug 2024 | Stale | ^0.16.21 |
| zoomapps-cameramode-vuejs | Camera Mode | - | - | - |
| zoomapps-workshop-sample | Workshop | - | - | - |
Recommended for new projects: Use @zoom/appssdk version ^0.16.26.
Community
| Type | Repository | Description |
|---|---|---|
| Library | harvard-edtech/zaccl | Zoom App Complete Connection Library |
Full list: See general/references/community-repos.md
Learning Path
1. Start: zoomapps-sample-js - Simplest, most up-to-date 2. Advanced: zoomapps-advancedsample-react - Comprehensive (In-Client OAuth, Guest Mode, Collaborate) 3. Specialized: Pick based on feature (Layers, Serverless, Camera Mode)
Critical Gotchas (From Real Development)
1. Global Variable Conflict
The CDN script defines window.zoomSdk. Declaring let zoomSdk in your code causes SyntaxError: redeclaration of non-configurable global property. Use let sdk = window.zoomSdk or the NPM import.
2. Domain Allowlist
Your app URL must be in the Marketplace domain allowlist. Without it, Zoom shows a blank panel with no error. Also add appssdk.zoom.us and any CDN domains you use.
3. Capabilities Must Be Listed
Only APIs listed in config({ capabilities: [...] }) are available. Calling an unlisted API throws an error. This is also true for event listeners.
4. SDK Only Works Inside Zoom
zoomSdk.config() throws outside the Zoom client. Always wrap in try/catch with browser fallback:
try { await zoomSdk.config({...}); } catch { showBrowserPreview(); }5. ngrok URL Changes
Free ngrok URLs change on restart. You must update 4 places in Marketplace: Home URL, Redirect URL, OAuth Allow List, Domain Allow List. Consider ngrok paid plan for stable subdomain.
6. In-Client OAuth vs Web OAuth
Use zoomSdk.authorize() (In-Client) for best UX - no browser redirect. Only fall back to web redirect for initial install from Marketplace.
7. Camera Mode CEF Race Condition
Camera mode uses CEF which takes time to initialize. drawImage/drawWebView may fail if called too early. Implement retry with exponential backoff.
8. Cookie Configuration
Zoom's embedded browser requires cookies with SameSite=None and Secure=true. Without this, sessions break silently.
9. State Validation
Always validate the OAuth state parameter to prevent CSRF attacks. Generate cryptographically random state, store it, and verify on callback.
Resources
- Official docs: https://developers.zoom.us/docs/zoom-apps/
- SDK reference: https://appssdk.zoom.us/
- NPM package: https://www.npmjs.com/package/@zoom/appssdk
- Developer forum: https://devforum.zoom.us/
- GitHub SDK source: https://github.com/zoom/appssdk
---
Need help? Start with Integrated Index section below for complete navigation.
---
Integrated Index
_This section was migrated from SKILL.md._
Quick Start Path
If you're new to Zoom Apps, follow this order:
1. Run preflight checks first -> RUNBOOK.md
2. Read the architecture -> concepts/architecture.md
- Frontend/backend pattern, embedded browser, deep linking
- Understand how Zoom loads and communicates with your app
3. Build your first app -> examples/quick-start.md
- Complete Express + SDK Hello World
- ngrok setup for local development
4. Understand running contexts -> concepts/running-contexts.md
- Where your app runs (inMeeting, inMainClient, inWebinar, etc.)
- Context-specific APIs and limitations
5. Implement OAuth -> examples/in-client-oauth.md
- In-Client OAuth with PKCE (best UX)
- Token exchange and storage
6. Add features -> references/apis.md
- 100+ SDK methods organized by category
- Code examples for each
7. Troubleshoot -> troubleshooting/common-issues.md
- Quick diagnostics for common problems
---
Documentation Structure
zoom-apps-sdk/
├── SKILL.md # Main skill overview
├── SKILL.md # This file - navigation guide
│
├── concepts/ # Core architectural patterns
│ ├── architecture.md # Frontend/backend, embedded browser, OAuth flow
│ ├── running-contexts.md # Where your app runs + context-specific APIs
│ └── security.md # OWASP headers, CSP, data access layers
│
├── examples/ # Complete working code
│ ├── quick-start.md # Hello World - minimal Express + SDK app
│ ├── in-client-oauth.md # In-Client OAuth with PKCE
│ ├── layers-immersive.md # Layers API - immersive mode (custom layouts)
│ ├── layers-camera.md # Layers API - camera mode (virtual camera)
│ ├── collaborate-mode.md # Collaborate mode (shared state)
│ ├── guest-mode.md # Guest mode (unauthenticated -> authorized)
│ ├── breakout-rooms.md # Breakout room integration
│ └── app-communication.md # connect + postMessage between instances
│
├── troubleshooting/ # Problem solving guides
│ ├── common-issues.md # Quick diagnostics, error codes
│ ├── debugging.md # Local dev setup, ngrok, browser preview
│ └── migration.md # SDK version migration notes
│
└── references/ # Reference documentation
├── apis.md # Complete API reference (100+ methods)
├── events.md # All SDK events
├── layers-api.md # Layers API detailed reference
├── oauth.md # OAuth flows for Zoom Apps
└── zmail-sdk.md # Zoom Mail integration---
By Use Case
I want to build a basic Zoom App
1. Architecture - Understand the pattern 2. Quick Start - Build Hello World 3. In-Client OAuth - Add authorization 4. Security - Required headers
I want immersive video layouts (Layers API)
1. Layers Immersive - Custom video positions 2. Layers API Reference - All drawing methods 3. App Communication - Sync layout across participants
I want a virtual camera overlay
1. Camera Mode - Camera mode rendering 2. Layers API Reference - Drawing methods
I want real-time collaboration
1. Collaborate Mode - Shared state APIs 2. App Communication - Instance messaging
I want guest/anonymous access
1. Guest Mode - Three authorization states 2. In-Client OAuth - promptAuthorize flow
I want breakout room support
1. Breakout Rooms - Room detection and state sync
I want to sync between main client and meeting
1. App Communication - connect + postMessage 2. Running Contexts - Multi-instance behavior
I want serverless deployment
1. Quick Start - Understand the base pattern first 2. Sample: zoomapps-serverless-vuejs - Firebase pattern
I want to add Zoom Mail integration
1. Zoom Mail Reference - REST API + mail plugins
I'm getting errors
1. Common Issues - Quick diagnostic table 2. Debugging - Local dev setup, DevTools 3. Migration - Version compatibility
---
Most Critical Documents
1. Architecture (FOUNDATION)
[concepts/architecture.md](concepts/architecture.md)
Understand how Zoom Apps work: Frontend in embedded browser, backend for OAuth/API, SDK as the bridge. Without this, nothing else makes sense.
2. Quick Start (FIRST APP)
[examples/quick-start.md](examples/quick-start.md)
Complete working code. Get something running before diving into advanced features.
3. Common Issues (MOST COMMON PROBLEMS)
[troubleshooting/common-issues.md](troubleshooting/common-issues.md)
90% of Zoom Apps issues are: domain allowlist, global variable conflict, or missing capabilities.
---
Key Learnings
Critical Discoveries:
1. Global Variable Conflict is the #1 Gotcha
- CDN script defines
window.zoomSdkglobally let zoomSdk = ...causes SyntaxError in Zoom's browser- Use
let sdk = window.zoomSdkor NPM import
2. Domain Allowlist is Non-Negotiable
- App shows blank panel with zero error if domain not whitelisted
- Must include your domain AND
appssdk.zoom.usAND any CDN domains - ngrok URLs change on restart - must update Marketplace each time
3. config() Gates Everything
- Must be called first, must list all capabilities
- Unlisted capabilities throw errors
- Check
unsupportedApisfor client version compatibility
4. In-Client OAuth > Web OAuth for UX
authorize()keeps user in Zoom (no browser redirect)- Web redirect only needed for initial Marketplace install
- Always implement PKCE (code_verifier + code_challenge)
5. Two App Instances Can Run Simultaneously
- Main client instance + meeting instance
- Use
connect()+postMessage()to sync between them - Pre-meeting setup in main client, use in meeting
6. Camera Mode Has CEF Quirks
- CEF initialization takes time
- Draw calls may fail if too early
- Use retry with exponential backoff
7. Cookie Settings Matter
SameSite=None+Secure=truerequired- Without this, sessions silently fail in embedded browser
---
Quick Reference
"App shows blank panel"
-> Domain Allowlist - add domain to Marketplace
"SyntaxError: redeclaration"
-> Global Variable - use let sdk = window.zoomSdk
"config() throws error"
-> Browser Preview - SDK only works inside Zoom
"API call fails silently"
-> OAuth Scopes - add required scopes in Marketplace
"How do I implement [feature]?"
-> API Reference - find the method, check capabilities needed
"How do I test locally?"
-> Debugging Guide - ngrok + Marketplace config
---
Document Version
Based on @zoom/appssdk v0.16.x (latest: 0.16.26+)
---
Happy coding!
Start with Architecture to understand the pattern, then Quick Start to build your first app.
Zoom Apps Architecture
Overview
A Zoom App is a web application that runs inside the Zoom client's embedded browser. It consists of two parts:
- Frontend: Your web app loaded inside Zoom (HTML/CSS/JS +
@zoom/appssdk) - Backend: Your server handling OAuth, REST API calls, and business logic
The SDK (@zoom/appssdk) is the bridge between your frontend and the Zoom client.
Architecture Diagram
┌─────────────────────────────────────────────────────┐
│ ZOOM CLIENT │
│ │
│ ┌──────────────────────────────────────────────┐ │
│ │ Embedded Browser (WebView) │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────┐ │ │
│ │ │ YOUR FRONTEND WEB APP │ │ │
│ │ │ │ │ │
│ │ │ import zoomSdk from '@zoom/appssdk' │ │ │
│ │ │ zoomSdk.config({...}) │ │ │
│ │ │ zoomSdk.getMeetingContext() │ │ │
│ │ │ │ │ │
│ │ │ fetch('/api/data') ──────────────────────── YOUR BACKEND
│ │ └─────────────────────────────────────────┘ │ │ (Express/Node.js)
│ │ │ │ │ - OAuth token exchange
│ │ │ SDK Bridge │ │ - REST API calls
│ │ ▼ │ │ - Business logic
│ │ Zoom Client APIs │ │ - Token storage
│ │ (meeting, user, UI) │ │
│ └──────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘Embedded Browser Details
Zoom uses different browser engines per platform:
| Platform | Browser Engine | Notes |
|---|---|---|
| Windows | WebView2 (Chromium) | Modern, good DevTools |
| macOS | WKWebView (WebKit) | Safari-like behavior |
| iOS | WKWebView | Mobile viewport |
| Android | WebView | Mobile viewport |
| Some surfaces | CEF (Chromium Embedded) | Camera mode uses this |
Limitations:
- No browser extensions
- Limited
window.opensupport (usezoomSdk.openUrl()instead) - No access to browser-level storage across different apps
- CSP must allow
frame-ancestors zoom.us *.zoom.us - Cookies require
SameSite=None; Secure
App Lifecycle
Initial Install (Web OAuth)
User clicks "Add" in Marketplace
│
▼
Browser opens Zoom OAuth page
(https://zoom.us/oauth/authorize?client_id=...&code_challenge=...)
│
▼
User clicks "Allow"
│
▼
Zoom redirects to your redirect URI with ?code=...
│
▼
Your backend exchanges code + code_verifier for access_token
│
▼
Backend calls GET /v2/zoomapp/deeplink with access_token
│
▼
Backend redirects user to deeplink URL
│
▼
Zoom client opens, loads your frontend URL in embedded browser
│
▼
Frontend calls zoomSdk.config({...})
│
▼
App is readySubsequent Opens (In-Client OAuth)
User opens your app in Zoom client
│
▼
Zoom loads your frontend URL in embedded browser
│
▼
Frontend calls zoomSdk.config({...})
│
▼
Frontend calls zoomSdk.authorize({ codeChallenge, state })
│
▼
User approves in Zoom popup (no browser redirect)
│
▼
onAuthorized event fires with authorization code
│
▼
Frontend sends code to backend
│
▼
Backend exchanges code + code_verifier for tokens
│
▼
App is authorizedDeep Linking
After web-based OAuth, your backend must get a deeplink to open the app in Zoom:
// After token exchange, get deeplink
const response = await fetch('https://api.zoom.us/v2/zoomapp/deeplink', {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ action: '' })
});
const { deeplink } = await response.json();
// deeplink = 'zoommtg://zoom.us/...' or similar
// Redirect user to open in Zoom client
res.redirect(deeplink);X-Zoom-App-Context Header
When Zoom loads your frontend, it sends an X-Zoom-App-Context HTTP header. This encrypted header contains user and meeting context, allowing your backend to identify the user without OAuth.
Decryption (Node.js)
const crypto = require('crypto');
function decryptContext(header, clientSecret) {
const buf = Buffer.from(header, 'base64');
const iv = buf.slice(0, 12); // First 12 bytes = IV
const encryptedData = buf.slice(12, buf.length - 16); // Middle = ciphertext
const tag = buf.slice(buf.length - 16); // Last 16 bytes = auth tag
const key = crypto.createHash('sha256')
.update(clientSecret)
.digest();
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(tag);
const decrypted = Buffer.concat([
decipher.update(encryptedData),
decipher.final()
]);
return JSON.parse(decrypted.toString());
}
// Usage in Express middleware
app.use((req, res, next) => {
const contextHeader = req.headers['x-zoom-app-context'];
if (contextHeader) {
req.zoomContext = decryptContext(contextHeader, process.env.ZOOM_APP_CLIENT_SECRET);
// { uid: '...', aud: '...', iss: 'marketplace.zoom.us', ts: ..., ... }
}
next();
});The decrypted context contains:
uid- Zoom user IDmid- Meeting ID (if in meeting)aud- Your app's client IDiss- Issuer (marketplace.zoom.us)ts- Timestamp
Data Access Layers
Zoom Apps can access data through three layers:
| Layer | Method | Data Available | Auth Required |
|---|---|---|---|
| Contextual | SDK APIs (getMeetingContext, etc.) | Meeting/user/participant info | config() only |
| Server-side | REST API (via backend) | Full Zoom API (users, meetings, recordings) | OAuth tokens |
| Header | X-Zoom-App-Context header | User identity, meeting context | Client secret |
Domain Allowlist
The Zoom client will only load URLs from domains in your app's allowlist.
Required domains:
- Your app domain (e.g.,
yourdomain.com) appssdk.zoom.us(if using CDN)- Any CDN domains (fonts, CSS, images)
- Any API domains your frontend calls directly
Configure in: Marketplace -> Your App -> Feature -> Zoom App -> Add Allow List
Without this, the embedded browser shows a blank panel with no error.
Resources
- Architecture docs: https://developers.zoom.us/docs/zoom-apps/architecture/
- Data access: https://developers.zoom.us/docs/zoom-apps/data-access/
- X-Zoom-App-Context: https://developers.zoom.us/docs/zoom-apps/zoom-app-context/
Zoom Apps vs Meeting SDK (Common Confusion)
Forum pattern: developers try to use Meeting SDK concepts inside Zoom Apps, or vice versa.
Zoom Apps (Apps SDK)
Use when you want a web app that runs inside the Zoom client:
- in-meeting panel
- main client
- webinar contexts
- Layers API (immersive / camera mode)
- collaborate mode (shared state)
The app runs in Zoom’s embedded browser and interacts with the meeting via @zoom/appssdk capabilities.
Meeting SDK
Use when you want to embed the Zoom meeting experience in your own app (outside the Zoom client):
- your website or desktop app hosts the meeting UI
- you handle signature generation server-side
The Practical Decision
- “I want a sidebar app inside Zoom” -> Zoom Apps SDK
- “I want to embed Zoom meeting UI inside my product” -> Meeting SDK
If the question is “show contents in Zoom App via Meeting SDK”, first clarify which experience they actually want (inside Zoom vs embedded in their product).
Running Contexts
Overview
A Zoom App can run in multiple surfaces within the Zoom client. The runningContext property returned by config() tells you where your app is currently loaded.
const configResponse = await zoomSdk.config({
capabilities: ['getMeetingContext', 'getUserContext', ...],
version: '0.16'
});
console.log(configResponse.runningContext);
// 'inMeeting' | 'inMainClient' | 'inWebinar' | 'inImmersive' | ...All Running Contexts
| Context | Surface | Meeting APIs | User APIs | Layers APIs | Notes |
|---|---|---|---|---|---|
inMeeting | Meeting sidebar | Yes | Yes | Yes | Most common context |
inMainClient | Main client panel | No | Yes | No | Home tab, no meeting running |
inWebinar | Webinar sidebar | Yes | Yes | Yes | Host/panelist initially |
inImmersive | Layers full-screen | Limited | Yes | Yes | After runRenderingContext |
inCamera | Camera mode | Limited | Yes | Camera only | Virtual camera overlay |
inCollaborate | Collaborate mode | Yes | Yes | No | Shared state context |
inPhone | Zoom Phone | No | Yes | No | Phone call app surface |
inChat | Team Chat | No | Yes | No | Chat sidebar |
Context-Specific Behavior
inMeeting (Most Common)
The primary context. Your app appears as a sidebar panel during a meeting.
if (configResponse.runningContext === 'inMeeting') {
const meeting = await zoomSdk.getMeetingContext();
console.log('Meeting ID:', meeting.meetingID);
console.log('Topic:', meeting.meetingTopic);
const user = await zoomSdk.getUserContext();
console.log('Name:', user.screenName);
console.log('Role:', user.role); // 'host' | 'coHost' | 'attendee'
}Available: All meeting APIs, sharing, invitations, breakout rooms, recording.
inMainClient
Your app runs in the main Zoom window (not during a meeting). Used for dashboards, settings, pre-meeting setup.
if (configResponse.runningContext === 'inMainClient') {
// NO meeting APIs available - getMeetingContext() will fail
const user = await zoomSdk.getUserContext();
console.log('Name:', user.screenName);
// Can still use connect() to sync with meeting instance later
}Key limitation: No meeting context, no participants, no sharing.
inWebinar
Similar to inMeeting but for webinars. Initially only available to host and panelists.
if (configResponse.runningContext === 'inWebinar') {
const user = await zoomSdk.getUserContext();
// role: 'host' | 'panelist' | 'attendee'
if (user.role === 'attendee') {
// Limited functionality for attendees
}
}inImmersive / inCamera
Layers API contexts. Your app has taken over the video rendering.
inImmersive: Full-screen custom layout (replaces gallery view)inCamera: Overlay on user's camera feed
See Layers API Reference for details.
Multiple Instances
A Zoom App can have two instances running simultaneously:
1. Main client instance (inMainClient) - Always available 2. Meeting instance (inMeeting) - Created when user opens app in meeting
Instance Communication
Use connect() and postMessage() to sync between instances:
// Both instances call connect()
await zoomSdk.connect();
// Listen for connection
zoomSdk.addEventListener('onConnect', (event) => {
console.log('Connected to other instance');
});
// Send data to other instance
await zoomSdk.postMessage({ type: 'settings', data: mySettings });
// Receive data from other instance
zoomSdk.addEventListener('onMessage', (event) => {
const { type, data } = JSON.parse(event.payload);
if (type === 'settings') {
applySettings(data);
}
});Pattern: Pre-Meeting Setup
Main Client Instance Meeting Instance
───────────────────── ─────────────────
User configures settings --> connect() + listen
Store in state --> onConnect fires
postMessage('getSettings')
onMessage('getSettings') -->
postMessage(settings) --> onMessage(settings)
Apply settings to meetingDetecting Context at Runtime
import zoomSdk from '@zoom/appssdk';
async function init() {
const config = await zoomSdk.config({
capabilities: [
'getMeetingContext', 'getUserContext', 'getRunningContext',
'connect', 'postMessage', 'onConnect', 'onMessage',
'shareApp', 'runRenderingContext'
],
version: '0.16'
});
switch (config.runningContext) {
case 'inMeeting':
case 'inWebinar':
initMeetingUI();
break;
case 'inMainClient':
initDashboardUI();
break;
case 'inImmersive':
case 'inCamera':
initLayersUI();
break;
default:
initFallbackUI();
}
}Checking API Availability
Not all APIs are available in all contexts. Use getSupportedJsApis() to check:
const { supportedApis } = await zoomSdk.getSupportedJsApis();
if (supportedApis.includes('authorize')) {
// In-Client OAuth is available
showAuthButton();
}
if (supportedApis.includes('runRenderingContext')) {
// Layers API is available
showLayersButton();
}Also check configResponse.unsupportedApis after config() for capabilities that were requested but not available in the current client version.
Resources
- Running contexts docs: https://developers.zoom.us/docs/zoom-apps/guides/in-client-experience/
- Instance communication: https://developers.zoom.us/docs/zoom-apps/guides/collaborate-mode/
Security
Overview
Zoom Apps must meet security requirements for Marketplace approval. This covers required headers, CSP configuration, cookie security, and token storage.
Required OWASP Headers
Zoom's security review requires these HTTP headers on all responses:
| Header | Required Value | Purpose |
|---|---|---|
Strict-Transport-Security | max-age=31536000 | Force HTTPS for 1 year |
X-Content-Type-Options | nosniff | Prevent MIME type sniffing |
Content-Security-Policy | frame-ancestors 'self' zoom.us *.zoom.us | Allow Zoom to embed your app |
Referrer-Policy | same-origin | Limit referrer info |
X-Frame-Options | ALLOW-FROM zoom.us | Legacy frame control |
Express Implementation
app.use((req, res, next) => {
res.setHeader('Strict-Transport-Security', 'max-age=31536000');
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Content-Security-Policy',
"frame-ancestors 'self' zoom.us *.zoom.us"
);
res.setHeader('Referrer-Policy', 'same-origin');
next();
});Critical: The frame-ancestors CSP directive is what allows Zoom's embedded browser to load your app. Without it, the browser blocks the frame.
TLS Requirements
- HTTPS required for all endpoints (minimum TLS 1.2)
- HTTP redirects to HTTPS
- Valid SSL certificate (self-signed will fail)
- ngrok provides HTTPS automatically for local development
Cookie Security
Zoom's embedded browser requires specific cookie settings:
// Express cookie-session example
app.use(require('cookie-session')({
name: 'session',
keys: [process.env.SESSION_SECRET],
maxAge: 24 * 60 * 60 * 1000, // 24 hours
sameSite: 'none', // REQUIRED - Zoom embeds your app cross-origin
secure: true // REQUIRED - SameSite=None requires Secure
}));Why `SameSite=None`? Your app runs inside Zoom's embedded browser, which is a different origin. Without SameSite=None, the browser won't send cookies to your server, and sessions break silently.
PKCE OAuth Security
All Zoom Apps OAuth flows should use PKCE (Proof Key for Code Exchange):
const crypto = require('crypto');
// Generate PKCE pair
const verifier = crypto.randomBytes(32).toString('hex');
const challenge = crypto.createHash('sha256')
.update(verifier)
.digest('base64url');
// Store verifier in session (server-side)
req.session.codeVerifier = verifier;
// Send challenge to frontend (or include in OAuth redirect URL)
res.json({ codeChallenge: challenge, state: req.session.state });PKCE prevents authorization code interception attacks. The code_verifier never leaves your server.
Token Storage
Never store tokens in the frontend (localStorage, sessionStorage, cookies).
| Storage | When to Use | Security |
|---|---|---|
| Redis | Multi-instance servers, production | Fast, TTL support, scalable |
| Encrypted session | Single server, simple apps | Tied to server process |
| Firestore/DynamoDB | Serverless (Firebase/Lambda) | Persistent, managed |
| Database (encrypted) | Complex apps with user accounts | Full control, encrypted at rest |
// Redis token storage example
const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);
async function storeTokens(zoomUserId, tokens) {
await redis.set(
`zoom:tokens:${zoomUserId}`,
JSON.stringify(tokens),
'EX', tokens.expires_in // Auto-expire with token
);
}
async function getTokens(zoomUserId) {
const data = await redis.get(`zoom:tokens:${zoomUserId}`);
return data ? JSON.parse(data) : null;
}State Parameter (CSRF Protection)
Always validate the OAuth state parameter:
const crypto = require('crypto');
// Generate state before OAuth redirect
const state = crypto.randomBytes(16).toString('hex');
req.session.oauthState = state;
// Validate state on callback
app.get('/auth', (req, res) => {
if (req.query.state !== req.session.oauthState) {
return res.status(403).send('Invalid state - possible CSRF attack');
}
// Proceed with token exchange
});Data Access Layers
| Layer | What You Access | Authorization | Risk Level |
|---|---|---|---|
| SDK (contextual) | Meeting context, user info, UI controls | config() capabilities | Low - scoped to current context |
| REST API (server-side) | Full Zoom API (users, meetings, recordings) | OAuth access tokens | Medium - broader data access |
| X-Zoom-App-Context | User identity, meeting info | Client secret decryption | Low - read-only identity |
Principle of least privilege: Only request the OAuth scopes and SDK capabilities you actually need.
Marketplace Security Review Checklist
- [ ] All OWASP headers set on every response
- [ ] HTTPS enforced (no HTTP endpoints)
- [ ] PKCE used for OAuth flows
- [ ] State parameter validated on OAuth callbacks
- [ ] Tokens stored server-side (never in frontend)
- [ ] Token refresh implemented (tokens expire in 1 hour)
- [ ]
SameSite=None; Secureon cookies - [ ] CSP allows
frame-ancestors zoom.us *.zoom.us - [ ] No sensitive data logged to console in production
- [ ] Environment variables for all secrets (no hardcoded credentials)
Resources
- Security docs: https://developers.zoom.us/docs/zoom-apps/security/
- OWASP headers: https://developers.zoom.us/docs/zoom-apps/security/owasp/
App Instance Communication
Sync data between main client and meeting instances using connect() + postMessage().
Overview
A Zoom App can run two instances simultaneously:
- Main client instance (
inMainClient) - for settings, dashboards - Meeting instance (
inMeeting) - for in-meeting features
Use connect() and postMessage() to pass data between them.
Basic Setup
import zoomSdk from '@zoom/appssdk';
await zoomSdk.config({
capabilities: ['connect', 'postMessage', 'onConnect', 'onMessage'],
version: '0.16'
});
// Both instances must call connect()
await zoomSdk.connect();
// Know when the other instance connects
zoomSdk.addEventListener('onConnect', (event) => {
console.log('Other instance connected');
});
// Send a message
await zoomSdk.postMessage({
payload: JSON.stringify({ type: 'greeting', data: 'Hello from this instance!' })
});
// Receive messages
zoomSdk.addEventListener('onMessage', (event) => {
const message = JSON.parse(event.payload);
console.log('Received:', message.type, message.data);
});Pattern: Pre-Meeting Settings
Configure settings in the main client, use them during the meeting.
Main Client Instance
// Running in inMainClient context
const config = await zoomSdk.config({
capabilities: ['connect', 'postMessage', 'onConnect', 'onMessage', 'getRunningContext'],
version: '0.16'
});
if (config.runningContext === 'inMainClient') {
await zoomSdk.connect();
// User configures settings
const settings = {
theme: 'dark',
language: 'en',
notifications: true
};
// When meeting instance asks for settings, send them
zoomSdk.addEventListener('onMessage', async (event) => {
const msg = JSON.parse(event.payload);
if (msg.type === 'request-settings') {
await zoomSdk.postMessage({
payload: JSON.stringify({ type: 'settings', data: settings })
});
}
});
}Meeting Instance
// Running in inMeeting context
if (config.runningContext === 'inMeeting') {
await zoomSdk.connect();
let settings = null;
zoomSdk.addEventListener('onMessage', (event) => {
const msg = JSON.parse(event.payload);
if (msg.type === 'settings') {
settings = msg.data;
applySettings(settings);
}
});
// Request settings from main client instance
zoomSdk.addEventListener('onConnect', async () => {
await zoomSdk.postMessage({
payload: JSON.stringify({ type: 'request-settings' })
});
});
}Message Protocol
Define a consistent message format:
// Message types
const MessageType = {
REQUEST_SETTINGS: 'request-settings',
SETTINGS: 'settings',
STATE_UPDATE: 'state-update',
ACTION: 'action'
};
// Send helper
async function send(type, data = {}) {
await zoomSdk.postMessage({
payload: JSON.stringify({ type, data, timestamp: Date.now() })
});
}
// Receive handler
zoomSdk.addEventListener('onMessage', (event) => {
const { type, data } = JSON.parse(event.payload);
switch (type) {
case MessageType.REQUEST_SETTINGS:
send(MessageType.SETTINGS, currentSettings);
break;
case MessageType.STATE_UPDATE:
applyState(data);
break;
case MessageType.ACTION:
handleAction(data);
break;
}
});Important Notes
- Messages are JSON strings (must stringify/parse)
- Both instances must call
connect()independently onConnectfires when the other instance connects- Messages are one-to-one (not broadcast to all participants)
- If one instance isn't running, messages are lost (no queue)
Resources
- In-client experience: https://developers.zoom.us/docs/zoom-apps/guides/in-client-experience/
Breakout Room Integration
Detect breakout rooms, track room changes, and sync state across rooms.
Overview
When a meeting uses breakout rooms, each room gets its own meeting UUID. Your app needs to handle room transitions and optionally sync state between rooms.
Key Concepts
| Property | Description |
|---|---|
meetingUUID | Unique per room (changes when moving to breakout) |
parentUUID | Original meeting UUID (available in breakout rooms) |
onBreakoutRoomChange | Event fired when user moves between rooms |
Detecting Room Changes
import zoomSdk from '@zoom/appssdk';
await zoomSdk.config({
capabilities: [
'getMeetingUUID', 'getMeetingContext',
'onBreakoutRoomChange'
],
version: '0.16'
});
// Get current room info
const { meetingUUID } = await zoomSdk.getMeetingUUID();
console.log('Current room UUID:', meetingUUID);
// Listen for room changes
zoomSdk.addEventListener('onBreakoutRoomChange', async (event) => {
console.log('Room changed:', event);
// Get new room UUID
const { meetingUUID: newUUID } = await zoomSdk.getMeetingUUID();
console.log('Now in room:', newUUID);
// Re-initialize room-specific state
await loadRoomState(newUUID);
});Cross-Room State Sync
Use your backend to sync state across rooms. The parentUUID links all breakout rooms to the main meeting.
// Frontend: Register with backend on room entry
async function registerRoom() {
const { meetingUUID } = await zoomSdk.getMeetingUUID();
const context = await zoomSdk.getMeetingContext();
await fetch('/api/room/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
roomUUID: meetingUUID,
parentUUID: context.parentUUID || meetingUUID,
// parentUUID is null in main room, set in breakout rooms
})
});
}
// Backend: Aggregate state across all rooms
app.get('/api/meeting/:parentUUID/state', async (req, res) => {
const rooms = await db.find({ parentUUID: req.params.parentUUID });
const aggregated = rooms.reduce((acc, room) => {
// Merge state from all rooms
return { ...acc, ...room.state };
}, {});
res.json(aggregated);
});Managing Breakout Rooms via REST API
Create and manage breakout rooms from your backend using the Zoom REST API:
// Create breakout rooms (requires meeting:write scope)
const response = await axios.post(
`https://api.zoom.us/v2/meetings/${meetingId}/batch_registrants`,
{
rooms: [
{ name: 'Room 1', participants: ['user1@example.com'] },
{ name: 'Room 2', participants: ['user2@example.com'] }
]
},
{ headers: { 'Authorization': `Bearer ${accessToken}` } }
);Pattern: Room-Specific vs Global State
const { meetingUUID } = await zoomSdk.getMeetingUUID();
const context = await zoomSdk.getMeetingContext();
const parentUUID = context.parentUUID || meetingUUID;
// Room-specific state (e.g., room discussion notes)
const roomState = await loadState(`room:${meetingUUID}`);
// Global state (e.g., meeting agenda visible in all rooms)
const globalState = await loadState(`meeting:${parentUUID}`);Resources
- Breakout rooms docs: https://developers.zoom.us/docs/zoom-apps/guides/breakout-rooms/
Collaborate Mode
Real-time shared app state across all meeting participants.
Overview
Collaborate mode lets participants share an app experience simultaneously - like Google Docs for Zoom Apps. When one user makes a change, all participants see it in real time.
Starting Collaborate Mode
import zoomSdk from '@zoom/appssdk';
await zoomSdk.config({
capabilities: [
'startCollaborate', 'onCollaborateChange',
'connect', 'postMessage', 'onConnect', 'onMessage',
'getMeetingUUID'
],
version: '0.16'
});
// Host starts collaborate mode
await zoomSdk.startCollaborate({
shareScreen: true // Also share the app screen
});
// Listen for collaborate state changes
zoomSdk.addEventListener('onCollaborateChange', (event) => {
console.log('Collaborate state changed:', event);
});State Synchronization Patterns
Pattern 1: Server Relay (Socket.io)
Best for apps with a backend. Server is the source of truth.
// Frontend
const socket = io('https://your-server.com');
const meetingUUID = await zoomSdk.getMeetingUUID();
socket.emit('join-room', { roomId: meetingUUID.meetingUUID });
// Send state changes
function updateState(key, value) {
socket.emit('state-update', { key, value });
}
// Receive state changes
socket.on('state-update', ({ key, value }) => {
applyStateChange(key, value);
});Pattern 2: SDK Message Passing
No server needed. Direct peer messaging via connect() + postMessage().
// All participants connect
await zoomSdk.connect();
zoomSdk.addEventListener('onConnect', () => {
console.log('Connected to other instances');
});
// Send state to all connected instances
async function broadcastState(state) {
await zoomSdk.postMessage({
payload: JSON.stringify({ type: 'state-sync', data: state })
});
}
// Receive state from other instances
zoomSdk.addEventListener('onMessage', (event) => {
const message = JSON.parse(event.payload);
if (message.type === 'state-sync') {
applyState(message.data);
}
});Pattern 3: CRDT (Y.js)
Best for collaborative editing (text, whiteboards). Conflict-free resolution.
import * as Y from 'yjs';
import { WebrtcProvider } from 'y-webrtc';
// Use meeting UUID as room name
const meetingUUID = await zoomSdk.getMeetingUUID();
const ydoc = new Y.Doc();
const provider = new WebrtcProvider(meetingUUID.meetingUUID, ydoc, {
signaling: ['wss://your-signaling-server.com']
});
// Shared state automatically syncs via CRDT
const sharedMap = ydoc.getMap('app-state');
// Update state (syncs to all peers automatically)
sharedMap.set('counter', (sharedMap.get('counter') || 0) + 1);
// Observe changes
sharedMap.observe((event) => {
event.keysChanged.forEach((key) => {
console.log(`${key} changed to:`, sharedMap.get(key));
});
});Note: The texteditor sample app uses public Y.js signaling servers. For production, host your own signaling server.
Example: Shared Counter
import zoomSdk from '@zoom/appssdk';
let count = 0;
async function init() {
await zoomSdk.config({
capabilities: ['connect', 'postMessage', 'onConnect', 'onMessage'],
version: '0.16'
});
await zoomSdk.connect();
zoomSdk.addEventListener('onMessage', (event) => {
const msg = JSON.parse(event.payload);
if (msg.type === 'count-update') {
count = msg.count;
render();
}
});
render();
}
async function increment() {
count++;
render();
await zoomSdk.postMessage({
payload: JSON.stringify({ type: 'count-update', count })
});
}
function render() {
document.getElementById('counter').textContent = count;
}Meeting UUID as Room Identifier
Use getMeetingUUID() to get a unique room ID for state synchronization:
const { meetingUUID } = await zoomSdk.getMeetingUUID();
// Use as Socket.io room, Y.js document name, Redis key, etc.This UUID is unique per meeting instance and consistent across all participants.
Resources
- Collaborate docs: https://developers.zoom.us/docs/zoom-apps/guides/collaborate-mode/
- Sample app: https://github.com/zoom/zoomapps-texteditor-vuejs
Guest Mode
Handle unauthenticated meeting participants who haven't authorized your app.
Overview
When a meeting has external guests (non-Zoom users or users who haven't installed your app), they enter your app in an unauthenticated state. Guest mode lets you progressively request authorization.
Three Authorization States
Unauthenticated ──> Authenticated ──> Authorized
│ │ │
│ │ │
No Zoom identity Has Zoom identity Has OAuth tokens
External guest Zoom user, no app Full app access
Limited UI Can promptAuthorize All APIs available| State | Who | getUserContext Returns | Can Use APIs |
|---|---|---|---|
| Unauthenticated | External guests, no Zoom account | Minimal (no user ID) | Very limited |
| Authenticated | Zoom users who haven't authorized your app | Name, role (no personal data) | Read-only context |
| Authorized | Users who authorized your app | Full user data | All APIs |
Detecting State
import zoomSdk from '@zoom/appssdk';
await zoomSdk.config({
capabilities: ['getUserContext', 'authorize', 'promptAuthorize', 'onAuthorized'],
version: '0.16'
});
const user = await zoomSdk.getUserContext();
if (user.status === 'authorized') {
showFullApp();
} else if (user.status === 'authenticated') {
showLimitedApp();
showAuthorizeButton();
} else {
showGuestView();
showAuthorizeButton();
}Prompting Authorization
// Show a button that triggers authorization
document.getElementById('authorize-btn').addEventListener('click', async () => {
try {
await zoomSdk.promptAuthorize();
} catch (e) {
console.error('Authorization prompt failed:', e);
}
});
// Listen for successful authorization
zoomSdk.addEventListener('onAuthorized', async (event) => {
const { code, state } = event;
// Exchange code for tokens on your backend
await fetch('/api/auth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, state })
});
// Upgrade UI to full access
showFullApp();
});UI Pattern
function showGuestView() {
document.getElementById('app').innerHTML = `
<h2>Welcome, Guest!</h2>
<p>You're viewing this app as a guest. Authorize to unlock all features.</p>
<div id="limited-content">
<!-- Show read-only or minimal content -->
</div>
<button id="authorize-btn">Authorize App</button>
`;
}
function showLimitedApp() {
document.getElementById('app').innerHTML = `
<h2>Welcome!</h2>
<p>Authorize to unlock all features.</p>
<div id="limited-content">
<!-- Show more content than guest, but still limited -->
</div>
<button id="authorize-btn">Authorize for Full Access</button>
`;
}
function showFullApp() {
document.getElementById('app').innerHTML = `
<h2>Full App Access</h2>
<div id="full-content">
<!-- All features available -->
</div>
`;
}Host-Required Authorization
Meeting hosts can require all participants to authorize before using the app. This is configured in the Marketplace app settings under the "Guest Mode" feature.
When enabled, unauthenticated/authenticated users see the authorization prompt immediately.
Resources
- Guest mode docs: https://developers.zoom.us/docs/zoom-apps/guides/guest-mode/
- Sample app: https://github.com/zoom/zoomapps-advancedsample-react (includes guest mode)
In-Client OAuth with PKCE
Authorize users without leaving the Zoom client. Best UX for returning users.
Why In-Client OAuth?
| Approach | UX | When to Use |
|---|---|---|
| Web redirect | Opens browser, redirects back | Initial install from Marketplace |
| In-Client | Popup inside Zoom, no redirect | Subsequent authorizations, best UX |
Flow
Frontend Backend Zoom
──────── ──────── ────
GET /api/auth/challenge -->
<-- { codeChallenge, state }
(stores code_verifier in session)
zoomSdk.authorize({ --> --> Shows "Authorize" popup
codeChallenge, state to user
})
onAuthorized fires <-- <-- User clicks "Allow"
{ code, state }
POST /api/auth/token -->
{ code, state } Validates state
Exchanges code + verifier
for access_token
<-- { success: true }Frontend Implementation
import zoomSdk from '@zoom/appssdk';
async function init() {
await zoomSdk.config({
capabilities: ['authorize', 'onAuthorized', 'getUserContext'],
version: '0.16'
});
// Check if already authorized
try {
const response = await fetch('/api/auth/status');
const { authorized } = await response.json();
if (authorized) {
showApp();
return;
}
} catch (e) {
// Not authorized yet
}
// Set up authorization listener BEFORE calling authorize
zoomSdk.addEventListener('onAuthorized', async (event) => {
const { code, state } = event;
const response = await fetch('/api/auth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, state })
});
if (response.ok) {
showApp();
} else {
showError('Authorization failed');
}
});
// Get challenge and start authorization
const challengeResponse = await fetch('/api/auth/challenge');
const { codeChallenge, state } = await challengeResponse.json();
await zoomSdk.authorize({ codeChallenge, state });
}Backend Implementation
const crypto = require('crypto');
const express = require('express');
const axios = require('axios');
const router = express.Router();
// Generate PKCE challenge
router.get('/api/auth/challenge', (req, res) => {
const verifier = crypto.randomBytes(32).toString('hex');
const challenge = crypto.createHash('sha256')
.update(verifier)
.digest('base64url');
const state = crypto.randomBytes(16).toString('hex');
// Store in session (server-side only)
req.session.codeVerifier = verifier;
req.session.state = state;
res.json({ codeChallenge: challenge, state });
});
// Exchange authorization code for tokens
router.post('/api/auth/token', async (req, res) => {
const { code, state } = req.body;
// Validate state (CSRF protection)
if (state !== req.session.state) {
return res.status(403).json({ error: 'Invalid state' });
}
try {
const tokenResponse = await axios.post('https://zoom.us/oauth/token', null, {
params: {
grant_type: 'authorization_code',
code,
redirect_uri: process.env.ZOOM_APP_REDIRECT_URI,
code_verifier: req.session.codeVerifier
},
headers: {
'Authorization': 'Basic ' + Buffer.from(
`${process.env.ZOOM_APP_CLIENT_ID}:${process.env.ZOOM_APP_CLIENT_SECRET}`
).toString('base64')
}
});
// Store tokens securely (session, Redis, or database)
req.session.tokens = {
access_token: tokenResponse.data.access_token,
refresh_token: tokenResponse.data.refresh_token,
expires_at: Date.now() + (tokenResponse.data.expires_in * 1000)
};
// Clean up PKCE data
delete req.session.codeVerifier;
delete req.session.state;
res.json({ success: true });
} catch (error) {
console.error('Token exchange failed:', error.response?.data || error.message);
res.status(500).json({ error: 'Token exchange failed' });
}
});
// Check authorization status
router.get('/api/auth/status', (req, res) => {
const tokens = req.session.tokens;
const authorized = tokens && tokens.expires_at > Date.now();
res.json({ authorized });
});
// Token refresh helper
async function refreshTokens(req) {
const { refresh_token } = req.session.tokens;
const response = await axios.post('https://zoom.us/oauth/token', null, {
params: {
grant_type: 'refresh_token',
refresh_token
},
headers: {
'Authorization': 'Basic ' + Buffer.from(
`${process.env.ZOOM_APP_CLIENT_ID}:${process.env.ZOOM_APP_CLIENT_SECRET}`
).toString('base64')
}
});
req.session.tokens = {
access_token: response.data.access_token,
refresh_token: response.data.refresh_token,
expires_at: Date.now() + (response.data.expires_in * 1000)
};
return req.session.tokens;
}
module.exports = router;Re-Authorization with promptAuthorize
For guest mode users who need to upgrade their authorization:
// Use when user needs to grant additional permissions
await zoomSdk.promptAuthorize();
// Same onAuthorized listener fires
zoomSdk.addEventListener('onAuthorized', async (event) => {
// Handle same as initial authorization
});Token Refresh Pattern
// Middleware to auto-refresh expired tokens
async function ensureAuthorized(req, res, next) {
if (!req.session.tokens) {
return res.status(401).json({ error: 'Not authorized' });
}
// Refresh if expiring within 5 minutes
if (req.session.tokens.expires_at < Date.now() + 300000) {
try {
await refreshTokens(req);
} catch (error) {
return res.status(401).json({ error: 'Token refresh failed' });
}
}
next();
}Resources
- Auth guide: https://developers.zoom.us/docs/zoom-apps/authentication/
- OAuth reference: ../references/oauth.md
Layers API - Camera Mode
Overlay graphics on the user's own camera feed. Create virtual camera effects, branded frames, and interactive overlays.
Overview
Camera mode overlays your content on the user's camera video. Unlike immersive mode (which controls the entire meeting view), camera mode only affects the individual user's camera feed - other participants see the overlay on that user's video.
Quick Start
import zoomSdk from '@zoom/appssdk';
const config = await zoomSdk.config({
capabilities: [
'getRunningContext',
'runRenderingContext', 'closeRenderingContext',
'drawParticipant', 'clearParticipant',
'drawImage', 'clearImage',
'drawWebView', 'clearWebView',
'postMessage', 'onMessage',
'onRenderedAppOpened'
],
version: '0.16'
});
// renderTarget = virtual camera frame size (default: 1280x720)
const rtWidth = config.media?.renderTarget?.width || 1280;
const rtHeight = config.media?.renderTarget?.height || 720;
// Start camera mode
await zoomSdk.runRenderingContext({ view: 'camera' });
// Wait for CEF to initialize
zoomSdk.addEventListener('onRenderedAppOpened', async () => {
// Draw self video as background
await zoomSdk.drawParticipant({
participantUUID: myUUID,
x: 0, y: 0,
width: rtWidth, height: rtHeight,
zIndex: 1,
cameraModeMirroring: true // v5.13.5+ — mirror for self-view
});
// Add a branded frame overlay
const frame = await createBrandedFrame(rtWidth, rtHeight);
const imageData = frame.getContext('2d').getImageData(0, 0, rtWidth, rtHeight);
await zoomSdk.drawImage({
imageData,
x: 0, y: 0,
zIndex: 2
});
// Or draw webview overlay (your app's home URL rendered off-screen)
await zoomSdk.drawWebView({
x: 0, y: 0,
width: rtWidth, height: rtHeight,
zIndex: 3
});
});CEF Race Condition (Critical)
Camera mode uses CEF (Chromium Embedded Framework) which takes time to initialize. Drawing too early will fail silently.
Solution: Retry with backoff
async function drawWithRetry(drawFn, maxRetries = 5) {
for (let i = 0; i < maxRetries; i++) {
try {
await drawFn();
return; // Success
} catch (error) {
if (i === maxRetries - 1) throw error;
// Exponential backoff: 200ms, 400ms, 800ms, 1600ms, 3200ms
await new Promise(r => setTimeout(r, 200 * Math.pow(2, i)));
}
}
}
// Usage
await zoomSdk.runRenderingContext({ view: 'camera' });
await drawWithRetry(async () => {
await zoomSdk.drawImage({
imageData: frame.toDataURL(),
x: 0, y: 0, width: 1280, height: 720, zIndex: 1
});
});Example: Branded Camera Frame
async function createBrandedFrame() {
const canvas = document.createElement('canvas');
canvas.width = 1280;
canvas.height = 720;
const ctx = canvas.getContext('2d');
// Transparent center (camera shows through)
ctx.clearRect(0, 0, 1280, 720);
// Bottom bar with company branding
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.fillRect(0, 660, 1280, 60);
// Company name
ctx.fillStyle = 'white';
ctx.font = 'bold 20px sans-serif';
ctx.fillText('Acme Corp', 20, 695);
// Border frame
ctx.strokeStyle = '#2d8cff';
ctx.lineWidth = 4;
ctx.strokeRect(2, 2, 1276, 716);
return canvas;
}Example: Name Tag Overlay
async function drawNameTag(name, title) {
const canvas = document.createElement('canvas');
canvas.width = 300;
canvas.height = 80;
const ctx = canvas.getContext('2d');
// Background
ctx.fillStyle = 'rgba(45, 140, 255, 0.85)';
ctx.beginPath();
ctx.roundRect(0, 0, 300, 80, 12);
ctx.fill();
// Name
ctx.fillStyle = 'white';
ctx.font = 'bold 22px sans-serif';
ctx.fillText(name, 16, 32);
// Title
ctx.font = '16px sans-serif';
ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
ctx.fillText(title, 16, 58);
await zoomSdk.drawImage({
imageData: canvas.toDataURL(),
x: 20, y: 620,
width: 300, height: 80,
zIndex: 2
});
}Exiting Camera Mode
await zoomSdk.closeRenderingContext();drawWebView in Camera Mode
The webview renders your app's home URL off-screen. Use it for interactive overlays controlled from your sidebar app:
// Draw webview filling entire camera frame
await zoomSdk.drawWebView({
x: 0, y: 0,
width: rtWidth, height: rtHeight,
zIndex: 2
});
// Or partial overlay (bottom third)
await zoomSdk.drawWebView({
x: 0, y: rtHeight * 0.67,
width: rtWidth, height: rtHeight * 0.33,
zIndex: 2
});
// Hide webview (app keeps running)
await zoomSdk.clearWebView();Communication between sidebar ↔ camera mode app:
// Sidebar sends command to camera mode instance
zoomSdk.postMessage({ command: 'show-nametag', name: 'John' });
// Camera mode instance listens (no connect() required)
zoomSdk.addEventListener('onMessage', (event) => {
if (event.command === 'show-nametag') {
document.getElementById('name').textContent = event.name;
}
});Differences from Immersive Mode
| Aspect | Immersive | Camera |
|---|---|---|
| Scope | Entire meeting view | User's camera only |
| drawParticipant | Any participant | Self only |
| drawWebView | Yes | Yes |
| Who sees it | All participants | All see it on this user's feed |
| Use case | Custom layouts | Personal overlays, branding |
| Browser | Standard WebView | CEF (has init delay) |
| Coordinate space | CSS pixels | Raw pixels (renderTarget) |
cameraModeMirroring | N/A | Yes (v5.13.5+) |
Resources
- Camera mode docs: https://developers.zoom.us/docs/zoom-apps/guides/camera-mode/
- Layers API reference: ../references/layers-api.md
- Sample app: https://github.com/zoom/zoomapps-customlayout-js
Layers API - Immersive Mode
Custom video layouts that replace the standard gallery view. Position participant video feeds, backgrounds, and web content anywhere on screen.
Overview
Immersive mode takes over the entire meeting video area. You control where each participant's video appears, add background images, and overlay web content.
Use cases: Podcast layout, talk show, classroom, game show, branded meetings.
Quick Start
import zoomSdk from '@zoom/appssdk';
// 1. Config with Layers capabilities
await zoomSdk.config({
capabilities: [
'getRunningContext',
'runRenderingContext', 'closeRenderingContext',
'drawParticipant', 'clearParticipant',
'drawImage', 'clearImage',
'drawWebView', 'clearWebView',
'getMeetingParticipants', 'onParticipantChange',
'postMessage', 'onMessage',
'sendAppInvitationToAllParticipants',
'onRenderedAppOpened'
],
version: '0.16'
});
// 2. Start immersive mode (Team = person cutout, Presentation = rectangle)
await zoomSdk.runRenderingContext({
view: 'immersive',
defaultCutout: 'person' // Removes backgrounds via AI segmentation
});
// 3. Draw a background (imageData = JS ImageData object, NOT base64)
const canvas = document.createElement('canvas');
canvas.width = 1280;
canvas.height = 720;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#1a1a2e';
ctx.fillRect(0, 0, 1280, 720);
const imageData = ctx.getImageData(0, 0, 1280, 720);
await zoomSdk.drawImage({
imageData,
x: 0, y: 0,
zIndex: 0
});
// 4. Position participants
const { participants } = await zoomSdk.getMeetingParticipants();
await zoomSdk.drawParticipant({
participantUUID: participants[0].participantUUID,
x: 50, y: 100,
width: 500, height: 400,
zIndex: 1,
cutout: 'person' // Override default if needed
});
await zoomSdk.drawParticipant({
participantUUID: participants[1].participantUUID,
x: 730, y: 100,
width: 500, height: 400,
zIndex: 1,
cutout: 'person'
});Drawing Methods
drawParticipant
Position a participant's video feed. In immersive mode, you can draw any participant.
await zoomSdk.drawParticipant({
participantUUID: 'uuid-string', // From getMeetingParticipants()
x: 0, // PixelValue: "Npx", "N%", or number
y: 0, // PixelValue
width: 640, // PixelValue (aspect ratio maintained)
height: 480, // PixelValue (aspect ratio maintained)
zIndex: 1, // Stacking order (higher = on top)
cutout: 'person' // Optional: "person"|"standard"|"rectangle"|"circle"|"square"|"verticalRectangle"
});Cutout shapes (all have 30px rounded corners except "standard"):
"person"— AI background removal (v5.9.3+)"standard"— Full uncropped video, squared corners (v5.11.3+)"rectangle"— Rounded rectangle (v5.11.0+)"circle"— Circle (v5.11.3+)"square"— Square with rounded corners (v5.11.3+)"verticalRectangle"— Vertical rectangle with rounded corners (v5.11.3+)
Deprecated:participantId— useparticipantUUIDinstead.
drawImage
Add images (backgrounds, overlays, borders). Uses standard JavaScript ImageData (NOT base64):
const canvas = document.createElement('canvas');
canvas.width = 1280;
canvas.height = 720;
const ctx = canvas.getContext('2d');
// ... draw on canvas ...
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const { imageId } = await zoomSdk.drawImage({
imageData, // ImageData object from canvas.getImageData()
x: 0, y: 0,
zIndex: 0 // Behind participants
});
// Save imageId for clearImage() laterdrawWebView
Embed your app's webview as an interactive overlay. Only one webview per rendering context.
await zoomSdk.drawWebView({
x: 400, y: 600,
width: 480, height: 100,
zIndex: 2 // On top of everything
});See ../references/layers-api.md for full drawWebView details, webview communication, and the webviewId documentation inconsistency.Clearing
await zoomSdk.clearParticipant({ participantUUID: 'uuid' });
await zoomSdk.clearImage({ imageId: 'id-from-drawImage-response' });
await zoomSdk.clearWebView(); // No params per TypeDoc v0.16.36Exit Immersive Mode
await zoomSdk.closeRenderingContext();Complete Example: Podcast Layout
Two hosts side-by-side with custom background:
import zoomSdk from '@zoom/appssdk';
class PodcastLayout {
constructor() {
this.active = false;
}
async start() {
await zoomSdk.runRenderingContext({ view: 'immersive', defaultCutout: 'person' });
this.active = true;
// Draw background
await this.drawBackground();
// Position hosts
const { participants } = await zoomSdk.getMeetingParticipants();
await this.layoutParticipants(participants);
// React to participant changes
zoomSdk.addEventListener('onParticipantChange', async () => {
const { participants } = await zoomSdk.getMeetingParticipants();
await this.layoutParticipants(participants);
});
}
async drawBackground() {
const canvas = document.createElement('canvas');
canvas.width = 1280;
canvas.height = 720;
const ctx = canvas.getContext('2d');
// Gradient background
const gradient = ctx.createLinearGradient(0, 0, 1280, 720);
gradient.addColorStop(0, '#1a1a2e');
gradient.addColorStop(1, '#16213e');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, 1280, 720);
// Title
ctx.fillStyle = 'white';
ctx.font = 'bold 32px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('The Zoom Podcast', 640, 60);
const imageData = ctx.getImageData(0, 0, 1280, 720);
await zoomSdk.drawImage({
imageData,
x: 0, y: 0, zIndex: 0
});
}
async layoutParticipants(participants) {
if (participants.length === 1) {
// Single host - centered
await zoomSdk.drawParticipant({
participantUUID: participants[0].participantUUID,
x: 340, y: 100, width: 600, height: 500, zIndex: 1
});
} else if (participants.length >= 2) {
// Two hosts - side by side
await zoomSdk.drawParticipant({
participantUUID: participants[0].participantUUID,
x: 40, y: 100, width: 580, height: 500, zIndex: 1
});
await zoomSdk.drawParticipant({
participantUUID: participants[1].participantUUID,
x: 660, y: 100, width: 580, height: 500, zIndex: 1
});
}
}
async stop() {
await zoomSdk.closeRenderingContext();
this.active = false;
}
}HiDPI Support
For Retina/HiDPI displays, multiply coordinates by window.devicePixelRatio:
const dpr = window.devicePixelRatio || 1;
await zoomSdk.drawParticipant({
participantUUID: uuid,
x: 100 * dpr,
y: 100 * dpr,
width: 640 * dpr,
height: 480 * dpr,
zIndex: 1
});Multi-Participant Sync
The host controls the layout. Use Socket.io to broadcast layout changes:
// Host sends layout to all participants via your backend
socket.emit('layout-change', {
participants: [
{ uuid: 'a', x: 40, y: 100, w: 580, h: 500 },
{ uuid: 'b', x: 660, y: 100, w: 580, h: 500 }
]
});
// All participants apply the layout
socket.on('layout-change', async (layout) => {
for (const p of layout.participants) {
await zoomSdk.drawParticipant({
participantUUID: p.uuid,
x: p.x, y: p.y, width: p.w, height: p.h, zIndex: 1
});
}
});Performance Tips
- Use
requestAnimationFramefor animations - Minimize
drawImagecalls (batch updates) - Pre-render complex backgrounds to canvas
- Keep zIndex values low (0-10 range)
- Clear unused elements to free resources
Resources
- Layers docs: https://developers.zoom.us/docs/zoom-apps/guides/layers-api/
- Layers API reference: ../references/layers-api.md
- Sample app: https://github.com/zoom/zoomapps-customlayout-js
Quick Start - Hello World Zoom App
Complete working Zoom App with Express backend and SDK frontend.
Prerequisites
- Node.js 18+
- ngrok account (free tier works)
- Zoom Marketplace app (type: "Zoom App")
Project Setup
package.json
{
"name": "zoom-app-hello-world",
"version": "1.0.0",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
},
"dependencies": {
"express": "^4.18.0",
"cookie-session": "^2.0.0",
"axios": "^1.6.0",
"dotenv": "^16.0.0"
}
}.env
ZOOM_APP_CLIENT_ID=your_client_id
ZOOM_APP_CLIENT_SECRET=your_client_secret
ZOOM_APP_REDIRECT_URI=https://xxxxx.ngrok.io/auth
SESSION_SECRET=generate_a_random_string_hereserver.js
require('dotenv').config();
const express = require('express');
const crypto = require('crypto');
const cookieSession = require('cookie-session');
const axios = require('axios');
const app = express();
app.use(express.json());
app.use(express.static('public'));
// Cookie session - SameSite=None required for Zoom embedded browser
app.use(cookieSession({
name: 'session',
keys: [process.env.SESSION_SECRET],
maxAge: 24 * 60 * 60 * 1000,
sameSite: 'none',
secure: true
}));
// OWASP security headers (required for Marketplace approval)
app.use((req, res, next) => {
res.setHeader('Strict-Transport-Security', 'max-age=31536000');
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Content-Security-Policy', "frame-ancestors 'self' zoom.us *.zoom.us");
res.setHeader('Referrer-Policy', 'same-origin');
next();
});
// Step 1: Install - redirect to Zoom OAuth
app.get('/install', (req, res) => {
const verifier = crypto.randomBytes(32).toString('hex');
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
const state = crypto.randomBytes(16).toString('hex');
req.session.codeVerifier = verifier;
req.session.state = state;
const url = `https://zoom.us/oauth/authorize?` +
`client_id=${process.env.ZOOM_APP_CLIENT_ID}` +
`&response_type=code` +
`&redirect_uri=${encodeURIComponent(process.env.ZOOM_APP_REDIRECT_URI)}` +
`&code_challenge=${challenge}` +
`&code_challenge_method=S256` +
`&state=${state}`;
res.redirect(url);
});
// Step 2: OAuth callback - exchange code for tokens
app.get('/auth', async (req, res) => {
const { code, state } = req.query;
if (state !== req.session.state) {
return res.status(403).send('Invalid state');
}
try {
// Exchange authorization code for tokens
const tokenResponse = await axios.post('https://zoom.us/oauth/token', null, {
params: {
grant_type: 'authorization_code',
code,
redirect_uri: process.env.ZOOM_APP_REDIRECT_URI,
code_verifier: req.session.codeVerifier
},
headers: {
'Authorization': 'Basic ' + Buffer.from(
`${process.env.ZOOM_APP_CLIENT_ID}:${process.env.ZOOM_APP_CLIENT_SECRET}`
).toString('base64')
}
});
req.session.tokens = tokenResponse.data;
// Get deeplink to open app in Zoom client
const deeplink = await axios.post('https://api.zoom.us/v2/zoomapp/deeplink',
{ action: '' },
{ headers: { 'Authorization': `Bearer ${tokenResponse.data.access_token}` } }
);
res.redirect(deeplink.data.deeplink);
} catch (error) {
console.error('OAuth error:', error.response?.data || error.message);
res.status(500).send('Authorization failed');
}
});
// API endpoint for frontend
app.get('/api/user', (req, res) => {
if (!req.session.tokens) {
return res.status(401).json({ error: 'Not authenticated' });
}
res.json({ authenticated: true });
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));public/index.html
<!DOCTYPE html>
<html>
<head>
<title>My Zoom App</title>
<script src="https://appssdk.zoom.us/sdk.js"></script>
<style>
body { font-family: -apple-system, sans-serif; padding: 20px; }
.info { background: #f0f4f8; padding: 16px; border-radius: 8px; margin: 12px 0; }
.error { background: #fff0f0; color: #c00; padding: 16px; border-radius: 8px; }
button { padding: 10px 20px; background: #2d8cff; color: white; border: none; border-radius: 4px; cursor: pointer; }
</style>
</head>
<body>
<h1>Hello Zoom App!</h1>
<div id="content">Loading...</div>
<script>
// CRITICAL: Do NOT use "let zoomSdk" - causes SyntaxError
let sdk = window.zoomSdk;
async function init() {
try {
const configResponse = await sdk.config({
capabilities: [
'shareApp',
'getMeetingContext',
'getUserContext',
'openUrl'
],
version: '0.16'
});
const context = configResponse.runningContext;
let html = `<div class="info"><strong>Running in:</strong> ${context}</div>`;
if (context === 'inMeeting' || context === 'inWebinar') {
const meeting = await sdk.getMeetingContext();
const user = await sdk.getUserContext();
html += `<div class="info"><strong>Meeting:</strong> ${meeting.meetingTopic || meeting.meetingID}</div>`;
html += `<div class="info"><strong>User:</strong> ${user.screenName} (${user.role})</div>`;
html += `<button onclick="shareApp()">Share App</button>`;
} else {
html += `<div class="info">Open this app during a meeting for full features.</div>`;
}
document.getElementById('content').innerHTML = html;
} catch (error) {
// Not running inside Zoom - show preview mode
document.getElementById('content').innerHTML =
'<div class="error">Not running inside Zoom client. ' +
'<a href="/install">Install App</a> to get started.</div>';
}
}
async function shareApp() {
try {
await sdk.shareApp();
} catch (e) {
console.error('Share failed:', e);
}
}
// Initialize with timeout fallback
document.addEventListener('DOMContentLoaded', () => {
init();
setTimeout(() => {
if (document.getElementById('content').textContent === 'Loading...') {
document.getElementById('content').innerHTML =
'<div class="error">SDK timed out. <a href="/install">Install App</a></div>';
}
}, 3000);
});
</script>
</body>
</html>Running Locally
# 1. Install dependencies
npm install
# 2. Start ngrok tunnel
ngrok http 3000
# 3. Copy the https URL (e.g., https://abc123.ngrok.io)
# 4. Update .env with ngrok URL
# ZOOM_APP_REDIRECT_URI=https://abc123.ngrok.io/auth
# 5. Start server
npm run devMarketplace Configuration
In Zoom Marketplace -> Your App:
1. App Credentials: Copy Client ID and Secret to .env 2. Feature tab -> Zoom App:
- Home URL:
https://abc123.ngrok.io - Redirect URL:
https://abc123.ngrok.io/auth - Domain Allow List:
abc123.ngrok.io
3. Scopes tab: Add zoomapp:inmeeting 4. Local Test: Click your app name in Zoom client sidebar to open
Next Steps
- Add In-Client OAuth for seamless re-authorization
- Add Layers API for immersive experiences
- Review Security headers for Marketplace approval
Zoom Apps SDK - API Reference
Complete reference for all @zoom/appssdk methods organized by category.
Initialization
config(options)
Must be called first. Initializes the SDK and declares capabilities.
const configResponse = await zoomSdk.config({
capabilities: ['getMeetingContext', 'shareApp', ...],
version: '0.16'
});
// Returns: { runningContext, clientVersion, unsupportedApis }getSupportedJsApis()
Check which APIs are available in the current client.
const { supportedApis } = await zoomSdk.getSupportedJsApis();
// Returns: { supportedApis: ['getMeetingContext', 'shareApp', ...] }Context APIs
getMeetingContext()
const context = await zoomSdk.getMeetingContext();
// { meetingID, meetingTopic, meetingStatus }getUserContext()
const user = await zoomSdk.getUserContext();
// { screenName, role, participantId, status }
// role: 'host' | 'coHost' | 'attendee' | 'panelist'
// status: 'authorized' | 'authenticated' | 'unauthenticated'getRunningContext()
const { context } = await zoomSdk.getRunningContext();
// 'inMeeting' | 'inMainClient' | 'inWebinar' | 'inImmersive' | ...getMeetingUUID()
const { meetingUUID } = await zoomSdk.getMeetingUUID();getMeetingJoinUrl()
const { joinUrl } = await zoomSdk.getMeetingJoinUrl();getMeetingParticipants()
const { participants } = await zoomSdk.getMeetingParticipants();
// [{ participantId, participantUUID, screenName, role }]getRecordingContext()
const recording = await zoomSdk.getRecordingContext();
// { cloudRecordingStatus, localRecordingStatus }Authorization APIs
authorize(options)
Trigger In-Client OAuth (no browser redirect).
await zoomSdk.authorize({ codeChallenge: '...', state: '...' });
// onAuthorized event fires with { code, state }promptAuthorize()
Prompt guest/authenticated users to authorize.
await zoomSdk.promptAuthorize();Meeting Action APIs
shareApp()
await zoomSdk.shareApp();sendAppInvitation(options)
await zoomSdk.sendAppInvitation({ participantUUIDs: ['uuid1'], action: 'open' });sendAppInvitationToAllParticipants()
await zoomSdk.sendAppInvitationToAllParticipants();sendAppInvitationToMeetingOwner()
await zoomSdk.sendAppInvitationToMeetingOwner();showAppInvitationDialog()
await zoomSdk.showAppInvitationDialog();UI APIs
expandApp(options)
await zoomSdk.expandApp({ action: 'expand' }); // or 'collapse'openUrl(options)
await zoomSdk.openUrl({ url: 'https://example.com' });showNotification(options)
await zoomSdk.showNotification({ title: 'Alert', message: 'Something happened!', type: 'info' });Media APIs
listCameras()
const { cameras } = await zoomSdk.listCameras();setVirtualBackground(options)
await zoomSdk.setVirtualBackground({ imageData: 'base64-data' });removeVirtualBackground()
await zoomSdk.removeVirtualBackground();setVideoMirrorEffect(options)
await zoomSdk.setVideoMirrorEffect({ mirrorMyVideo: true });allowParticipantToRecord(options)
await zoomSdk.allowParticipantToRecord({ participantUUID: 'uuid', action: 'grant' });cloudRecording(options)
await zoomSdk.cloudRecording({ action: 'start' }); // 'stop', 'pause', 'resume'Communication APIs
connect()
Connect to other app instances (main client <-> meeting).
await zoomSdk.connect();postMessage(options)
await zoomSdk.postMessage({ payload: JSON.stringify({ type: 'update', data: {...} }) });Layers APIs
runRenderingContext(options)
await zoomSdk.runRenderingContext({ view: 'immersive' }); // or 'camera'closeRenderingContext()
await zoomSdk.closeRenderingContext();drawParticipant(options)
await zoomSdk.drawParticipant({ participantUUID: 'uuid', x: 0, y: 0, width: 640, height: 480, zIndex: 1 });clearParticipant(options)
await zoomSdk.clearParticipant({ participantUUID: 'uuid' });drawImage(options)
await zoomSdk.drawImage({ imageData: canvas.toDataURL(), x: 0, y: 0, width: 1280, height: 720, zIndex: 0 });clearImage(options)
await zoomSdk.clearImage({ imageId: 'id' });drawWebView(options)
await zoomSdk.drawWebView({ webviewId: 'my-view', x: 0, y: 0, width: 400, height: 300, zIndex: 2 });clearWebView(options)
await zoomSdk.clearWebView({ webviewId: 'my-view' });Collaborate APIs
startCollaborate(options)
await zoomSdk.startCollaborate({ shareScreen: true });Event Listeners
zoomSdk.addEventListener('onMeeting', (event) => { ... });
zoomSdk.removeEventListener('onMeeting', handler);See [events.md](events.md) for complete event reference.
Resources
- SDK Reference: https://appssdk.zoom.us/
- Capabilities list: https://developers.zoom.us/docs/zoom-apps/
Zoom Apps SDK Environment Variables
Standard .env keys
| Variable | Required | Used for | Where to find |
|---|---|---|---|
ZOOM_APP_CLIENT_ID | Yes | OAuth and app identity | Zoom Marketplace -> your Zoom App -> App Credentials |
ZOOM_APP_CLIENT_SECRET | Yes | OAuth token exchange | Zoom Marketplace -> your Zoom App -> App Credentials |
ZOOM_APP_REDIRECT_URI | Yes | OAuth callback URL | Zoom Marketplace -> your Zoom App -> OAuth allow list / redirect settings |
ZOOM_APP_URL | Usually | URL loaded by Zoom client | Zoom Marketplace -> your Zoom App -> Basic Information (Development URL / Home URL) |
ZOOM_APP_BASE_URL | Optional | Internal base URL alias | Set to your deployed app origin |
SESSION_SECRET | Recommended | Session signing/encryption | Generate and manage in your own secret manager |
Runtime-only values
ZOOM_ACCESS_TOKENZOOM_REFRESH_TOKEN
Generate these during OAuth flow; do not hardcode them in repo files.
Notes
- Keep
ZOOM_APP_CLIENT_SECRETserver-side only. - Use separate development and production app credentials.
Zoom Apps SDK - Events Reference
Subscribe to events to respond to meeting, user, and app state changes.
IMPORTANT: Register event listeners AFTER successful zoomSdk.config() call. Events must be listed in the capabilities array.
Subscribing to Events
import zoomSdk from '@zoom/appssdk';
await zoomSdk.config({
capabilities: ['onMeeting', 'onParticipantChange', 'onAuthorized', ...],
version: '0.16'
});
zoomSdk.addEventListener('onMeeting', (event) => {
console.log('Meeting event:', event);
});Meeting Events
onMeeting
Meeting lifecycle changes (start, end, etc.).
zoomSdk.addEventListener('onMeeting', (event) => {
// event: { action: 'started' | 'ended' | ... }
});onParticipantChange
Participants join or leave.
zoomSdk.addEventListener('onParticipantChange', (event) => {
const { participants, action } = event;
// action: 'join' | 'leave'
// participants: [{ participantId, screenName, role }]
});onActiveSpeakerChange
Active speaker changes.
zoomSdk.addEventListener('onActiveSpeakerChange', (event) => {
const { participantId, screenName } = event;
});onBreakoutRoomChange
User moves between breakout rooms.
zoomSdk.addEventListener('onBreakoutRoomChange', (event) => {
// Re-fetch meeting UUID and state for new room
});User Events
onMyUserContextChange
Current user's context changes (role, name, mute status, etc.).
zoomSdk.addEventListener('onMyUserContextChange', (event) => {
const { role, screenName } = event;
});onMyMediaChange
Current user's audio/video status changes.
zoomSdk.addEventListener('onMyMediaChange', (event) => {
const { audio, video } = event;
// audio: { muted: true/false }
// video: { started: true/false }
});App Events
onShareApp
App sharing status changes.
zoomSdk.addEventListener('onShareApp', (event) => {
const { isShared } = event;
});onAppPopout
App popped out to separate window or back.
zoomSdk.addEventListener('onAppPopout', (event) => {
const { isPopout } = event;
});onRunningContextChange
Running context changes (e.g., meeting starts while in main client).
zoomSdk.addEventListener('onRunningContextChange', (event) => {
const { runningContext } = event;
});Authorization Events
onAuthorized
In-Client OAuth authorization completed.
zoomSdk.addEventListener('onAuthorized', (event) => {
const { code, state } = event;
// Send code to backend for token exchange
});Communication Events
onConnect
Another app instance connected.
zoomSdk.addEventListener('onConnect', (event) => {
console.log('Other instance connected');
});onMessage
Message received from connected instance.
zoomSdk.addEventListener('onMessage', (event) => {
const data = JSON.parse(event.payload);
});Collaborate Events
onCollaborateChange
Collaborate mode state changes.
zoomSdk.addEventListener('onCollaborateChange', (event) => {
console.log('Collaborate state:', event);
});Layers Events
onRenderedAppOpened
Immersive/camera rendering context opened.
zoomSdk.addEventListener('onRenderedAppOpened', (event) => {
console.log('Rendering context ready');
});Reaction Events
onReaction
Participant sends a reaction.
zoomSdk.addEventListener('onReaction', (event) => {
const { participantId, reaction } = event;
});Removing Listeners
const handler = (event) => { ... };
zoomSdk.addEventListener('onMeeting', handler);
// Later, remove it
zoomSdk.removeEventListener('onMeeting', handler);Resources
- Events reference: https://appssdk.zoom.us/
Zoom Apps SDK - Layers API Reference
Build immersive video layouts and camera overlays using the Layers API.
Overview
The Layers API (v1.5) provides rendering modes for custom visual experiences. Requires Zoom Client v5.10.6+.
| Mode | Description | Use Case |
|---|---|---|
Team (immersive + person cutout) | Canvas with background-removed participant cutouts | Podcast, talk show, classroom |
Presentation (immersive + rectangle cutout) | Canvas with full-width participant video tiles | Presentations, branded meetings |
| Camera | Overlay on user's own camera feed (OSR) | Branding, name tags, effects |
| Controller | Sidebar app that coordinates Layers modes | Required for all modes above |
Note: When using the Layers API, your app is categorized as an "Immersive App" on the Marketplace.
Required Capabilities
await zoomSdk.config({
capabilities: [
'getRunningContext',
'runRenderingContext', 'closeRenderingContext',
'drawParticipant', 'clearParticipant',
'drawImage', 'clearImage',
'drawWebView', 'clearWebView',
'postMessage', 'onMessage',
'sendAppInvitationToAllParticipants',
'onMyMediaChange',
'onRenderedAppOpened'
],
version: '0.16'
});Gotcha: The official guide listsclearWebview(lowercase 'v') in one config example. UseclearWebView(camelCase) to match the actual method name.
Types
PixelValue
All position/size parameters accept three formats:
type PixelValue = `${string}px` | `${string}%` | number;| Format | Example | Meaning |
|---|---|---|
"Npx" | "100px" | CSS reference pixels |
"N%" | "50%" | Percentage of container/view |
number | 1280 | Raw physical pixels |
ParticipantCutoutShape
type ParticipantCutoutShape =
| "person" // v5.9.3+ — Cut out background (AI segmentation)
| "standard" // v5.11.3+ — Full uncropped video (squared corners)
| "rectangle" // v5.11.0+ — Rounded rectangle (30px radius)
| "circle" // v5.11.3+ — Circle
| "square" // v5.11.3+ — Square (30px radius)
| "verticalRectangle" // v5.11.3+ — Vertical rectangle (30px radius)All shapes have 30px rounded corners except "standard" which has squared corners.
RenderingContextView
type RenderingContextView = "immersive" | "camera";Lifecycle
Starting a Rendering Context
// Team mode (person cutout — removes backgrounds)
await zoomSdk.runRenderingContext({
view: 'immersive',
defaultCutout: 'person'
});
// Presentation mode (rectangle cutout — keeps backgrounds)
await zoomSdk.runRenderingContext({
view: 'immersive',
defaultCutout: 'rectangle'
});
// Camera mode (affects only your video stream)
await zoomSdk.runRenderingContext({ view: 'camera' });`runRenderingContext(options)`:
view(required):"immersive"|"camera"defaultCutout(optional): Sets the default cutout shape for alldrawParticipant()calls in this context
Running Context Values
| Context | Meaning |
|---|---|
inMeeting | Default sidebar panel |
inImmersive | Running in immersive mode (team or presentation) |
inCamera | Running as virtual camera (off-screen rendering) |
const { runningContext } = await zoomSdk.getRunningContext();
// runningContext changes automatically when runRenderingContext() is calledUpdating Content
To move, resize, or adjust a drawn element: clear it first, then redraw.
// Move a participant
await zoomSdk.clearParticipant({ participantUUID: uuid });
await zoomSdk.drawParticipant({ participantUUID: uuid, x: 100, y: 200, width: 640, height: 480, zIndex: 1 });There is no in-place update — always clear + redraw.
Closing
await zoomSdk.closeRenderingContext();
// Returns app to sidebar, runningContext becomes "inMeeting"Constraints
- Only a meeting host can set the rendering context to immersive
- Only one immersive context can exist at a time (second attempt fails with error)
- Camera mode + Presentation mode can run simultaneously
- Host must use
sendAppInvitationToAllParticipantsto transition other participants - If
aomhostpackage needs download,runRenderingContextreturns non-success
Drawing Methods
drawParticipant
Position a participant's video feed on the canvas.
drawParticipant(options: DrawParticipantOptions): Promise<GeneralMessageResponse>| Parameter | Type | Default | Description |
|---|---|---|---|
participantUUID | string | — | Meeting-specific participant identifier |
~~participantId~~ | ~~string~~ | — | DEPRECATED — use participantUUID |
x | PixelValue | "0px" | Horizontal position |
y | PixelValue | "0px" | Vertical position |
width | PixelValue | "100%" | Width (aspect ratio maintained) |
height | PixelValue | "100%" | Height (aspect ratio maintained) |
zIndex | number | 1 | Stacking order (higher = on top) |
cutout | ParticipantCutoutShape | context default | Cutout behavior (v5.9.3+) |
cameraModeMirroring | boolean | false | Mirror video in camera mode (v5.13.5+) |
Mode differences:
- Immersive: Can draw any participant
- Camera: Can only draw current user (self)
// Immersive — draw any participant with person cutout
await zoomSdk.drawParticipant({
participantUUID: 'uuid-from-getMeetingParticipants',
x: 40, y: 100,
width: 580, height: 500,
zIndex: 1,
cutout: 'person'
});
// Camera — draw self with mirroring
await zoomSdk.drawParticipant({
participantUUID: myUUID,
x: 0, y: 0,
width: 1280, height: 720,
zIndex: 1,
cameraModeMirroring: true // v5.13.5+
});drawImage
Draw static images (backgrounds, overlays, borders).
drawImage(options: DrawImageOptions): Promise<DrawImageResponse>| Parameter | Type | Default | Description |
|---|---|---|---|
imageData | ImageData | — | Required. Standard JS ImageData object (width, height, pixel bytes) |
x | PixelValue | "0px" | Horizontal position |
y | PixelValue | "0px" | Vertical position |
zIndex | number | 1 | Stacking order |
Returns: { imageId: string } — use this ID with clearImage().
Important:imageDatais a standard JavaScriptImageDataobject (fromcanvas.getImageData()), NOT a base64 data URL.
const canvas = document.createElement('canvas');
canvas.width = 1280;
canvas.height = 720;
const ctx = canvas.getContext('2d');
// ... draw on canvas ...
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const { imageId } = await zoomSdk.drawImage({
imageData,
x: 0, y: 0,
zIndex: 0
});HiDPI Constraints
drawImage() does not directly support HiDPI image sizes. For HiDPI/Retina:
1. Draw to canvas using the scaling ratio (window.devicePixelRatio) 2. Divide out the ratio for x/y coordinates when passing to drawImage 3. Keep the ratio for width and height 4. You may need to tile the screen for full-screen images
const dpr = window.devicePixelRatio || 1;
const canvas = document.createElement('canvas');
canvas.width = 1280 * dpr;
canvas.height = 720 * dpr;
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
// ... draw at logical pixels ...
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
await zoomSdk.drawImage({
imageData,
x: 0, y: 0,
zIndex: 0
});drawWebView
Position the app's OSR (Off-Screen Rendering) webview within the Layers canvas.
drawWebView(options: DrawWebViewOptions): Promise<GeneralMessageResponse>| Parameter | Type | Default | Description |
|---|---|---|---|
x | PixelValue | 0 | Horizontal position in OSR target area |
y | PixelValue | 0 | Vertical position in OSR target area |
width | PixelValue | full rendering width | Width in OSR target area |
height | PixelValue | full rendering height | Height in OSR target area |
zIndex | number | 1 | Stacking order |
⚠ Documentation inconsistency: The official Zoom guides show a webviewId parameter in examples, but the TypeDoc type definition (v0.16.36) does not include it. Since there is only one webview per app, this parameter may be vestigial. If in doubt, omit it.What the webview renders: Your app's home URL as configured in zoomSdk.config(). It's an off-screen rendering of your app — not a configurable URL.
Only one webview per rendering context. There is no multi-webview support.
// Full-screen webview in camera mode
const config = await zoomSdk.config({ /* ... */ });
await zoomSdk.runRenderingContext({ view: 'camera' });
await zoomSdk.drawWebView({
x: 0,
y: 0,
width: config.media.renderTarget.width, // Default: 1280
height: config.media.renderTarget.height, // Default: 720
zIndex: 2
});// Partial webview overlay (bottom third of camera)
await zoomSdk.drawWebView({
x: 0,
y: 480,
width: 1280,
height: 240,
zIndex: 2
});Webview Communication
The sidebar app and the camera/immersive app are separate instances. Use postMessage() and onMessage to communicate between them:
// Sidebar instance → Camera instance (no connect() required)
zoomSdk.postMessage({ command: 'update-overlay', text: 'Q&A Time' });
// Camera instance listens
zoomSdk.addEventListener('onMessage', (eventInfo) => {
if (eventInfo.command === 'update-overlay') {
document.getElementById('overlay-text').textContent = eventInfo.text;
}
});Note:connect()is NOT required for app-to-app messaging in Layers.postMessageworks between instances of the same app.
Clearing
// Clear participant (use participantUUID, not the deprecated participantId)
await zoomSdk.clearParticipant({ participantUUID: 'uuid' });
// Clear image (use imageId from drawImage response)
await zoomSdk.clearImage({ imageId: 'id-from-drawImage' });
// Clear webview (hides it — app continues running)
await zoomSdk.clearWebView();
// Note: TypeDoc v0.16.36 shows no parameters.
// Guide examples show { webviewId: "xxx" } but this may be outdated.Coordinate System
- Origin: Top-left corner (0, 0)
- X: Increases rightward
- Y: Increases downward
- Units: PixelValue — supports
"Npx","N%", or rawnumber
Immersive Mode
- Coordinates are CSS pixels relative to the meeting canvas
- Automatic scaling for different window sizes
Camera Mode
- Coordinates are raw pixels relative to
renderTargetdimensions - Default renderTarget: 1280×720 (configurable)
- Access via:
config.media.renderTarget.width/.height
const config = await zoomSdk.config({ /* ... */ });
const rtWidth = config.media.renderTarget.width; // e.g. 1280
const rtHeight = config.media.renderTarget.height; // e.g. 720Z-Index Layering
zIndex: 2+ ─ WebViews, interactive overlays (top)
zIndex: 1 ─ Participant videos
zIndex: 0 ─ Background images (bottom)Higher zIndex values render on top. All three element types (participant, image, webview) share the same z-index space and can overlap.
Events
onRenderedAppOpened
Fires when the rendering context is ready. Best signal that CEF is initialized in camera mode.
zoomSdk.addEventListener('onRenderedAppOpened', () => {
// Safe to call drawParticipant, drawImage, drawWebView
});onMyMediaChange
Fires when the user's video changes (camera switch, "Original ratio" toggle, "HD" toggle). Returns device pixel dimensions of the source video.
zoomSdk.addEventListener('onMyMediaChange', (event) => {
// event.media.video.width / height — device pixels of source video
// Redraw your layout if needed
});Window Resize (Immersive Only)
When the Zoom meeting window is resized, the app must move and resize participants/images. Not relevant to Camera Mode (fixed renderTarget).
Immersive Mode vs Camera Mode
| Aspect | Immersive | Camera |
|---|---|---|
| Scope | Entire meeting view | User's camera only |
| drawParticipant | Any participant | Self only |
| drawImage | Yes | Yes |
| drawWebView | Yes | Yes |
| Who sees it | All participants | All see it on this user's feed |
| Browser engine | Standard WebView | CEF (Chromium Embedded Framework) |
| Rendering | On-screen | Off-screen (OSR) |
| Coordinate space | CSS pixels | Raw pixels (renderTarget) |
| Simultaneous | One immersive at a time | Can run with Presentation mode |
Camera Mode: CEF Race Condition
Camera mode uses CEF which takes time to initialize. Draw calls may fail if called too early.
Best approach: Listen for `onRenderedAppOpened`:
zoomSdk.addEventListener('onRenderedAppOpened', async () => {
await zoomSdk.drawWebView({ x: 0, y: 0, width: 1280, height: 720, zIndex: 2 });
});Fallback: Retry with exponential backoff:
async function drawWithRetry(drawFn, maxRetries = 5) {
for (let i = 0; i < maxRetries; i++) {
try {
await drawFn();
return;
} catch (error) {
if (i === maxRetries - 1) throw error;
await new Promise(r => setTimeout(r, 200 * Math.pow(2, i)));
}
}
}Alternative: Check running context:
const { runningContext } = await zoomSdk.getRunningContext();
if (runningContext === 'inCamera') {
// CEF is ready, safe to draw
}Performance Tips
- Use
requestAnimationFramefor animations - Minimize draw calls (batch updates when possible)
- Pre-render complex backgrounds to a single canvas ImageData
- Keep zIndex values low (0-10 range)
- Clear unused elements to free resources
- Test on lower-end hardware
- For full-screen images: tile the screen (HiDPI limitation)
Example: Two-Person Podcast Layout
// Background
const canvas = document.createElement('canvas');
canvas.width = 1280;
canvas.height = 720;
const ctx = canvas.getContext('2d');
const gradient = ctx.createLinearGradient(0, 0, 1280, 720);
gradient.addColorStop(0, '#1a1a2e');
gradient.addColorStop(1, '#16213e');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, 1280, 720);
const imageData = ctx.getImageData(0, 0, 1280, 720);
await zoomSdk.drawImage({ imageData, x: 0, y: 0, zIndex: 0 });
// Host (left) — person cutout removes background
await zoomSdk.drawParticipant({
participantUUID: hostUUID,
x: 40, y: 100, width: 580, height: 500,
zIndex: 1, cutout: 'person'
});
// Guest (right)
await zoomSdk.drawParticipant({
participantUUID: guestUUID,
x: 660, y: 100, width: 580, height: 500,
zIndex: 1, cutout: 'person'
});Version History
| Feature | Client Version | SDK Version |
|---|---|---|
| Core Layers API | 5.9.0 | 0.16 |
cutout: "person" | 5.9.3 | 0.16 |
cutout: "rectangle" | 5.11.0 | 0.16 |
cutout: "circle", "square", "verticalRectangle" | 5.11.3 | 0.16 |
drawWebView() / clearWebView() | 5.10.6 | 0.16.11+ |
| Camera Mode | 5.13.1 | 0.16 |
cameraModeMirroring | 5.13.5 | 0.16 |
Resources
- Layers API docs: https://developers.zoom.us/docs/zoom-apps/guides/layers-api/
- Using the API: https://developers.zoom.us/docs/zoom-apps/guides/layers-using-api/
- Manipulating UI: https://developers.zoom.us/docs/zoom-apps/guides/layers-manipulating-ui/
- Camera Mode docs: https://developers.zoom.us/docs/zoom-apps/guides/camera-mode/
- Sample app: https://github.com/zoom/zoomapps-customlayout-js
- SDK TypeDoc: https://appssdk.zoom.us/classes/ZoomSdk.ZoomSdk.html
- Immersive example: ../examples/layers-immersive.md
- Camera example: ../examples/layers-camera.md
Zoom Apps SDK - OAuth Reference
OAuth flows for Zoom Apps: web-based redirect, In-Client, and third-party.
Three OAuth Flows
| Flow | UX | When to Use |
|---|---|---|
| Web-based redirect | Opens browser, redirect back | Initial install from Marketplace |
| In-Client OAuth | Popup inside Zoom, no redirect | Subsequent authorizations (best UX) |
| Third-party OAuth | External provider (Auth0, Google) | When your app needs non-Zoom auth |
PKCE (Required for All Flows)
All Zoom Apps OAuth must use PKCE (Proof Key for Code Exchange):
const crypto = require('crypto');
// Generate PKCE pair
const verifier = crypto.randomBytes(32).toString('hex');
const challenge = crypto.createHash('sha256')
.update(verifier)
.digest('base64url');
// verifier: stored server-side (never exposed to client)
// challenge: sent with authorization requestFlow 1: Web-Based OAuth (Initial Install)
User clicks "Add" in Marketplace
|
v
GET https://zoom.us/oauth/authorize
?client_id=YOUR_CLIENT_ID
&response_type=code
&redirect_uri=YOUR_REDIRECT_URI
&code_challenge=CHALLENGE
&code_challenge_method=S256
&state=RANDOM_STATE
|
v
User authorizes -> Zoom redirects to YOUR_REDIRECT_URI?code=AUTH_CODE&state=STATE
|
v
Backend validates state, exchanges code for tokens
|
v
Backend gets deeplink, redirects user to Zoom clientServer Route Handler
app.get('/auth', async (req, res) => {
const { code, state } = req.query;
// Validate state (CSRF protection)
if (state !== req.session.state) {
return res.status(403).send('Invalid state');
}
// Exchange code for tokens
const tokenResponse = await axios.post('https://zoom.us/oauth/token', null, {
params: {
grant_type: 'authorization_code',
code,
redirect_uri: process.env.ZOOM_APP_REDIRECT_URI,
code_verifier: req.session.codeVerifier
},
headers: {
'Authorization': 'Basic ' + Buffer.from(
`${process.env.ZOOM_APP_CLIENT_ID}:${process.env.ZOOM_APP_CLIENT_SECRET}`
).toString('base64')
}
});
const { access_token, refresh_token, expires_in } = tokenResponse.data;
// Store tokens securely
req.session.tokens = { access_token, refresh_token, expires_at: Date.now() + expires_in * 1000 };
// Get deeplink to open app in Zoom
const deeplink = await axios.post('https://api.zoom.us/v2/zoomapp/deeplink',
{ action: '' },
{ headers: { 'Authorization': `Bearer ${access_token}` } }
);
res.redirect(deeplink.data.deeplink);
});Flow 2: In-Client OAuth (Best UX)
No browser redirect - authorization happens inside Zoom:
// Frontend
const { codeChallenge, state } = await fetch('/api/auth/challenge').then(r => r.json());
zoomSdk.addEventListener('onAuthorized', async (event) => {
await fetch('/api/auth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: event.code, state: event.state })
});
});
await zoomSdk.authorize({ codeChallenge, state });See [In-Client OAuth example](../examples/in-client-oauth.md) for complete implementation.
Token Exchange Endpoint
POST https://zoom.us/oauth/token
Headers:
Authorization: Basic base64(CLIENT_ID:CLIENT_SECRET)
Parameters:
grant_type=authorization_code
code=AUTH_CODE
redirect_uri=YOUR_REDIRECT_URI
code_verifier=PKCE_VERIFIERResponse:
{
"access_token": "...",
"token_type": "bearer",
"refresh_token": "...",
"expires_in": 3600,
"scope": "zoomapp:inmeeting"
}Token Refresh
Access tokens expire in 1 hour. Use refresh token to get new ones:
async function refreshTokens(refreshToken) {
const response = await axios.post('https://zoom.us/oauth/token', null, {
params: {
grant_type: 'refresh_token',
refresh_token: refreshToken
},
headers: {
'Authorization': 'Basic ' + Buffer.from(
`${process.env.ZOOM_APP_CLIENT_ID}:${process.env.ZOOM_APP_CLIENT_SECRET}`
).toString('base64')
}
});
return response.data; // { access_token, refresh_token, expires_in }
}Note: Refresh tokens are single-use. Each refresh returns a new refresh_token.
Deep Linking
After web OAuth, get a deeplink to open your app in Zoom:
const response = await axios.post('https://api.zoom.us/v2/zoomapp/deeplink',
{ action: '' },
{ headers: { 'Authorization': `Bearer ${accessToken}` } }
);
const { deeplink } = response.data;
// Redirect user to this URL to open app in Zoom clientRequired Scopes
| Scope | Description |
|---|---|
zoomapp:inmeeting | In-meeting functionality (most common) |
user:read | Read user profile |
meeting:read | Read meeting details |
meeting:write | Create/modify meetings |
Token Storage Patterns
| Pattern | When to Use |
|---|---|
| Redis | Multi-instance production servers |
| Session cookie | Simple single-server apps |
| Firestore | Serverless (Firebase) |
| Encrypted database | Complex apps with user accounts |
Resources
- Auth docs: https://developers.zoom.us/docs/zoom-apps/authentication/
- In-Client OAuth example: ../examples/in-client-oauth.md
- oauth skill: ../../oauth/SKILL.md
Zoom Mail Integration
Integrate with Zoom Mail via REST API and Zoom Apps SDK for mail plugins.
Overview
Important: There is no standalone "ZMail JS SDK". Zoom Mail integration uses: 1. Zoom Mail REST API - Server-side email operations 2. Zoom Apps SDK - Build mail plugins that run inside Zoom client
Prerequisites
- Zoom Workplace Pro, Standard Pro, Business, or Enterprise account
- Zoom Mail enabled (Pro accounts have it by default; Business/Enterprise must enable)
- OAuth app with mail scopes
Zoom Mail REST API
Base URL
https://api.zoom.us/v2/emailsKey Endpoints
| Method | Endpoint | Description |
|---|---|---|
| POST | /emails/mailboxes/{email}/messages/send | Send a message |
| POST | /emails/mailboxes/{email}/messages | Create/add message to mailbox |
| POST | /emails/mailboxes/{email}/drafts | Create draft |
| POST | /emails/mailboxes/{email}/labels | Create label |
| GET | /emails/mailboxes/me/profile | Get mailbox profile |
Send Email Example
Emails must be in RFC 2822 format, base64 encoded:
// Generate RFC 2822 compliant message
function generateEmailMessage(from, to, subject, body) {
const message = `From: ${from}\nTo: ${to}\nSubject: ${subject}\n\n${body}`;
return btoa(message); // Base64 encode
}
// Send email via API
async function sendEmail(mailbox, toEmail, subject, body) {
const encodedMessage = generateEmailMessage(mailbox, toEmail, subject, body);
const response = await fetch(
`https://api.zoom.us/v2/emails/mailboxes/${mailbox}/messages/send`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
raw: encodedMessage
})
}
);
return response.json();
}
// Usage
await sendEmail(
'sender@company.zmail.com',
'recipient@example.com',
'Meeting Follow-up',
'Thank you for attending today\'s meeting.'
);Create Draft
async function createDraft(mailbox, to, subject, body) {
const encodedMessage = generateEmailMessage(mailbox, to, subject, body);
await fetch(
`https://api.zoom.us/v2/emails/mailboxes/${mailbox}/drafts`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
raw: encodedMessage
})
}
);
}Get Mailbox Profile
const profile = await fetch(
'https://api.zoom.us/v2/emails/mailboxes/me/profile',
{
headers: { 'Authorization': `Bearer ${accessToken}` }
}
).then(r => r.json());
console.log('Email:', profile.email);Zoom Apps SDK - Mail Plugins
Build apps that run inside Zoom Mail tab using Zoom Apps SDK.
Installation
npm install @zoom/appssdkOr via CDN:
<script src="https://appssdk.zoom.us/sdk.js"></script>Initialize for Mail Context
// IMPORTANT: Do NOT declare "let zoomSdk" - causes redeclaration error
// The SDK defines window.zoomSdk globally
let sdk = window.zoomSdk;
async function init() {
try {
const configResponse = await sdk.config({
popout: true,
capabilities: ['insertContentToMailActiveEditor'],
version: '0.16'
});
console.log('Running in context:', configResponse.runningContext);
// Will be "mailTab" when running in Zoom Mail
} catch (error) {
console.error('Not running inside Zoom client:', error);
}
}Insert Content to Mail Editor
Insert HTML content into the active mail composer:
// Insert content into mail editor (must comply with Tiptap HTML specs)
await sdk.insertContentToMailActiveEditor({
html: '<p>This content will be inserted into the email body.</p>'
});Mail Plugin Use Cases
| Use Case | Description |
|---|---|
| Email Templates | Insert pre-built email templates |
| Signature Manager | Dynamic email signatures |
| Meeting Links | Auto-insert Zoom meeting links |
| CRM Integration | Pull contact info into emails |
| Translation | Translate email content |
Authentication
Server-to-Server OAuth (for REST API)
async function getAccessToken() {
const response = await fetch('https://zoom.us/oauth/token', {
method: 'POST',
headers: {
'Authorization': 'Basic ' + btoa(`${clientId}:${clientSecret}`),
'Content-Type': 'application/x-www-form-urlencoded'
},
body: 'grant_type=client_credentials'
});
const data = await response.json();
return data.access_token; // Valid for 1 hour
}Required Scopes
| Scope | Description |
|---|---|
mail:read | Read mailbox data |
mail:write | Send emails, create drafts |
mail:read:admin | Admin read access |
mail:write:admin | Admin write access |
Limitations
| Limitation | Notes |
|---|---|
| Account requirement | Zoom Workplace Pro+ with Zoom Mail |
| Email format | Must be RFC 2822 compliant, base64 encoded |
| Plugin context | Zoom Apps SDK mail features only work in Mail tab |
| HTML in editor | Must comply with Tiptap specifications |
Resources
- Zoom Mail API Docs: https://developers.zoom.us/docs/api/rest/zoom-mail/
- Zoom Mail Overview: https://developers.zoom.us/docs/mail/
- Zoom Apps SDK: https://github.com/zoom/appssdk
- NPM Package: https://www.npmjs.com/package/@zoom/appssdk
- Sample Implementation: https://github.com/Wrightlab1/ZoomMailAPI
Zoom Apps SDK 5-Minute Preflight Runbook
Use this before deep debugging. It catches common Zoom Apps integration failures quickly.
Skill Doc Standard Note
- Agent-skill standard entrypoint is
SKILL.md. - This runbook is an operational convention (recommended), not a required skill file.
SKILL.mdis also a navigation convention for larger skill docs.
1) Confirm App Type and Context
- App type must be Zoom App in Marketplace.
- Confirm expected running context (
inMeeting,inMainClient,inWebinar, etc.).
Context mismatch often looks like missing APIs.
2) Confirm Domain Allowlist
- Whitelist the exact dev/prod domains used by your app.
- If app panel is blank or refuses to load, domain allowlist is first check.
Blank Panel Triage (60s)
- Confirm app URL is HTTPS and reachable directly in a browser.
- Confirm the exact host is allowlisted in Marketplace (including subdomain differences).
- Confirm no redirect loop (watch network tab for repeated 30x responses).
- Confirm CSP/X-Frame-Options do not block Zoom embedded browser usage.
- Confirm local tunnel URL in app config matches current active tunnel.
3) Confirm In-Client OAuth Setup
- Use correct redirect/callback handling for Zoom Apps flow.
- Validate state/PKCE handling if implemented.
- Confirm scopes and re-authorize after scope changes.
4) Confirm SDK Capability Usage
- Call APIs only when supported in current context/capability set.
- Inspect initialization and capability negotiation results.
Capability Probe Snippet
Use this early in app startup to avoid calling unavailable APIs:
import zoomSdk from '@zoom/appssdk';
async function probeSdk() {
const config = await zoomSdk.config({
capabilities: [
'getSupportedJsApis',
'getRunningContext',
'authorize',
'openUrl',
'shareApp',
],
});
console.log('runningContext:', config.runningContext);
console.log('supportedApis:', config.supportedApis || []);
const supported = new Set(config.supportedApis || []);
if (!supported.has('authorize')) {
console.warn('authorize API unavailable in this context/capability set');
}
}5) Confirm Local Development Tunnel
- Use stable HTTPS tunnel (ngrok or equivalent).
- Update Marketplace config when tunnel URL changes.
6) Quick Probes
- App loads inside Zoom client without blank panel.
- SDK init succeeds and returns expected capabilities.
- OAuth flow completes and API calls work with granted scopes.
Copy/Paste Validation Commands
# 1) Verify app URL is reachable and returns HTML
curl -sS -i "$ZOOM_APP_URL"
# 2) Verify OAuth callback URL is reachable
curl -sS -i "$ZOOM_APP_CALLBACK_URL"
# 3) Verify backend token/config endpoint returns JSON
curl -sS -i "$ZOOM_APP_BASE_URL/api/config"Expected: HTTP 200/3xx and valid HTML/JSON (not generic 404/502 pages).
7) Fast Decision Tree
- Blank panel -> domain allowlist, HTTPS, CSP headers.
- API unavailable -> wrong running context or capability not granted.
- OAuth loop/failure -> redirect/state/scope mismatch.
8) SDK Selection Guardrail
- Use Zoom Apps SDK when app runs inside Zoom client contexts.
- Use Meeting SDK when embedding Zoom meeting UI into your own website/app.
- If you are debugging "missing Zoom Apps APIs" in a standalone browser page, you are likely in the wrong SDK/runtime.
Forum-Derived Top Questions (Zoom Apps)
This is a checklist of the most common forum questions for Zoom Apps SDK.
Fast Routing Questions (Ask First)
- Running context:
inMeetingvsinMainClientvsinWebinarvsinImmersiveetc. - SDK loading style: NPM import vs CDN (
window.zoomSdk) - Marketplace config: domain allowlist, scopes, and required capabilities
“App won’t load / blank panel”
Most common causes:
- domain not in Marketplace allowlist
- trying to run the app in a normal browser (needs preview/demo mode)
- blocked mixed-content or missing HTTPS in dev tunnel
“zoomSdk redeclaration” (CDN)
Common failure:
- redeclaring
let zoomSdk = ...when CDN already defineswindow.zoomSdk
Answer pattern:
- use
const sdk = window.zoomSdkor NPM import
Auth Confusion
Common asks:
- “Do I use OAuth redirects?”
- “How does In-Client OAuth work?”
Answer pattern:
- explain In-Client OAuth (PKCE) and required scopes
- differentiate from REST API OAuth flows
Related skills
Forks & variants (1)
Zoom Apps Sdk has 1 known copy in the catalog totaling 13 installs. They canonicalize to this original listing.
- zoom - 13 installs
How it compares
Choose zoom-apps-sdk when the product must live inside Zoom's client WebView rather than as a standalone web app.
FAQ
What does zoom-apps-sdk do?
Reference skill for Zoom Apps SDK. Use after routing to an in-client app workflow when building web apps that run inside Zoom meetings, webinars, the main client, or Zoom Phone.
When should I use zoom-apps-sdk?
User asks about zoom apps sdk or related SKILL.md workflows.
Is zoom-apps-sdk safe to install?
Review the Security Audits panel on this page before installing in production.