02 Get your data in /Integrations
Zoom Meetings
Send Zoom Meetings cloud recording transcripts to Dimension Labs with the universal API.
- ZM Zoom MeetingsTickets, chats, calls, reviews
- ⟳ Your jobPull new · map fields · every 15 min
- { } Universal APIPOST /track · /trackhistorical
- 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.
What you need
Section titled “What you need”- 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. Get API access in Zoom
Section titled “1. Get API access in Zoom”- 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.
- 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, add
user:read:list_users:admin,cloud_recording:read:list_user_recordings:adminandmeeting:read:list_past_participants:admin. You need admin-level role permissions to add admin scopes. - 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).
2. Pull new recordings
Section titled “2. Pull new recordings”Cloud recordings are listed per user, so list your users first, then each user’s recordings:
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 withuuid,start_timeandrecording_files[]. The transcript is the file withfile_typeTRANSCRIPT: a WebVTT file at itsdownload_url. Download it with the same Bearer token. fromandtoare 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_tokenwhile there are more results. It expires after 15 minutes. - Get past meeting participants (
GET /past_meetings/{meetingUUID}/participants) returns each participant’sname,user_email,idandinternal_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).
3. Map the fields
Section titled “3. Map the fields”| Dimension Labs | Zoom | Notes |
|---|---|---|
text | each cue of the TRANSCRIPT file | When 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. |
userId | participant id, else a hash | The 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. |
incoming | participant internal_user | false 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. |
sessionId | meeting uuid | Each meeting instance has its own uuid. The code converts it to hex, so it has no /, + or =, and prefixes it: zoom-.... |
conversationId | meeting uuid | Same value as sessionId. |
dimensionlabs_timestamp | transcript file recording_start plus the cue’s start time | Cue times count from the start of the recording. Cues closer than a second apart are spaced to 1,000 ms. |
platformJson | meeting id, type, duration; speakerMatched, internal | id 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.
4. Send it
Section titled “4. Send it”// 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;}5. Run it
Section titled “5. Run it”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-01sends 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_HOURSold. 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.mjswith 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.
Zoom reference
Section titled “Zoom reference”- Using audio transcription for cloud recordings
- Create a Server-to-Server OAuth app
- Server-to-Server OAuth
- Meetings API: List all recordings, Get past meeting participants
- Users API: List users
- Rate limits
Checked against Zoom’s documentation on 2026-10-26.