02 Get your data in /Integrations
Zoom Contact Center
Send call transcripts from Zoom Contact Center to Dimension Labs with the universal API.
- ZC Zoom Contact CenterTickets, chats, calls, reviews
- ⟳ Your jobPull new · map fields · every 15 min
- { } Universal APIPOST /track · /trackhistorical
- Dimension LabsRead in full · enriched daily
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.
What you need
Section titled “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.
1. Get API access in Zoom Contact Center
Section titled “1. Get API access in Zoom Contact Center”Zoom’s steps are in Create a Server-to-Server OAuth app.
- Sign in to the Zoom App Marketplace, select Developers, then the plus sign next to Developer, and Build an app. Choose Server-to-Server OAuth app and click Create.
- Under App credentials, copy the account ID, client ID and client secret.
- Under Scopes, choose Add Scopes and add the scopes above. You need admin-level role permissions to add admin scopes.
- Fill in the information Zoom asks for, and activate the app. You can’t get a token until the app is active.
- Get a token:
POST https://zoom.us/oauth/tokenwith the bodygrant_type=account_credentials&account_id=YOUR_ACCOUNT_IDand the headerAuthorization: Basic BASE64(client_id:client_secret). The token lasts one hour, and there is no refresh token: request a new one. Send it asAuthorization: Bearer YOUR_TOKEN.
2. Pull new transcripts
Section titled “2. Pull new transcripts”List recordings by when they ended, then download each recording’s transcript:
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_timefilters by when the recording ended.fromandtouseyyyy-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 asnext_page_tokenfor the next page, within 15 minutes. - Each recording has
engagement_id,recording_start_time,recording_end_time,channel(voiceorvideo), the agent’suser_idanddisplay_name, andtranscript_url. The code also gets the engagement (GET /contact_center/engagements/{engagementId}) for itsagentsand the customer’sconsumer_id. transcript_urlis 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_completedwebhook with thetranscript_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 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>orName: 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 toCOMPANY_SPEAKERS. - If no agent name shows up, the code sends the whole transcript as one message. See Transcripts in one block for what that means for your analysis.
3. Map the fields
Section titled “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. 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.
4. Send it
Section titled “4. Send it”// 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.import { sendToDimensionLabs, toMillis } from './dimensionlabs.mjs';
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:ssZlet 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, and send it the same way.
5. Run it
Section titled “5. Run it”Save the code as zoom-contact-center.mjs in a project set up as in Set up your project, and run it with the scheduled sync runner: import { syncZoomContactCenter as sync } from './zoom-contact-center.mjs';.
- History first:
node run.mjs 2025-01-01sends the transcript of every recording that ended since that date to/trackhistorical. Then start a 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 waitsZOOM_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 built from downloaded transcripts (see Turn call transcripts into turns) and check it first with the file checker, then run a backfill.
Zoom Contact Center reference
Section titled “Zoom Contact Center reference”- Contact Center API
- Contact Center webhook events
- Create a Server-to-Server OAuth app
- Server-to-Server OAuth
- Rate limits
- Zoom Contact Center preferences
- Integrate engagement data
- Contact Center call transcripts (developer forum)
Checked against Zoom Contact Center’s documentation on 2026-10-26.