Getting started with Zoom Contact Center apps

The Zoom App framework lets developers embed custom web applications directly into the Zoom Contact Center desktop interface. By integrating tools within the agent’s call UI, you eliminate constant switching between applications—creating a true single-pane-of-glass experience that improves efficiency and productivity.

In this guide, you’ll learn how to build a Zoom Contact Center App that allows support agents to interact with external users and enhances their workflow inside the Zoom Contact Center desktop client.

Prerequisites

  • Zoom account with developer role permissions
  • Zoom Contact Center license
  • Zoom Global Phone
  • Node.js and npm

Get SDK credentials

You can quickly configure your Zoom Marketplace app using the Update an app by manifest API endpoint and the manifest JSON object. For more details, refer to the Zoom Manifest documentation.

This sample app includes a manifest folder containing the app configuration for a Zoom Contact Center app. Use the pre-defined configuration to create and configure a general app.

Scaffold

The sample app consists of three pages:

  • A default browser landing page
  • The Zoom App in-client home page
  • The Zoom Contact Center app page for agents

To begin:

$ git clone https://github.com/zoom/zcc-javascript-quickstart.git --branch scaffold
$ cd zcc-quickstart
$ npm install

This is a simple vanilla JavaScript, Node.js, and Express app. It includes:

File PathDescriptionPurpose
lib/zoom-api.jsOAuth helper functionsProvides Zoom OAuth utilities
public/browser.htmlBrowser landing pageRedirects user from browser to Zoom client
public/zoomapp-home.htmlZoom App in-client home pageRenders third-party app UI in the Zoom client
public/zcc-apps.htmlZoom Contact Center pageCaptures and logs agent engagement notes
server.jsExpress server with OWASP headersImplements backend functionality

This guide focuses on enabling support agents to deep link into a Zoom App from a browser and capture notes during engagements in the desktop interface.

  • Completed demo on the main branch.

To learn more about building Zoom App experiences and the deep linking API, see the resources below.

Account configuration

After creating and configuring your Marketplace app, complete the following setup in the Zoom web portal:

  • Create and select a queue
  • Add a phone number
  • Create an agent profile
  • Create a voice flow

For a detailed walkthrough, refer to the Zoom Contact Center code-lab.

Zoom App home page

The Zoom App SDK lets you to bring third-party apps into the Zoom collaboration experience. The app can access contextual data to determine its execution environment.

On line 17 of zoomapp-home.html, insert a script to display supported Zoom JS APIs and app context data. This script initializes zoomSdk, calls key APIs, and renders the results.

 <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>

You now have a Zoom App page that leverages the Zoom App SDK to access contextual data. Note that certain APIs return values only when the app is running in specific contexts, such as during a meeting.

Zoom Contact Center page

By default, the Zoom Contact Center app only appears during an active engagement. This script demonstrates how to use the Zoom App Contact Center API and local storage to track engagement notes.

Add the provided script to zcc-apps.html. It captures engagement data, listens for SDK events, and auto-saves user input. You can replace local storage with your own provider to persist data externally.

<script>
      (async () => {
        /** ---------- Tiny DOM helpers ---------- */
        const byId = (id) => document.getElementById(id);
        const setTextContent = (id, value) => { byId(id).textContent = value; };
        const setJSON = (id, obj) => { byId(id).textContent = (obj && Object.keys(obj).length) ? JSON.stringify(obj, null, 2) : "—"; };
        /** Build localStorage keys for a given engagement */
        const notesKeyForEngagement = (engagementId) => `zcc-notes:${engagementId}`;
        const surveyKeyForEngagement = (engagementId) => `zcc-survey:${engagementId}`;
        const logKeyForEngagement   = (engagementId) => `zcc-log:${engagementId}`;
        /** Safe localStorage wrapper */
        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 {} },
          getJSON(key, fallback) {
            try { const v = localStorage.getItem(key); return v ? JSON.parse(v) : fallback; } catch { return fallback; }
          },
          setJSON(key, obj) {
            try { localStorage.setItem(key, JSON.stringify(obj)); } catch {}
          }
        };
        /** Engagement activity logger (append-only array) */
        function logEvent(engagementId, type, payload) {
          if (!engagementId) return;
          const key = logKeyForEngagement(engagementId);
          const arr = storage.getJSON(key, []);
          arr.push({ ts: new Date().toISOString(), type, payload: payload ?? {} });
          storage.setJSON(key, arr);
        }
        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",
            "getAppVariableList",
            "getEngagementVariableValue",
            "onEngagementContextChange",
            "onEngagementStatusChange",
            "onEngagementVariableValueChange"
          ],
        });
        /** ---------- Initial values ---------- */
        const runningContext = await zoomSdk.getRunningContext();
        setTextContent(
          "running-context",
          typeof runningContext === "string" ? runningContext : JSON.stringify(runningContext)
        );
        let currentEngagementId = "";
        let currentEngagementState = "";
        let currentAppVariableNames = [];
        let currentEngagementValues = {};
        /** Helper: fetch app variable list and current values (UI only; no logging) */
        async function loadVariablesUI() {
          const appVarListResp = await zoomSdk.getAppVariableList().catch(() => null);
          const variables = appVarListResp?.variables || [];
          currentAppVariableNames = variables.map(v => v.name).filter(Boolean);
          setJSON("app-variables", variables);
          if (currentAppVariableNames.length) {
            const varValuesResp = await zoomSdk.getEngagementVariableValue({
              variableNames: currentAppVariableNames
            }).catch(() => null);
            const list = varValuesResp?.variables || [];
            currentEngagementValues = {};
            for (const item of list) currentEngagementValues[item.name] = item.value;
            setJSON("engagement-variables", currentEngagementValues);
          } else {
            currentEngagementValues = {};
            setJSON("engagement-variables", currentEngagementValues);
          }
        }
        /** Load context + status and hydrate UI 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 || "—");
          // Restore notes
          byId("notes-textarea").value = currentEngagementId
            ? (storage.get(notesKeyForEngagement(currentEngagementId)) || "")
            : "";
          // Restore survey selections
          const savedSurvey = currentEngagementId ? storage.getJSON(surveyKeyForEngagement(currentEngagementId), {}) : {};
          setRadio("q_satisfied", savedSurvey.q_satisfied);
          setRadio("q_survey", savedSurvey.q_survey);
          setRadio("q_followup", savedSurvey.q_followup);
          // Log load (no variable data logged)
          if (currentEngagementId) {
            logEvent(currentEngagementId, "engagement_context_loaded", { state: currentEngagementState });
          }
          // Update UI with variables (display only)
          await loadVariablesUI();
        }
        /** ---------- Notes auto-save (per engagement) ---------- */
        byId("notes-textarea").addEventListener("input", (e) => {
          if (!currentEngagementId) return;
          const val = e.target.value;
          storage.set(notesKeyForEngagement(currentEngagementId), val);
          logEvent(currentEngagementId, "notes_updated", { length: val.length });
        });
        /** ---------- Survey radios (per engagement) ---------- */
        function setRadio(name, value) {
          if (!value) return;
          const input = document.querySelector(`input[name="${name}"][value="${value}"]`);
          if (input) input.checked = true;
        }
        function handleRadioChange(e) {
          if (!currentEngagementId) return;
          const name = e.target.name;
          const value = e.target.value;
          const key = surveyKeyForEngagement(currentEngagementId);
          const current = storage.getJSON(key, {});
          current[name] = value;
          storage.setJSON(key, current);
          logEvent(currentEngagementId, "survey_updated", { [name]: value });
        }
        document.querySelectorAll('input[name="q_satisfied"]').forEach(i => i.addEventListener("change", handleRadioChange));
        document.querySelectorAll('input[name="q_survey"]').forEach(i => i.addEventListener("change", handleRadioChange));
        document.querySelectorAll('input[name="q_followup"]').forEach(i => i.addEventListener("change", handleRadioChange));
        /** ---------- Live updates from ZCC ---------- */
        function handleEngagementContextChange(evt) {
          const newId = evt?.engagementContext?.engagementId || "";
          if (newId && newId !== currentEngagementId) {
            logEvent(currentEngagementId, "engagement_switched", { to: newId });
          }
          currentEngagementId = newId;
          setTextContent("engagement-id", currentEngagementId || "—");
          // Restore per-engagement artifacts
          byId("notes-textarea").value = currentEngagementId
            ? (storage.get(notesKeyForEngagement(currentEngagementId)) || "")
            : "";
          const savedSurvey = currentEngagementId ? storage.getJSON(surveyKeyForEngagement(currentEngagementId), {}) : {};
          setRadio("q_satisfied", savedSurvey.q_satisfied);
          setRadio("q_survey", savedSurvey.q_survey);
          setRadio("q_followup", savedSurvey.q_followup);
          // Update UI variables (no logging)
          loadVariablesUI();
          logEvent(currentEngagementId, "engagement_context_changed", {});
        }
        function handleEngagementStatusChange(evt) {
          currentEngagementState = evt?.engagementStatus?.state || "";
          setTextContent("engagement-status", currentEngagementState || "—");
          logEvent(currentEngagementId, "status_changed", { state: currentEngagementState });
          // Clear saved data when engagement ends (keep the activity log)
          if (currentEngagementState === "end" && currentEngagementId) {
            storage.remove(notesKeyForEngagement(currentEngagementId));
            storage.remove(surveyKeyForEngagement(currentEngagementId));
            byId("notes-textarea").value = "";
          }
        }
        function handleEngagementVariableValueChange(evt) {
          // Update UI only; do not log variable data
          if (evt?.variables?.length) {
            const map = {};
            for (const { name, value } of evt.variables) map[name] = value;
            // Merge into current view map
            currentEngagementValues = { ...currentEngagementValues, ...map };
            setJSON("engagement-variables", currentEngagementValues);
          }
        }
        zoomSdk.addEventListener("onEngagementContextChange", handleEngagementContextChange);
        zoomSdk.addEventListener("onEngagementStatusChange", handleEngagementStatusChange);
        zoomSdk.addEventListener("onEngagementVariableValueChange", handleEngagementVariableValueChange);
        /** Kick off initial load */
        await loadEngagementInfo();
      })();
    </script>

Run the app

Run the app by executing the following command:

$ node server.js

Once the app is running, you can access it from the Zoom desktop client by navigating to the Contact Center interface. Make sure:

  • Your agent status is set to available

  • All relevant queues and routes are active

After that, initiate a test call to your Zoom global number. When the agent answers the call, the Zoom Contact Center app will automatically appear in the right-side panel of the interface.

What's next?

You’ve now built a Zoom Contact Center integration. Explore the Zoom Contact Center API endpoints and events to integrate Contact Center features directly into your application. For CRM integration and more, visit the Zoom Contact Center support page.