Skip to content

02 Get your data in /Integrations

Zoom Meetings

Send Zoom Meetings cloud recording transcripts to Dimension Labs with the universal API.

  1. ZM Zoom MeetingsTickets, 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 the audio transcripts of your Zoom cloud recordings, one conversation per meeting, turn by turn: what your prospects and customers said, and what your own team said. You run it on your side: a small script pulls from the Zoom API and posts each turn to the universal API. For Zoom Contact Center, see other sources.

  • A Zoom account on a Pro plan or higher, with cloud recording and Create audio transcript turned on. Only meetings recorded to the cloud with this setting on have a transcript.
  • A Zoom admin who can create a Server-to-Server OAuth app with admin scopes.
  • The email domains your company uses, for example acme.com, as a fallback for telling your team apart.
  • A Dimension Labs API key for a new Universal API source. See Your API key.
  1. In the Zoom web portal, as an admin, go to Admin Center > Settings > Recording & Transcript. Make sure cloud recording is on, and under Advanced cloud recording settings select Create audio transcript. Save. Zoom’s steps are in Using audio transcription for cloud recordings.
  2. 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.
  3. Under App credentials, copy the Account ID, Client ID and Client secret.
  4. Under Scopes, add user:read:list_users:admin, cloud_recording:read:list_user_recordings:admin and meeting:read:list_past_participants:admin. You need admin-level role permissions to add admin scopes.
  5. Under Activation, activate the app. You can’t get a token until it is active.

Get a token by posting grant_type=account_credentials and your account_id to https://zoom.us/oauth/token, with the client ID and secret as Basic auth. The token lasts one hour and there is no refresh token: request a new one (Server-to-Server OAuth).

Cloud recordings are listed per user, so list your users first, then each user’s recordings:

Terminal window
curl -H "Authorization: Bearer $ZOOM_TOKEN" "https://api.zoom.us/v2/users?status=active"
curl -H "Authorization: Bearer $ZOOM_TOKEN" "https://api.zoom.us/v2/users/USER_ID/recordings?from=2026-10-01&to=2026-10-26"
  • List all recordings returns meetings[], each with uuid, start_time and recording_files[]. The transcript is the file with file_type TRANSCRIPT: a WebVTT file at its download_url. Download it with the same Bearer token.
  • from and to are UTC dates (yyyy-mm-dd), at most a month apart. The code walks longer ranges a month at a time.
  • Each list returns a next_page_token while there are more results. It expires after 15 minutes.
  • Get past meeting participants (GET /past_meetings/{meetingUUID}/participants) returns each participant’s name, user_email, id and internal_user. A UUID that starts with / or contains // must be encoded twice. Participant data is kept for 15 months.
  • A transcript exists only after the meeting ends and Zoom has processed the recording. The code waits ZOOM_DELAY_HOURS (3 by default) after a meeting starts before it reads it. Zoom doesn’t publish how long processing takes: if recent meetings arrive without turns, raise the delay.

Rate limits, per Zoom, depend on your plan and are shared by every app on the account. These calls are labeled MEDIUM: 20 requests per second on Pro, 60 on Business and higher. Going over returns 429 (rate limits).

Dimension LabsZoomNotes
texteach cue of the TRANSCRIPT fileWhen a cue starts with a participant’s display name and a colon, the code reads that as the speaker and drops the name from the text.
userIdparticipant id, else a hashThe participant’s id is their Zoom user id when they signed in. Otherwise the code sends a hash of the meeting and the name, never the name itself. Cues with no matching speaker use the sessionId.
incomingparticipant internal_userfalse for your team: internal_user is true, or the email domain is in COMPANY_DOMAINS. Everyone else, and cues with no matching speaker, are incoming.
sessionIdmeeting uuidEach meeting instance has its own uuid. The code converts it to hex, so it has no /, + or =, and prefixes it: zoom-....
conversationIdmeeting uuidSame value as sessionId.
dimensionlabs_timestamptranscript file recording_start plus the cue’s start timeCue times count from the start of the recording. Cues closer than a second apart are spaced to 1,000 ms.
platformJsonmeeting id, type, duration; speakerMatched, internalid is the meeting number, shared by every instance of a recurring meeting.

Zoom tells your team apart with internal_user on each participant. Zoom also returns user_email only for participants in the host’s account, with some exceptions, so a domain check on it is a safe fallback.

Personal data. Participant names and email addresses are used only to match speakers and are never sent. Leave out the meeting topic, which often names the customer. Transcripts hold names, companies and numbers: redact them before you send. See PII redaction.

// zoom-meetings.mjs. Environment: ZOOM_ACCOUNT_ID, ZOOM_CLIENT_ID, ZOOM_CLIENT_SECRET,
// COMPANY_DOMAINS (for example "acme.com,acme.co.uk"), ZOOM_DELAY_HOURS (default 3), DL_API_KEY.
import { createHash } from 'node:crypto';
import { sendToDimensionLabs, toMillis } from './dimensionlabs.mjs';
const HOUR = 60 * 60 * 1000;
const DAY = 24 * HOUR;
const API = 'https://api.zoom.us/v2';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const DELAY = Number(process.env.ZOOM_DELAY_HOURS ?? 3) * HOUR;
const DOMAINS = (process.env.COMPANY_DOMAINS ?? '').toLowerCase().split(',').map((d) => d.trim()).filter(Boolean);
const hash = (s) => createHash('sha256').update(s.trim().toLowerCase()).digest('hex').slice(0, 32);
let token, tokenExpires = 0;
// Server-to-Server OAuth: a one-hour token, requested again when it runs out.
async function bearer() {
if (Date.now() < tokenExpires) return token;
const basic = Buffer.from(`${process.env.ZOOM_CLIENT_ID}:${process.env.ZOOM_CLIENT_SECRET}`).toString('base64');
const body = new URLSearchParams({ grant_type: 'account_credentials', account_id: process.env.ZOOM_ACCOUNT_ID });
const res = await fetch('https://zoom.us/oauth/token', { method: 'POST', headers: { Authorization: `Basic ${basic}` }, body });
if (!res.ok) throw new Error(`Zoom token request returned ${res.status}: ${await res.text()}`);
const t = await res.json();
token = t.access_token;
tokenExpires = Date.now() + (t.expires_in - 60) * 1000;
return token;
}
// Retries Zoom's rate limits (429) and server errors (5xx), honoring Retry-After. With optional, other 4xx return null.
async function get(url, { text = false, optional = false } = {}) {
for (let attempt = 0; ; attempt++) {
const res = await fetch(url, { headers: { Authorization: `Bearer ${await bearer()}` } });
if (res.ok) return text ? res.text() : res.json();
if (optional && res.status !== 429 && res.status < 500) return null;
if ((res.status !== 429 && res.status < 500) || attempt === 5) throw new Error(`Zoom returned ${res.status}: ${await res.text()}`);
await sleep((Number(res.headers.get('retry-after')) || 2 ** attempt) * 1000);
}
}
async function list(path, key, optional = false) {
const items = [];
for (let next = ''; ; ) {
const sep = path.includes('?') ? '&' : '?';
const page = await get(`${API}${path}${next ? `${sep}next_page_token=${encodeURIComponent(next)}` : ''}`, { optional });
items.push(...(page?.[key] ?? []));
next = page?.next_page_token;
if (!next) return items;
}
}
// A UUID that starts with "/" or contains "//" is encoded twice.
const encodeUuid = (u) => (u.startsWith('/') || u.includes('//') ? encodeURIComponent(encodeURIComponent(u)) : encodeURIComponent(u));
// WebVTT cues: start time in ms from the start of the recording, and the text.
function cues(vtt) {
const out = [];
for (const block of vtt.replace(/\r/g, '').split(/\n\n+/)) {
const lines = block.split('\n');
const i = lines.findIndex((l) => l.includes('-->'));
if (i < 0) continue;
const offset = lines[i].split('-->')[0].trim().split(':').reduce((a, p) => a * 60 + Number(p), 0) * 1000;
const text = lines.slice(i + 1).join(' ').trim();
if (text) out.push({ offset, text });
}
return out;
}
const isInternal = (p) => p.internal_user === true || DOMAINS.includes(p.user_email?.split('@')[1]?.toLowerCase());
// Sends every meeting that started at or after `since` (Unix ms) and at least ZOOM_DELAY_HOURS ago. Returns the new `since`.
export async function syncZoom({ since, historical = false }) {
const until = Date.now() - DELAY;
if (until <= since) return since;
const meetings = new Map();
for (const user of await list('/users?status=active', 'users')) {
for (let from = since; from < until; from += 30 * DAY) {
const day = (t) => new Date(t).toISOString().slice(0, 10);
const path = `/users/${encodeURIComponent(user.id)}/recordings?from=${day(from)}&to=${day(Math.min(from + 30 * DAY, until))}`;
for (const m of await list(path, 'meetings')) {
const start = toMillis(m.start_time);
if (start >= since && start < until) meetings.set(m.uuid, m);
}
}
}
for (const m of meetings.values()) {
const file = (m.recording_files ?? []).find((f) => f.file_type === 'TRANSCRIPT' && f.download_url);
if (!file) continue;
const session = `zoom-${Buffer.from(m.uuid, 'base64').toString('hex')}`;
const people = (await list(`/past_meetings/${encodeUuid(m.uuid)}/participants`, 'participants', true)).filter((p) => p.name);
const names = [...new Map(people.map((p) => [p.name, p])).values()].sort((a, b) => b.name.length - a.name.length);
const startedAt = toMillis(file.recording_start ?? m.start_time);
let last = 0;
for (const cue of cues(await get(file.download_url, { text: true }))) {
const speaker = names.find((p) => cue.text.startsWith(`${p.name}:`));
const text = speaker ? cue.text.slice(speaker.name.length + 1).trim() : cue.text;
if (!text) continue;
const ts = Math.max(startedAt + cue.offset, last + 1000); // keep turns at least a second apart
last = ts;
const internal = speaker ? isInternal(speaker) : false;
await sendToDimensionLabs({
text,
userId: speaker ? `zoom-${speaker.id || hash(`${m.uuid}-${speaker.name}`)}` : session,
incoming: !internal,
sessionId: session,
conversationId: session,
dimensionlabs_timestamp: ts,
platformJson: { meetingId: m.id, meetingType: m.type, durationMinutes: m.duration, speakerMatched: Boolean(speaker), internal },
}, { historical: historical || ts < Date.now() - HOUR });
}
}
return until;
}

Save the code as zoom-meetings.mjs in a project set up as in Set up your project, and run it with the scheduled sync runner: import { syncZoom as sync } from './zoom-meetings.mjs';.

  • History first: node run.mjs 2025-01-01 sends every transcribed meeting since that date to /trackhistorical. Then start a backfill from the Backfill tab. Recordings that were deleted from the cloud are gone, and participant data only goes back 15 months: older meetings still come through, with every turn incoming.
  • Then every 15 minutes: the scheduled job sends meetings once they are ZOOM_DELAY_HOURS old. By then every turn is more than an hour old, so it goes to /trackhistorical: start a backfill whenever you want the latest meetings enriched.
  • Re-runs are safe: duplicates are removed automatically. If a transcript was late, run node run.mjs with a date before the meeting to pick it up.

Each run lists recordings for every active user, so a large account makes many calls; the code waits whenever Zoom asks it to. You can also download the transcripts yourself, turn them into a file (see Call transcripts), check it with the file checker and upload it, then run a backfill.

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