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.

What you'll Need

Prerequisites

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.

Manifest schema

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.

Create your App: How to Create and Configure a Zoom App (User-Managed) with the Manifest API

  1. Visit the Zoom App Marketplace
  2. Start Building a New App
    • Click the "Develop" dropdown in the top-right.
    • Select "Build App".
  3. Choose App Type:
    • When prompted to choose an app type, select "General App" unless you have a specific use case.
    • Click "Create".
  4. Choose App Management Type
    • Select User-managed App. This allows individual users to install and authorize your app to access their Zoom data — similar to apps accessing a user's Google Drive or Gmail
  5. Configure Basic App Information
    • On the "Basic Information" page:
    • Scroll down to the Redirect URL for OAuth section. For testing with Postman, add the Postman redirect URI:
https://oauth.pstmn.io/v1/callback

  1. Get App ID from URL: App ID
  2. Make a PATCH request to update the app (test with Postman). After configuring the app, test it by sending a PATCH request to Zoom's API to update your app metadata or description.

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": []
          }
      }
  }
}

Resource:

  1. Update an app by manifest API

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.

Account Setup

  1. Add User : Start tunnel
  2. Add Queue: Queue
  3. Add Number: Add Number
  4. Assign Number: Assign Number
  5. Add Agent Profile (Optional): Assign agent
  6. Flow - Configure Queue: Configure Queue
  7. Integration: Integration
  8. Add Zoom Apps: Add Zoom Apps
  9. Manage/Add Queues: Integration

Contact Center external landing page

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>

Contact Center In-App home page

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>

Contact Center dashboard page

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:

Learn more