Recording Download Pipeline
Automatically download Zoom Meeting cloud recordings to your own storage (S3, GCS, Azure Blob, etc.) using webhooks and REST API.
Automatically download Zoom Meeting cloud recordings to your own storage (S3, GCS, Azure Blob, etc.) using webhooks and REST API.
Automatically download Zoom Meeting cloud recordings to your own storage (S3, GCS, Azure Blob, etc.) using webhooks and REST API.
**Note:** This is NOT Video SDK BYOS (Bring Your Own Storage). Video SDK BYOS saves recordings **directly** to S3 without downloading through Zoom servers. See `zoom-video-sdk` for true BYOS.
Set up automated pipelines to download Zoom Meeting cloud recordings to your own storage infrastructure for compliance, cost management, or integration with existing media workflows.
Recording Download Pipeline:
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Zoom │────▶│ Webhook │────▶│ Your │
│ Cloud │ │ Handler │ │ Storage │
│ Recording │ │ │ │ (S3, GCS) │
└─────────────┘ └─────────────┘ └─────────────┘const axios = require('axios');
const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3');
const { Upload } = require('@aws-sdk/lib-storage');
const s3 = new S3Client({ region: 'us-east-1' });
// Handle recording.completed webhook
app.post('/webhook', async (req, res) => {
const { event, payload } = req.body;
if (event === 'recording.completed') {
const { uuid, recording_files, host_email, topic } = payload.object;
// Process each recording file
for (const file of recording_files) {
await uploadToS3(file, uuid, topic);
}
// Optionally delete from Zoom after successful upload
// await deleteZoomRecording(uuid);
}
res.status(200).send();
});
async function uploadToS3(file, meetingUuid, topic) {
// Download from Zoom
const response = await axios({
method: 'GET',
url: file.download_url,
headers: { 'Authorization': `Bearer ${accessToken}` },
responseType: 'stream'
});
// Sanitize topic for filename
const safeTopic = topic.replace(/[^a-zA-Z0-9]/g, '_').substring(0, 50);
const key = `recordings/${meetingUuid}/${safeTopic}_${file.file_type}.${file.file_extension}`;
// Upload to S3 using multipart upload for large files
const upload = new Upload({
client: s3,
params: {
Bucket: 'your-recordings-bucket',
Key: key,
Body: response.data,
ContentType: getContentType(file.file_extension),
Metadata: {
'meeting-uuid': meetingUuid,
'file-type': file.file_type,
'recording-start': file.recording_start
}
}
});
await upload.done();
return `s3://your-recordings-bucket/${key}`;
}const { Storage } = require('@google-cloud/storage');
const storage = new Storage();
const bucket = storage.bucket('your-recordings-bucket');
async function uploadToGCS(file, meetingUuid, topic) {
const response = await axios({
method: 'GET',
url: file.download_url,
headers: { 'Authorization': `Bearer ${accessToken}` },
responseType: 'stream'
});
const safeTopic = topic.replace(/[^a-zA-Z0-9]/g, '_').substring(0, 50);
const filePath = `recordings/${meetingUuid}/${safeTopic}_${file.file_type}.${file.file_extension}`;
const gcsFile = bucket.file(filePath);
return new Promise((resolve, reject) => {
const writeStream = gcsFile.createWriteStream({
metadata: {
contentType: getContentType(file.file_extension),
metadata: {
meetingUuid: meetingUuid,
fileType: file.file_type
}
},
resumable: true // Important for large files
});
response.data.pipe(writeStream)
.on('finish', () => resolve(`gs://your-recordings-bucket/${filePath}`))
.on('error', reject);
});
}// Use streaming to avoid memory issues
async function streamDownload(downloadUrl, destination) {
const response = await axios({
method: 'GET',
url: downloadUrl,
headers: { 'Authorization': `Bearer ${accessToken}` },
responseType: 'stream',
maxContentLength: Infinity, // Allow large files
maxBodyLength: Infinity
});
// Track progress
const totalSize = parseInt(response.headers['content-length'], 10);
let downloadedSize = 0;
response.data.on('data', (chunk) => {
downloadedSize += chunk.length;
const progress = ((downloadedSize / totalSize) * 100).toFixed(2);
console.log(`Download progress: ${progress}%`);
});
return response.data;
}
// For very large files, use chunked upload
async function chunkedUploadToS3(stream, key) {
const upload = new Upload({
client: s3,
params: {
Bucket: 'your-bucket',
Key: key,
Body: stream
},
queueSize: 4, // Concurrent part uploads
partSize: 10 * 1024 * 1024 // 10MB parts
});
upload.on('httpUploadProgress', (progress) => {
console.log(`Upload progress: ${progress.loaded}/${progress.total}`);
});
await upload.done();
}const retry = require('async-retry');
async function downloadWithRetry(file, meetingUuid) {
return await retry(
async (bail, attemptNumber) => {
console.log(`Attempt ${attemptNumber} for ${file.file_type}`);
try {
return await uploadToS3(file, meetingUuid);
} catch (error) {
// Don't retry on permanent errors
if (error.response?.status === 404) {
bail(new Error('Recording not found'));
return;
}
if (error.response?.status === 401) {
// Refresh token and retry
await refreshAccessToken();
}
throw error; // Retry
}
},
{
retries: 3,
factor: 2,
minTimeout: 1000,
maxTimeout: 10000,
onRetry: (error, attempt) => {
console.log(`Retry attempt ${attempt}: ${error.message}`);
}
}
);
}
// Queue system for processing
const Queue = require('bull');
const recordingQueue = new Queue('recording-uploads', process.env.REDIS_URL || 'redis://YOUR_REDIS_HOST:6379');
recordingQueue.process(async (job) => {
const { file, meetingUuid, topic } = job.data;
return await downloadWithRetry(file, meetingUuid, topic);
});
// Add job with retry
app.post('/webhook', async (req, res) => {
if (req.body.event === 'recording.completed') {
const { uuid, recording_files, topic } = req.body.payload.object;
for (const file of recording_files) {
await recordingQueue.add(
{ file, meetingUuid: uuid, topic },
{ attempts: 3, backoff: { type: 'exponential', delay: 5000 }}
);
}
}
res.status(200).send();
});// Delete from Zoom after successful upload
async function deleteZoomRecording(meetingUuid) {
// Double-encode UUID if it contains / or //
const encodedUuid = encodeURIComponent(encodeURIComponent(meetingUuid));
await axios.delete(
`https://api.zoom.us/v2/meetings/${encodedUuid}/recordings`,
{
params: { action: 'trash' }, // Move to trash (recoverable for 30 days)
// params: { action: 'delete' }, // Permanent delete
headers: { 'Authorization': `Bearer ${accessToken}` }
}
);
}
// Track what's been archived
const db = require('./db');
async function markAsArchived(meetingUuid, s3Path) {
await db.recordings.upsert({
meeting_uuid: meetingUuid,
s3_path: s3Path,
archived_at: new Date(),
deleted_from_zoom: false
});
}
// Clean up old recordings from Zoom
async function cleanupOldRecordings() {
const archivedRecordings = await db.recordings.findAll({
where: {
archived_at: { $lt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) },
deleted_from_zoom: false
}
});
for (const recording of archivedRecordings) {
await deleteZoomRecording(recording.meeting_uuid);
await db.recordings.update(
{ deleted_from_zoom: true },
{ where: { meeting_uuid: recording.meeting_uuid }}
);
}
}