Skip to content

Commit 2982797

Browse files
github-actions[bot]examples-botlukeocodes
authored
[Example] 180 — Zoom Cloud Recording Transcription (Node.js) (#94)
## New example: Zoom Cloud Recording Transcription (Node.js) <!-- metadata type: example number: 180 slug: zoom-recording-transcription-node language: node products: stt integrations: zoom --> **Integration:** Zoom | **Language:** Node.js | **Products:** STT (pre-recorded) ### What this shows A Node.js/Express server that receives Zoom `recording.completed` webhook events, authenticates with Zoom Server-to-Server OAuth to download the recording, and transcribes it using Deepgram nova-3 with speaker diarization and smart formatting. Includes webhook signature validation and the Zoom endpoint URL validation handshake. ### Required secrets | Variable | Purpose | |----------|---------| | `DEEPGRAM_API_KEY` | Deepgram STT | | `ZOOM_ACCOUNT_ID` | Zoom S2S OAuth | | `ZOOM_CLIENT_ID` | Zoom S2S OAuth | | `ZOOM_CLIENT_SECRET` | Zoom S2S OAuth | | `ZOOM_WEBHOOK_SECRET_TOKEN` | Webhook signature validation | --- *Built by Engineer on 2026-04-01* Co-authored-by: examples-bot <noreply@deepgram.com> Co-authored-by: Luke Oliff <luke@lukeoliff.com>
1 parent 7853ad4 commit 2982797

5 files changed

Lines changed: 385 additions & 0 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Deepgram — https://console.deepgram.com/
2+
DEEPGRAM_API_KEY=
3+
4+
# Zoom Server-to-Server OAuth — https://marketplace.zoom.us/
5+
ZOOM_ACCOUNT_ID=
6+
ZOOM_CLIENT_ID=
7+
ZOOM_CLIENT_SECRET=
8+
ZOOM_WEBHOOK_SECRET_TOKEN=
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# Zoom Cloud Recording Transcription with Deepgram
2+
3+
Automatically transcribe Zoom cloud recordings using Deepgram's nova-3 speech-to-text model. When a Zoom meeting recording completes, this server receives the webhook, downloads the audio, and produces a formatted transcript with speaker labels.
4+
5+
## What you'll build
6+
7+
A Node.js/Express server that receives Zoom `recording.completed` webhook events, downloads the recording via Zoom's Server-to-Server OAuth, and transcribes it using Deepgram nova-3 with speaker diarization and smart formatting.
8+
9+
## Prerequisites
10+
11+
- Node.js 18 or later
12+
- Deepgram account — [get a free API key](https://console.deepgram.com/)
13+
- Zoom account with a Server-to-Server OAuth app — [create one](https://developers.zoom.us/docs/internal-apps/create/)
14+
15+
## Environment variables
16+
17+
Copy `.env.example` to `.env` and fill in your credentials:
18+
19+
| Variable | Where to find it |
20+
|----------|-----------------|
21+
| `DEEPGRAM_API_KEY` | [Deepgram console → API Keys](https://console.deepgram.com/) |
22+
| `ZOOM_ACCOUNT_ID` | [Zoom Marketplace](https://marketplace.zoom.us/) → your Server-to-Server OAuth app → App Credentials |
23+
| `ZOOM_CLIENT_ID` | Same app → App Credentials |
24+
| `ZOOM_CLIENT_SECRET` | Same app → App Credentials |
25+
| `ZOOM_WEBHOOK_SECRET_TOKEN` | Same app → Feature tab → Event Subscriptions → Secret Token |
26+
27+
## Install and run
28+
29+
```bash
30+
npm install
31+
npm start
32+
```
33+
34+
The server starts on port 3000 (override with `PORT` env var). Expose it publicly with a tunnel for Zoom webhooks:
35+
36+
```bash
37+
npx localtunnel --port 3000
38+
```
39+
40+
## Zoom app setup
41+
42+
1. Go to [Zoom Marketplace](https://marketplace.zoom.us/) → Develop → Build App
43+
2. Choose **Server-to-Server OAuth**
44+
3. Add scopes: `cloud_recording:read:list_recording_files:admin`
45+
4. Under **Feature****Event Subscriptions**, add:
46+
- Event subscription URL: `https://your-domain.com/webhook`
47+
- Event type: `recording.completed`
48+
5. Zoom will send a validation request — the server handles it automatically
49+
50+
## Key parameters
51+
52+
| Parameter | Value | Description |
53+
|-----------|-------|-------------|
54+
| `model` | `nova-3` | Deepgram's latest general-purpose STT model |
55+
| `smart_format` | `true` | Adds punctuation, capitalization, number formatting |
56+
| `diarize` | `true` | Labels speakers (Speaker 0, Speaker 1, etc.) |
57+
| `paragraphs` | `true` | Groups transcript into readable paragraphs |
58+
59+
## How it works
60+
61+
1. A Zoom cloud recording finishes → Zoom fires a `recording.completed` webhook
62+
2. The server validates the webhook signature using your secret token
63+
3. It extracts the recording download URL from the payload, preferring audio-only files
64+
4. It authenticates with Zoom's Server-to-Server OAuth to get an access token
65+
5. It downloads the recording audio file
66+
6. It sends the audio buffer to Deepgram's pre-recorded STT API (`transcribeFile`)
67+
7. Deepgram returns a transcript with speaker labels and smart formatting
68+
8. The transcript is logged (extend this to store, email, or post to Slack)
69+
70+
## Starter templates
71+
72+
[deepgram-starters](https://github.com/orgs/deepgram-starters/repositories)
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"name": "zoom-recording-transcription-node",
3+
"version": "1.0.0",
4+
"description": "Transcribe Zoom cloud recordings using Deepgram nova-3",
5+
"main": "src/server.js",
6+
"scripts": {
7+
"start": "node src/server.js",
8+
"test": "node tests/test.js"
9+
},
10+
"dependencies": {
11+
"@deepgram/sdk": "^5.0.0",
12+
"dotenv": "^16.4.0",
13+
"express": "^4.21.0"
14+
},
15+
"engines": {
16+
"node": ">=18"
17+
}
18+
}
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
'use strict';
2+
3+
require('dotenv').config();
4+
5+
const crypto = require('crypto');
6+
const express = require('express');
7+
const { DeepgramClient } = require('@deepgram/sdk');
8+
9+
const PORT = process.env.PORT || 3000;
10+
11+
const REQUIRED_ENV = [
12+
'DEEPGRAM_API_KEY',
13+
'ZOOM_ACCOUNT_ID',
14+
'ZOOM_CLIENT_ID',
15+
'ZOOM_CLIENT_SECRET',
16+
'ZOOM_WEBHOOK_SECRET_TOKEN',
17+
];
18+
19+
for (const key of REQUIRED_ENV) {
20+
if (!process.env[key]) {
21+
console.error(`Error: ${key} environment variable is not set.`);
22+
console.error('Copy .env.example to .env and add your credentials.');
23+
process.exit(1);
24+
}
25+
}
26+
27+
// SDK v5: constructor takes an options object, not a bare string.
28+
const deepgram = new DeepgramClient({ apiKey: process.env.DEEPGRAM_API_KEY });
29+
30+
const app = express();
31+
app.use(express.json());
32+
33+
// ── Zoom webhook endpoint ────────────────────────────────────────────────────
34+
// Zoom sends two event types here:
35+
// 1. endpoint.url_validation — a challenge/response handshake when you first
36+
// register the webhook URL in the Zoom Marketplace.
37+
// 2. recording.completed — fired when a cloud recording finishes processing.
38+
app.post('/webhook', async (req, res) => {
39+
const { event, payload } = req.body;
40+
41+
// ← THIS handles Zoom's webhook URL validation handshake.
42+
// Zoom POSTs a plainToken that must be hashed with your secret and returned.
43+
if (event === 'endpoint.url_validation') {
44+
const hashForValidation = crypto
45+
.createHmac('sha256', process.env.ZOOM_WEBHOOK_SECRET_TOKEN)
46+
.update(req.body.payload.plainToken)
47+
.digest('hex');
48+
49+
return res.json({
50+
plainToken: req.body.payload.plainToken,
51+
encryptedToken: hashForValidation,
52+
});
53+
}
54+
55+
// Verify webhook signature to ensure the request came from Zoom.
56+
const message = `v0:${req.headers['x-zm-request-timestamp']}:${JSON.stringify(req.body)}`;
57+
const expectedSig = `v0=${crypto
58+
.createHmac('sha256', process.env.ZOOM_WEBHOOK_SECRET_TOKEN)
59+
.update(message)
60+
.digest('hex')}`;
61+
62+
if (req.headers['x-zm-signature'] !== expectedSig) {
63+
console.error('Invalid webhook signature — rejecting request');
64+
return res.status(401).json({ error: 'Invalid signature' });
65+
}
66+
67+
if (event !== 'recording.completed') {
68+
return res.json({ status: 'ignored', event });
69+
}
70+
71+
res.json({ status: 'processing' });
72+
73+
try {
74+
await handleRecordingCompleted(payload);
75+
} catch (err) {
76+
console.error('Error processing recording:', err.message);
77+
}
78+
});
79+
80+
// ── Zoom OAuth ───────────────────────────────────────────────────────────────
81+
// Server-to-Server OAuth uses client_credentials grant with account_id.
82+
// Token is short-lived (1 hour) — fetch a fresh one each time for simplicity.
83+
async function getZoomAccessToken() {
84+
const credentials = Buffer.from(
85+
`${process.env.ZOOM_CLIENT_ID}:${process.env.ZOOM_CLIENT_SECRET}`
86+
).toString('base64');
87+
88+
const resp = await fetch(
89+
`https://zoom.us/oauth/token?grant_type=account_credentials&account_id=${process.env.ZOOM_ACCOUNT_ID}`,
90+
{
91+
method: 'POST',
92+
headers: { Authorization: `Basic ${credentials}` },
93+
}
94+
);
95+
96+
if (!resp.ok) {
97+
throw new Error(`Zoom OAuth failed: ${resp.status} ${await resp.text()}`);
98+
}
99+
100+
const data = await resp.json();
101+
return data.access_token;
102+
}
103+
104+
// ── Recording handler ────────────────────────────────────────────────────────
105+
async function handleRecordingCompleted(payload) {
106+
const { object } = payload;
107+
const meetingTopic = object.topic || 'Untitled Meeting';
108+
109+
// Prefer audio_only files — smaller and faster to transcribe than video.
110+
const audioFile = object.recording_files.find(
111+
(f) => f.recording_type === 'audio_only'
112+
) || object.recording_files[0];
113+
114+
if (!audioFile) {
115+
console.log('No recording files found in payload');
116+
return;
117+
}
118+
119+
console.log(`\nProcessing: "${meetingTopic}"`);
120+
console.log(`Recording type: ${audioFile.recording_type}, format: ${audioFile.file_extension}`);
121+
122+
const accessToken = await getZoomAccessToken();
123+
124+
// Zoom download URLs require an OAuth token.
125+
// Download the file as a buffer so we can send it to Deepgram.
126+
const downloadUrl = `${audioFile.download_url}?access_token=${accessToken}`;
127+
const downloadResp = await fetch(downloadUrl);
128+
129+
if (!downloadResp.ok) {
130+
throw new Error(`Failed to download recording: ${downloadResp.status}`);
131+
}
132+
133+
const audioBuffer = Buffer.from(await downloadResp.arrayBuffer());
134+
console.log(`Downloaded ${(audioBuffer.length / 1024 / 1024).toFixed(1)} MB`);
135+
136+
// SDK v5: transcribeFile takes (buffer, options) — the buffer is the first arg.
137+
// SDK v5: all options are flat in a single object.
138+
// SDK v5: throws on error — use try/catch, not { result, error } destructuring.
139+
const data = await deepgram.listen.v1.media.transcribeFile(audioBuffer, {
140+
model: 'nova-3',
141+
smart_format: true,
142+
// ← THIS enables speaker labels — essential for multi-speaker meetings.
143+
diarize: true,
144+
// ← THIS enables paragraph detection for readable output.
145+
paragraphs: true,
146+
});
147+
148+
// data.results.channels[0].alternatives[0].transcript
149+
const transcript = data.results.channels[0].alternatives[0].transcript;
150+
const paragraphs = data.results.channels[0].alternatives[0].paragraphs;
151+
152+
console.log(`\n── Transcript: "${meetingTopic}" ──`);
153+
console.log(transcript);
154+
155+
if (paragraphs?.paragraphs) {
156+
console.log(`\n── Paragraphs: ${paragraphs.paragraphs.length} ──`);
157+
}
158+
159+
const words = data.results.channels[0].alternatives[0].words;
160+
if (words?.length > 0) {
161+
const duration = words.at(-1).end;
162+
console.log(`\nDuration: ${(duration / 60).toFixed(1)} min | Words: ${words.length}`);
163+
}
164+
165+
return { meetingTopic, transcript };
166+
}
167+
168+
app.get('/health', (_req, res) => res.json({ status: 'ok' }));
169+
170+
app.listen(PORT, () => {
171+
console.log(`Zoom recording transcription server running on port ${PORT}`);
172+
console.log(`Webhook endpoint: POST http://localhost:${PORT}/webhook`);
173+
console.log(`Health check: GET http://localhost:${PORT}/health`);
174+
});
175+
176+
module.exports = { app, getZoomAccessToken, handleRecordingCompleted };
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
'use strict';
2+
3+
const fs = require('fs');
4+
const path = require('path');
5+
6+
// ── Credential check — MUST be first ──────────────────────────────────────────
7+
// Exit code convention used across all examples in this repo:
8+
// 0 = all tests passed
9+
// 1 = real test failure (code bug, assertion error, unexpected API response)
10+
// 2 = missing credentials (expected in CI until secrets are configured)
11+
const envExample = path.join(__dirname, '..', '.env.example');
12+
const required = fs.readFileSync(envExample, 'utf8')
13+
.split('\n')
14+
.filter(l => /^[A-Z][A-Z0-9_]+=/.test(l.trim()))
15+
.map(l => l.split('=')[0].trim());
16+
17+
const missing = required.filter(k => !process.env[k]);
18+
if (missing.length > 0) {
19+
console.error(`MISSING_CREDENTIALS: ${missing.join(',')}`);
20+
process.exit(2);
21+
}
22+
// ──────────────────────────────────────────────────────────────────────────────
23+
24+
const { DeepgramClient } = require('@deepgram/sdk');
25+
26+
const KNOWN_AUDIO_URL = 'https://dpgr.am/spacewalk.wav';
27+
const EXPECTED_WORDS = ['spacewalk', 'astronaut', 'nasa'];
28+
29+
async function run() {
30+
// ── Test 1: Deepgram pre-recorded STT works with transcribeUrl ──
31+
console.log('Test 1: Deepgram pre-recorded STT (nova-3)...');
32+
33+
const deepgram = new DeepgramClient({ apiKey: process.env.DEEPGRAM_API_KEY });
34+
35+
const data = await deepgram.listen.v1.media.transcribeUrl({
36+
url: KNOWN_AUDIO_URL,
37+
model: 'nova-3',
38+
smart_format: true,
39+
diarize: true,
40+
paragraphs: true,
41+
});
42+
43+
const transcript = data?.results?.channels?.[0]?.alternatives?.[0]?.transcript;
44+
45+
if (!transcript || transcript.length < 20) {
46+
throw new Error(`Transcript too short or empty: "${transcript}"`);
47+
}
48+
49+
const lower = transcript.toLowerCase();
50+
const found = EXPECTED_WORDS.filter(w => lower.includes(w));
51+
if (found.length === 0) {
52+
throw new Error(
53+
`Expected words not found in transcript.\nGot: "${transcript.substring(0, 200)}"`
54+
);
55+
}
56+
57+
console.log(`✓ Transcript received (${transcript.length} chars)`);
58+
console.log(`✓ Expected content verified (found: ${found.join(', ')})`);
59+
60+
// ── Test 2: Zoom OAuth token retrieval ──
61+
console.log('\nTest 2: Zoom OAuth token retrieval...');
62+
63+
const credentials = Buffer.from(
64+
`${process.env.ZOOM_CLIENT_ID}:${process.env.ZOOM_CLIENT_SECRET}`
65+
).toString('base64');
66+
67+
const tokenResp = await fetch(
68+
`https://zoom.us/oauth/token?grant_type=account_credentials&account_id=${process.env.ZOOM_ACCOUNT_ID}`,
69+
{
70+
method: 'POST',
71+
headers: { Authorization: `Basic ${credentials}` },
72+
}
73+
);
74+
75+
if (!tokenResp.ok) {
76+
throw new Error(`Zoom OAuth failed: ${tokenResp.status} ${await tokenResp.text()}`);
77+
}
78+
79+
const tokenData = await tokenResp.json();
80+
if (!tokenData.access_token) {
81+
throw new Error('No access_token in Zoom OAuth response');
82+
}
83+
84+
console.log('✓ Zoom OAuth token retrieved successfully');
85+
86+
// ── Test 3: Webhook validation logic ──
87+
console.log('\nTest 3: Webhook signature validation logic...');
88+
89+
const crypto = require('crypto');
90+
const testToken = 'test-plain-token';
91+
const hash = crypto
92+
.createHmac('sha256', process.env.ZOOM_WEBHOOK_SECRET_TOKEN)
93+
.update(testToken)
94+
.digest('hex');
95+
96+
if (!hash || hash.length !== 64) {
97+
throw new Error('HMAC hash generation failed');
98+
}
99+
100+
console.log('✓ Webhook validation HMAC logic works');
101+
}
102+
103+
run()
104+
.then(() => {
105+
console.log('\n✓ All tests passed');
106+
process.exit(0);
107+
})
108+
.catch(err => {
109+
console.error(`\n✗ Test failed: ${err.message}`);
110+
process.exit(1);
111+
});

0 commit comments

Comments
 (0)