# Zoom Meetings

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

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](/api/tracker). For Zoom Contact Center, see [other sources](/integrations/other-sources).

## 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](/api/tracker#your-api-key).

## 1. Get API access in Zoom

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](https://support.zoom.com/hc/en/article?id=zm_kb&sysparm_article=KB0064927).
2. 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**.
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](https://developers.zoom.us/docs/internal-apps/s2s-oauth/)).

## 2. Pull new recordings

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

```bash
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](https://developers.zoom.us/docs/api/meetings/) 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](https://developers.zoom.us/docs/api/meetings/) (`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](https://developers.zoom.us/docs/api/rate-limits/)).

## 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](/data/pii-redaction).

## 4. Send it

```js
// 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.



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

Save the code as `zoom-meetings.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 { 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](/dimensions/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](/transform/recipe-call-transcripts)), check it with the [file checker](/data/check-a-file) and [upload it](/data/upload-a-file), then run a backfill.

## Zoom reference

- [Using audio transcription for cloud recordings](https://support.zoom.com/hc/en/article?id=zm_kb&sysparm_article=KB0064927)
- [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/)
- [Meetings API: List all recordings, Get past meeting participants](https://developers.zoom.us/docs/api/meetings/)
- [Users API: List users](https://developers.zoom.us/docs/api/users/)
- [Rate limits](https://developers.zoom.us/docs/api/rate-limits/)

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