# Zoom Contact Center

> Send call transcripts from Zoom Contact Center to Dimension Labs with the universal API.

This brings in Zoom Contact Center call transcripts: the transcripts of recorded voice and video engagements, split into turns when the transcript names its speakers. One engagement becomes one conversation in Dimension Labs. You run it on your side: a small script pulls from the Zoom Contact Center API and posts each message to the [universal API](/api/tracker).

## What you need

- Call recording with transcription in Zoom Contact Center. In the Contact Center preferences, on the **Account** tab, under **Voice and Video Engagements**, turn on **Automatic Call Recording** and **Allow Call Recording Transcription**. A call that wasn't recorded and transcribed has no transcript. Zoom's recording APIs cover recordings created after July 18, 2022.
- A Server-to-Server OAuth app in the Zoom App Marketplace, with these scopes:
  - `contact_center_recording:read:admin` (granular: `contact_center:read:list_recordings:admin`), to list recordings and download their transcripts.
  - `contact_center_report:read:admin` (granular: `contact_center:read:engagement:admin`), to read each engagement's agents and customer id.
- A Dimension Labs API key for a new **Universal API** source. See [Your API key](/api/tracker#your-api-key).

## 1. Get API access in Zoom Contact Center

Zoom's steps are in [Create a Server-to-Server OAuth app](https://developers.zoom.us/docs/internal-apps/create/).

1. Sign in to the [Zoom App Marketplace](https://marketplace.zoom.us/), select **Developers**, then the plus sign next to **Developer**, and **Build an app**. Choose **Server-to-Server OAuth app** and click **Create**.
2. Under **App credentials**, copy the account ID, client ID and client secret.
3. Under **Scopes**, choose **Add Scopes** and add the scopes above. You need admin-level role permissions to add admin scopes.
4. Fill in the information Zoom asks for, and activate the app. You can't get a token until the app is active.
5. Get a token: `POST https://zoom.us/oauth/token` with the body `grant_type=account_credentials&account_id=YOUR_ACCOUNT_ID` and the header `Authorization: Basic BASE64(client_id:client_secret)`. The token lasts one hour, and there is no refresh token: request a new one. Send it as `Authorization: Bearer YOUR_TOKEN`.

## 2. Pull new transcripts

List recordings by when they ended, then download each recording's transcript:

```bash
curl -H "Authorization: Bearer $ZOOM_TOKEN" \
  "https://api.zoom.us/v2/contact_center/recordings?query_date_type=recording_end_time&from=2026-10-25T00:00:00Z&to=2026-10-26T00:00:00Z"

curl -L -H "Authorization: Bearer $ZOOM_TOKEN" "TRANSCRIPT_URL"
```

- `query_date_type=recording_end_time` filters by when the recording ended. `from` and `to` use `yyyy-MM-dd'T'HH:mm:ss'Z'`, and one request covers at most a month. The code asks for a week at a time.
- Each response has a `next_page_token`. Send it back as `next_page_token` for the next page, within 15 minutes.
- Each recording has `engagement_id`, `recording_start_time`, `recording_end_time`, `channel` (`voice` or `video`), the agent's `user_id` and `display_name`, and `transcript_url`. The code also gets the engagement (`GET /contact_center/engagements/{engagementId}`) for its `agents` and the customer's `consumer_id`.
- `transcript_url` is not a public link: download it with your token. Zoom notes it may redirect, so follow redirects (`curl -L`).
- Zoom also sends a `contact_center.recording_transcript_completed` webhook with the `transcript_url`, if you'd rather react to events than poll.

**What Zoom documents about the transcript, and what it doesn't.** Zoom's API reference documents the `transcript_url` field, the token it needs and the redirect, and calls the result a "transcript text file". It doesn't document the file's format. A Zoom staff reply on the [developer forum](https://devforum.zoom.us/t/contact-center-call-transcripts/137746) says Contact Center transcripts are WebVTT (`.vtt`) only. Zoom doesn't say how speakers are shown in the file, whether they are shown at all, or whether a speaker's name matches the agent's `display_name`.

So download one transcript with the second command above and look at it before you run the code:

- If cues carry a speaker, as `<v Name>` or `Name: text`, the code splits the call into turns. A speaker whose name matches one of the engagement's agents is your side; everyone else is the customer. If your agents appear under other names (a bot, a flow, a shortened name), add them to `COMPANY_SPEAKERS`.
- If no agent name shows up, the code sends the whole transcript as one message. See [Transcripts in one block](/integrations/transcripts) for what that means for your analysis.

## 3. Map the fields

| Dimension Labs | Zoom Contact Center | Notes |
|---|---|---|
| `text` | the turns of the transcript file | Split with `splitTranscript` from [Transcripts in one block](/integrations/transcripts). Without speaker names, the whole file is one message. |
| `userId` | engagement `consumers[].consumer_id` | Zoom's id for the customer. If the engagement has none, the code uses the `sessionId`. |
| `incoming` | the cue's speaker | `false` when the speaker is one of the engagement's agents (`agents[].display_name`, the recording's `display_name`, or a name in `COMPANY_SPEAKERS`), `true` otherwise. A transcript sent as one message is `true`. |
| `sessionId` | `engagement_id` | One engagement is one conversation in Dimension Labs, across all of its recordings. |
| `conversationId` | `engagement_id` | Same value as `sessionId`. |
| `dimensionlabs_timestamp` | `recording_start_time` plus the cue's start | Cue times are offsets into the recording. Turns closer than a second apart are spaced to 1,000 ms. |
| `platformJson` | `channel`, `direction`, `queue_name`, `flow_name`, `recording_type`, disposition names | Leave out `display_name`, `user_email`, `consumer_name`, `consumer_number` and the conference participants' names and numbers. |

**Personal data.** Call transcripts hold names, phone numbers, account numbers and addresses. Speaker names in the file are only used to decide the direction, and are never sent. Zoom Contact Center has a **Redact Personal Data** setting for call recordings, transcripts and messaging, under **Privacy and Data Retention** in the preferences; use it, or redact before you send. See [PII redaction](/data/pii-redaction).

## 4. Send it

```js
// zoom-contact-center.mjs. Environment: ZOOM_ACCOUNT_ID, ZOOM_CLIENT_ID, ZOOM_CLIENT_SECRET,
// COMPANY_SPEAKERS (optional: other names your side uses in transcripts, comma-separated),
// ZOOM_CC_DELAY_MINUTES (default 60), DL_API_KEY.


const HOUR = 60 * 60 * 1000;
const WEEK = 7 * 24 * HOUR;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const API = 'https://api.zoom.us/v2';
const DELAY = Number(process.env.ZOOM_CC_DELAY_MINUTES ?? 60) * 60 * 1000;
const EXTRA = (process.env.COMPANY_SPEAKERS ?? '').split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
const zoomTime = (ms) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z'); // yyyy-MM-ddTHH:mm:ssZ
let token;

// From /integrations/transcripts: split one block of transcript text into turns.
// isCompany(speaker) returns true for your agent, bot or staff, false for the customer.
// isSpeaker(label), optional: false for a label that isn't a speaker ("Note: ..."), so the line continues the turn.
// Returns [{ speaker, incoming, text, offsetMs }] in order. offsetMs is null when the block has no times.
export function splitTranscript(block, isCompany, isSpeaker = () => true) {
  const turns = [];
  let cueStart = null;
  const toMs = (h, m, s, frac = '0') => ((Number(h || 0) * 60 + Number(m)) * 60 + Number(s)) * 1000 + Number(frac.padEnd(3, '0').slice(0, 3));
  for (const raw of String(block).split(/\r?\n/)) {
    const line = raw.trim();
    if (!line || line === 'WEBVTT' || /^\d+$/.test(line) || /^NOTE\b/.test(line)) continue;
    const cue = line.match(/^(?:(\d+):)?(\d{1,2}):(\d{2})[.,](\d{1,3})\s*-->/);   // VTT or SRT cue timing
    if (cue) { cueStart = toMs(cue[1], cue[2], cue[3], cue[4]); continue; }
    let rest = line, offsetMs = cueStart;
    const stamp = rest.match(/^\[?(?:(\d+):)?(\d{1,2}):(\d{2})\]?\s+/);           // [00:00:09] or 00:09
    if (stamp) { offsetMs = toMs(stamp[1], stamp[2], stamp[3]); rest = rest.slice(stamp[0].length); }
    const voice = rest.match(/^<v(?:\.[^ >]*)?\s+([^>]+)>\s*/);                    // VTT voice tag
    let label = !voice && rest.match(/^([^:]{1,60}):\s+/);                          // "Agent: " or "Sam Rivera: "
    if (label && !isSpeaker(label[1].trim())) label = null;
    const speaker = voice ? voice[1].trim() : label ? label[1].trim() : null;
    const text = rest.slice(voice ? voice[0].length : label ? label[0].length : 0).replace(/<\/?[^>]+>/g, '').trim();
    if (!text) continue;
    const last = turns[turns.length - 1];
    if (speaker === null || (last && last.speaker === speaker)) {
      if (last) last.text += ' ' + text;   // a line with no speaker, or the same speaker again, continues the turn
      continue;                            // text before the first speaker is skipped
    }
    turns.push({ speaker, incoming: !isCompany(speaker), text, offsetMs });
    cueStart = null;
  }
  return turns;
}

async function newToken() {
  const basic = Buffer.from(`${process.env.ZOOM_CLIENT_ID}:${process.env.ZOOM_CLIENT_SECRET}`).toString('base64');
  const res = await fetch('https://zoom.us/oauth/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded', Authorization: `Basic ${basic}` },
    body: new URLSearchParams({ grant_type: 'account_credentials', account_id: process.env.ZOOM_ACCOUNT_ID }),
  });
  if (!res.ok) throw new Error(`Zoom token request returned ${res.status}: ${await res.text()}`);
  token = (await res.json()).access_token;
}

// Gets a fresh token on 401, and retries rate limits (429) and server errors (5xx). Returns null on 404.
// Follows redirects, which transcript downloads can use.
async function get(url, as = 'json') {
  for (let attempt = 0; ; attempt++) {
    if (!token) await newToken();
    const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
    if (res.ok) return as === 'text' ? res.text() : res.json();
    if (res.status === 404) return null;
    if (res.status === 401 && attempt === 0) { token = null; continue; }
    if ((res.status !== 429 && res.status < 500) || attempt === 5) throw new Error(`Zoom returned ${res.status} for ${url}: ${await res.text()}`);
    await sleep((Number(res.headers.get('retry-after')) || 2 ** attempt) * 1000);
  }
}

// Recordings that ended between `since` and `until`, a week at a time.
async function* endedRecordings(since, until) {
  for (let from = since; from < until; from += WEEK) {
    const to = Math.min(from + WEEK, until);
    let pageToken = '';
    do {
      const q = new URLSearchParams({ query_date_type: 'recording_end_time', from: zoomTime(from), to: zoomTime(to) });
      if (pageToken) q.set('next_page_token', pageToken);
      const page = await get(`${API}/contact_center/recordings?${q}`);
      for (const r of page?.recordings ?? []) {
        const end = toMillis(r.recording_end_time);
        if (end >= from && end < to) yield r;
      }
      pageToken = page?.next_page_token;
    } while (pageToken);
  }
}

// Sends the transcript of every recording that ended at or after `since` (Unix ms). Returns the new `since`.
export async function syncZoomContactCenter({ since, historical = false }) {
  token = null; // a new token for each run
  const until = Date.now() - DELAY; // give Zoom time to finish the transcript
  const engagements = new Map();
  let newest = since;
  for await (const r of endedRecordings(since, until)) {
    const end = toMillis(r.recording_end_time);
    const block = r.transcript_url ? await get(r.transcript_url, 'text') : null;
    if (block?.trim() && r.engagement_id) {
      if (!engagements.has(r.engagement_id)) {
        engagements.set(r.engagement_id, await get(`${API}/contact_center/engagements/${encodeURIComponent(r.engagement_id)}`));
      }
      const e = engagements.get(r.engagement_id) ?? {};
      // Your side: the engagement's agents, the recording's user and agent participants, and COMPANY_SPEAKERS.
      const team = new Set([
        ...(e.agents ?? []).map((a) => a.display_name),
        r.owner_type === 'user' ? r.display_name : null,
        ...(r.conference_participant ?? []).filter((p) => p.participant_type === 'agent' || p.participant_type === 'supervisor').map((p) => p.user_name),
        ...EXTRA,
      ].filter(Boolean).map((s) => s.trim().toLowerCase()));
      const isCompany = (s) => team.has(s.toLowerCase());

      const sessionId = `zoom-cc-${r.engagement_id}`.replace(/:/g, '-');
      const consumerId = (e.consumers ?? []).find((c) => c.consumer_id)?.consumer_id;
      const userId = consumerId ? `zoom-cc-${consumerId}`.replace(/:/g, '-') : sessionId;
      const platformJson = {
        channel: r.channel ?? r.channel_type, direction: r.direction ?? e.direction, queue: r.queue_name, flow: r.flow_name,
        recordingType: r.recording_type, dispositions: (e.dispositions ?? []).map((d) => d.disposition_name),
      };
      const start = toMillis(r.recording_start_time);
      const turns = splitTranscript(block, isCompany);

      if (turns.some((t) => !t.incoming)) {
        // The transcript names its speakers and your agent is among them: one message per turn.
        let last = 0;
        for (const turn of turns) {
          const ts = Math.max(start + (turn.offsetMs ?? 0), last + 1000); // keep turns at least a second apart
          last = ts;
          await sendToDimensionLabs({
            text: turn.text,
            userId,
            incoming: turn.incoming,
            sessionId,
            conversationId: sessionId,
            dimensionlabs_timestamp: ts,
            platformJson: { ...platformJson, speaker_role: turn.incoming ? 'customer' : 'company' },
          }, { historical: historical || ts < Date.now() - HOUR });
        }
      } else {
        // No speaker names, or none that match your side: send the whole transcript as one message.
        // Built from the turns when there are any, so speaker names stay out of the text.
        const text = (turns.length ? turns.map((t) => t.text).join(' ') : block.replace(/^WEBVTT.*$/m, '').split(/\r?\n/)
          .filter((l) => !/^\d+$/.test(l.trim()) && !/-->/.test(l) && !/^NOTE\b/.test(l.trim())).join(' '))
          .replace(/<\/?[^>]+>/g, '').replace(/\s+/g, ' ').trim();
        if (text) {
          await sendToDimensionLabs({
            text,
            userId,
            incoming: true, // it holds the customer's words
            sessionId,
            conversationId: sessionId,
            dimensionlabs_timestamp: start,
            platformJson: { ...platformJson, transcript: 'unsplit' },
          }, { historical: historical || start < Date.now() - HOUR });
        }
      }
    }
    newest = Math.max(newest, end);
  }
  return newest;
}
```

A transcript sent as one message has the cue numbers, times, tags and speaker names taken out, and is marked `transcript: 'unsplit'` so you can filter it. If a few calls come through unsplit and most don't, compare their speaker names with your agents' names and add the missing ones to `COMPANY_SPEAKERS`.

This guide covers calls. Messaging engagements (web chat, SMS, WhatsApp and others) have their own `transcript_url` on the engagement, and Zoom doesn't document that file's format either. If you want them, download one, check it against [Transcripts in one block](/integrations/transcripts), and send it the same way.

## 5. Run it

Save the code as `zoom-contact-center.mjs` in a project set up as in [Set up your project](/api/tracker#set-up-your-project), and run it with the [scheduled sync](/api/scheduled-sync) runner: `import { syncZoomContactCenter as sync } from './zoom-contact-center.mjs';`.

- **History first:** `node run.mjs 2025-01-01` sends the transcript of every recording that ended since that date to `/trackhistorical`. Then start a [backfill](/dimensions/backfill) from the **Backfill** tab. Only recordings your data retention settings still keep have a transcript.
- **Then every 15 minutes:** the scheduled job sends new transcripts to `/track`, and they are enriched automatically every day. The code waits `ZOOM_CC_DELAY_MINUTES` (60 by default) after a recording ends, so its transcript is ready. Turns more than an hour old by then go to `/trackhistorical`, and need a backfill. If transcripts show up later than that in your account, raise the delay.
- **Re-runs are safe:** duplicates are removed automatically.
- **Rate limits:** Zoom rate limits Contact Center APIs by label and plan. Listing recordings is a Medium API (10 requests a second on Pro, 20 on Business and up), and getting an engagement is a Light API (20 and 40 a second). The code waits whenever Zoom returns `429`.

You can also [upload a file](/data/upload-a-file) built from downloaded transcripts (see [Turn call transcripts into turns](/transform/recipe-call-transcripts)) and check it first with the [file checker](/data/check-a-file), then run a backfill.

## Zoom Contact Center reference

- [Contact Center API](https://developers.zoom.us/docs/api/contact-center/)
- [Contact Center webhook events](https://developers.zoom.us/docs/api/contact-center/events/)
- [Create a Server-to-Server OAuth app](https://developers.zoom.us/docs/internal-apps/create/)
- [Server-to-Server OAuth](https://developers.zoom.us/docs/internal-apps/s2s-oauth/)
- [Rate limits](https://developers.zoom.us/docs/api/rate-limits/)
- [Zoom Contact Center preferences](https://library.zoom.com/business-services/zoom-contact-center/zoom-customer-experience/admin-experience/preferences)
- [Integrate engagement data](https://library.zoom.com/business-services/zoom-contact-center/expert-insights/integrate-engagement-data)
- [Contact Center call transcripts (developer forum)](https://devforum.zoom.us/t/contact-center-call-transcripts/137746)

Checked against Zoom Contact Center's documentation on 2026-10-26.
