This codelab guides you through building a Zoom Contact Center app using HTML, JavaScript, and CSS. The sample application serves as a minimal example of how to extend a web application into the Zoom client and integrate within the Contact Center in the Zoom Client. You'll learn to create a Zoom Marketplace app using the Zoom Manifest API and display your Contact Center application within the Zoom client.
⚠️ Warning: Some feature requires a paid plan.
💡 Tip: If you don't have a paid workspace for development, you can join by signing up for a free Zoom account, which provides access to all Zoom Developer platform features. To learn more about powering your service with Zoom, visit Zoom for Startups.
✓ How to build a Zoom Contact Center app
✓ How to implement secure API communication by configuring proper OAuth scopes and webhook endpoints.
✓ How to manage manifest versioning to maintain your app's functionality during updates.
Ngrok is a tool that creates secure tunnels to your local development server, allowing you to expose your localhost to the internet over HTTPS. It's especially useful for testing webhooks, APIs, or any feature that requires a publicly accessible URL during development.
In this tutorial, ngrok is used to serve the sample Zoom App over HTTPS. This is a requirement for all Zoom Apps, as they must be hosted on a secure (HTTPS) endpoint in order to be embedded within the Zoom client and interact with Zoom services.
Run:
ngrok http 3000
Ngrok will output the origin it has created for your tunnel, eg https://9a20-38-99-100-7.ngrok.io. You should see the tunnel traffic:

Use your ngrok URL
After starting ngrok, you'll get a public HTTPS URL like https://9a20-38-99-100-7.ngrok.io. Use those origins for any Zoom Manifest or configuration that requires a secure URL.
Replace any placeholder URL such as https://example.ngrok.io with your actual ngrok-generated URL.
For example:
"https://example.ngrok.io" → "https://9a20-38-99-100-7.ngrok.io"
Make sure to update all instances in your manifest file, environment variables, and redirect URIs accordingly.
App manifests are JSON files that define the configuration of Zoom App Marketplace apps.
Manifests are JSON-formatted configuration files for Zoom Apps. You can use a manifest to create a general app via the Marketplace build flow or API, or to update the configuration of an existing general app. Manifests are portable, allowing you to share and reuse them across environments.
In this section, you will create a user-managed Zoom App Marketplace general app and configure it with Zoom App and Team Chatbot features.
https://oauth.pstmn.io/v1/callback

Example Postman setup:
Use the Ngrok output origins created for your tunnel, eg https://9a20-38-99-100-7.ngrok.io:
{
"manifest": {
"display_information": {
"display_name": "Zoom Contact Center Apps JS Sample"
},
"oauth_information": {
"usage": "USER_OPERATION",
"development_redirect_uri": "https://example.ngrok.app/auth/callback",
"production_redirect_uri": "",
"oauth_allow_list": [
"https://oauth.pstmn.io/v1/callback",
"https://example.ngrok.app/auth/callback"
],
"strict_mode": false,
"subdomain_strict_mode": false,
"scopes": [
{
"scope": "marketplace:read:app",
"optional": false
},
{
"scope": "meeting:read:meeting",
"optional": false
},
{
"scope": "team_chat:read:user_message",
"optional": false
},
{
"scope": "zoomapp:inmeeting",
"optional": false
}
]
},
"features": {
"products": [
"ZOOM_CONTACT_CENTER",
"ZOOM_MEETING"
],
"development_home_uri": "https://example.ngrok.app",
"production_home_uri": "",
"domain_allow_list": [
{
"domain": "appssdk.zoom.us",
"explanation": ""
},
{
"domain": "ngrok.app",
"explanation": ""
},
{
"domain": "cdn.ngrok.com",
"explanation": ""
}
],
"in_client_feature": {
"zoom_app_api": {
"enable": true,
"zoom_app_apis": [
"getAppContext",
"getEngagementContext",
"getEngagementStatus",
"getMeetingContext",
"getMeetingUUID",
"getRunningContext",
"getSupportedJsApis",
"getUserContext",
"onEngagementContextChange",
"onEngagementMediaRedirect",
"onEngagementStatusChange",
"onEngagementVariableValueChange"
]
},
"guest_mode": {
"enable": false,
"enable_test_guest_mode": false
},
"in_client_oauth": {
"enable": false
},
"collaborate_mode": {
"enable": false,
"enable_screen_sharing": false,
"enable_play_together": false,
"enable_start_immediately": false,
"enable_join_immediately": false
}
},
"zoom_client_support": {
"mobile": {
"enable": false
},
"zoom_room": {
"enable": false,
"enable_personal_zoom_room": false,
"enable_shared_zoom_room": false,
"enable_digital_signage": false,
"enable_zoom_rooms_controller": false
},
"pwa_client": {
"enable": false
}
},
"embed": {
"meeting_sdk": {
"enable": false,
"enable_device": false,
"devices": []
},
"contact_center_sdk": {
"enable": true
},
"phone_sdk": {
"enable": false
}
},
"team_chat_subscription": {
"enable": false,
"enable_support_channel": false,
"shortcuts": []
},
"event_subscription": {
"enable": false,
"events": []
}
}
}
}
Agents are internal team members who use ZCC to engage with external users. Depending on the workflow and environment, agents typically operate in the following contexts:
Zoom Workplace App: For a native experience, agents use the Zoom Contact Center desktop interface. You can enhance this experience by building Zoom Contact Center Apps, which embed your custom web tools directly into the agent's call UI — enabling a seamless, "single pane of glass" workflow that reduces context-switching and boosts productivity.
CRM Integration: Use the Zoom Contact Center API endpoints and events to integrate access to Contact Center features and data directly into your application. See the Zoom Contact Center Support page for further details on CRM integraton.









Develop a browser-compatible landing page for your Zoom Contact Center application
<!-- public/browser-version.html -->
<!DOCTYPE html>
<html>
<head>
<title>Zoom App – Browser Version</title>
<style>
body {
font-family: Arial, sans-serif;
padding: 40px;
text-align: center;
background-color: #f9f9f9;
color: #333;
}
h1 {
font-size: 32px;
margin-bottom: 20px;
}
p {
font-size: 18px;
max-width: 600px;
margin: 0 auto 30px;
}
.note {
font-size: 14px;
color: #888;
}
</style>
</head>
<body>
<h1>This app runs inside Zoom</h1>
<p>This app is built for the Zoom client and won't work in a standard browser.</p>
<p class="note">If you're a developer, launch the Zoom app from within the Zoom Client.</p>
<button id="open-in-zoom" type="button">Open in Zoom</button>
<script>
document.getElementById('open-in-zoom').addEventListener('click', () => {
window.open('/auth/start', '_blank', 'noopener');
});
</script>
</body>
</html>
Build the main interface users will see when accessing your app within Zoom
<!DOCTYPE html>
<html>
<head>
<title>Zoom App Context Data</title>
<script src="https://appssdk.zoom.us/sdk.js"></script>
<link rel="stylesheet" type="text/css" href="./styles.css" />
</head>
<body>
<h1>Zoom App Context Data</h1>
<div id="output">Initializing...</div>
<script>
// ---------- Configuration ----------
const APIS = [
{
title: "Supported JS APIs",
capability: "getSupportedJsApis",
call: () => zoomSdk.getSupportedJsApis(),
},
{
title: "Running Context",
capability: "getRunningContext",
call: () => zoomSdk.getRunningContext(),
},
{
title: "User Context",
capability: "getUserContext",
call: () => zoomSdk.getUserContext(),
},
{
title: "App Context",
capability: "getAppContext",
call: () => zoomSdk.getAppContext(),
},
{
title: "Meeting Context",
capability: "getMeetingContext",
call: () => zoomSdk.getMeetingContext(),
},
{
title: "Meeting UUID",
capability: "getMeetingUUID",
call: () => zoomSdk.getMeetingUUID(),
},
];
// ---------- Small DOM helpers ----------
const out = document.getElementById("output");
const clearOut = () => (out.textContent = "");
const addSection = (title, html) => {
const s = document.createElement("section");
s.innerHTML = `<h2>${title}</h2>${html}`;
out.appendChild(s);
};
const pre = (v) =>
`<pre>${typeof v === "string" ? v : JSON.stringify(v, null, 2)}</pre>`;
const addHint = (text) => {
const p = document.createElement("p");
p.className = "hint";
p.textContent = text;
out.prepend(p);
};
// ---------- Error classification (explicit code check) ----------
const isMeetingRequired = (err) => {
return err;
};
// ---------- Render one API ----------
const renderApi = async ({ title, call }) => {
try {
const data = await call();
addSection(title, pre(data));
return { ok: true, data };
} catch (err) {
console.error(`[${title}]`, err);
const note = isMeetingRequired(err)
? `<p class="error">This API requires the app to be running <em>in a meeting</em>.</p>`
: `<p class="error">Failed to load.</p>`;
addSection(title, `${note}`);
return { ok: false, error: err };
}
};
// ---------- Main flow ----------
(async function main() {
if (typeof zoomSdk === "undefined") {
out.innerHTML = `<p class="error">zoomSdk not found. Ensure the SDK script loads before this script.</p>`;
return;
}
try {
const capabilities = APIS.map((a) => a.capability);
await zoomSdk.config({ version: "0.16.0", capabilities });
// --- Check running context ASAP and jump to the Contact Center page
try {
const rc = await zoomSdk.getRunningContext(); // ensures the API is invoked here too
// Be tolerant of different return shapes (string vs object)
const rcStr =
typeof rc === "string"
? rc
: rc?.runningContext || rc?.context || JSON.stringify(rc);
if (
typeof rcStr === "string" &&
rcStr.toLowerCase().includes("incontactcenter")
) {
// Redirect to your Zoom Contact Center page in /public
window.location.replace("/zcc-apps.html");
return; // stop rendering the rest of this page
}
} catch (e) {
// If this call fails, just proceed with the normal page
console.warn("getRunningContext pre-check failed; continuing:", e);
}
// remove "Initializing..." once ready
clearOut();
// render all APIs, in order
const results = {};
for (const api of APIS) {
results[api.title] = await renderApi(api);
}
// gentle hint if not obviously in a meeting
const rc = results["Running Context"]?.data;
const rcText = rc ? JSON.stringify(rc) : "";
if (!/inMeeting/i.test(rcText)) {
addHint(
"Hint: Some APIs may be unavailable because the app is not currently running in a meeting."
);
}
} catch (fatal) {
console.error("Initialization failed:", fatal);
out.innerHTML = `<p class="error">Could not initialize Zoom SDK.</p>${pre(
fatal
)}`;
}
})();
</script>
</body>
</html>
Create a comprehensive dashboard for managing contact center operations
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>ZCC App Minimal (with localStorage)</title>
<script src="https://appssdk.zoom.us/sdk.js"></script>
<style>
body { font-family: system-ui, sans-serif; margin: 18px; }
.row { margin: 8px 0; }
textarea { width: 100%; min-height: 140px; }
.hint { opacity: 0.7; font-size: 0.9em; }
</style>
</head>
<body>
<h1>Zoom Contact Center</h1>
<div class="row"><strong>Running Context:</strong> <span id="running-context">—</span></div>
<div class="row"><strong>Engagement ID:</strong> <span id="engagement-id">—</span></div>
<div class="row"><strong>Status:</strong> <span id="engagement-status">—</span></div>
<div class="row">
<label for="notes-textarea"><strong>Notes (saved per engagement)</strong></label><br />
<textarea id="notes-textarea" placeholder="Type notes..."></textarea>
<div class="hint">Notes auto-save for this engagement and clear when it ends.</div>
</div>
<script>
(async () => {
/** ---------- Tiny DOM helpers ---------- */
const byId = (id) => document.getElementById(id);
const setTextContent = (id, value) => { byId(id).textContent = value; };
/** Build the localStorage key for a given engagement id */
const notesKeyForEngagement = (engagementId) => `zcc-notes:${engagementId}`;
/** Safe localStorage wrapper (avoids exceptions in restrictive envs) */
const storage = {
get(key) { try { return localStorage.getItem(key); } catch { return null; } },
set(key, value) { try { localStorage.setItem(key, value); } catch {} },
remove(key) { try { localStorage.removeItem(key); } catch {} },
};
if (typeof zoomSdk === "undefined") {
document.body.insertAdjacentHTML("beforeend", "<p class='hint'>zoomSdk not found.</p>");
return;
}
/** ---------- Configure SDK ---------- */
await zoomSdk.config({
version: "0.16.0",
capabilities: [
"getRunningContext",
"getEngagementContext",
"getEngagementStatus",
"onEngagementContextChange",
"onEngagementStatusChange",
],
});
/** ---------- Initial values ---------- */
const runningContext = await zoomSdk.getRunningContext();
setTextContent(
"running-context",
typeof runningContext === "string" ? runningContext : JSON.stringify(runningContext)
);
let currentEngagementId = "";
let currentEngagementState = "";
/** Load context + status and hydrate notes for the active engagement */
async function loadEngagementInfo() {
const [contextResponse, statusResponse] = await Promise.all([
zoomSdk.getEngagementContext().catch(() => null),
zoomSdk.getEngagementStatus().catch(() => null),
]);
currentEngagementId = contextResponse?.engagementContext?.engagementId || "";
currentEngagementState = statusResponse?.engagementStatus?.state || "";
setTextContent("engagement-id", currentEngagementId || "—");
setTextContent("engagement-status", currentEngagementState || "—");
// Load saved notes for this engagement (if any)
byId("notes-textarea").value = currentEngagementId
? (storage.get(notesKeyForEngagement(currentEngagementId)) || "")
: "";
}
/** ---------- Notes auto-save (per engagement) ---------- */
byId("notes-textarea").addEventListener("input", (e) => {
if (currentEngagementId) {
storage.set(notesKeyForEngagement(currentEngagementId), e.target.value);
}
});
/** ---------- Live updates from ZCC ---------- */
function handleEngagementContextChange(evt) {
currentEngagementId = evt?.engagementContext?.engagementId || "";
setTextContent("engagement-id", currentEngagementId || "—");
byId("notes-textarea").value = currentEngagementId
? (storage.get(notesKeyForEngagement(currentEngagementId)) || "")
: "";
}
function handleEngagementStatusChange(evt) {
currentEngagementState = evt?.engagementStatus?.state || "";
setTextContent("engagement-status", currentEngagementState || "—");
// Clear saved notes when engagement ends
if (currentEngagementState === "end" && currentEngagementId) {
storage.remove(notesKeyForEngagement(currentEngagementId));
byId("notes-textarea").value = "";
}
}
zoomSdk.addEventListener("onEngagementContextChange", handleEngagementContextChange);
zoomSdk.addEventListener("onEngagementStatusChange", handleEngagementStatusChange);
/** Kick off initial load */
await loadEngagementInfo();
})();
</script>
</body>
</html>
Congratulations! You have successfully built a multi-feature Zoom app using a manifest file.
To continue learning and extend your app's capabilities, explore the following resources: