Skip to content

02 Get your data in /Integrations

Zoom Contact Center

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

  1. ZC Zoom Contact CenterTickets, chats, calls, reviews
  2. ⟳ Your jobPull new · map fields · every 15 min
  3. { } Universal APIPOST /track · /trackhistorical
  4. 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.

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

Zoom’s steps are in Create a Server-to-Server OAuth app.

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

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

Terminal window
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 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 for what that means for your analysis.
Dimension LabsZoom Contact CenterNotes
textthe turns of the transcript fileSplit with splitTranscript from Transcripts in one block. Without speaker names, the whole file is one message.
userIdengagement consumers[].consumer_idZoom’s id for the customer. If the engagement has none, the code uses the sessionId.
incomingthe cue’s speakerfalse 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.
sessionIdengagement_idOne engagement is one conversation in Dimension Labs, across all of its recordings.
conversationIdengagement_idSame value as sessionId.
dimensionlabs_timestamprecording_start_time plus the cue’s startCue times are offsets into the recording. Turns closer than a second apart are spaced to 1,000 ms.
platformJsonchannel, direction, queue_name, flow_name, recording_type, disposition namesLeave 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.

// 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: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, and send it the same way.

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-01 sends 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 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 built from downloaded transcripts (see Turn call transcripts into turns) and check it first with the file checker, then run a backfill.

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