Markdown

Recording Download Pipeline

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.

Overview

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.

Skills Needed

  • **webhooks** - Receive recording events
  • **zoom-rest-api** - Download recordings

Architecture

Recording Download Pipeline:
┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│    Zoom     │────▶│  Webhook    │────▶│   Your      │
│   Cloud     │     │  Handler    │     │   Storage   │
│  Recording  │     │             │     │  (S3, GCS)  │
└─────────────┘     └─────────────┘     └─────────────┘

Prerequisites

  • Cloud recording enabled
  • Webhook endpoint
  • Storage bucket (S3, GCS, Azure, etc.)
  • `recording:read` scope

Workflow

  1. Meeting ends, cloud recording completes
  2. Receive `recording.completed` webhook
  3. Download recording via API
  4. Upload to your storage
  5. (Optional) Delete from Zoom cloud

Common Tasks

S3 Upload Integration

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}`;
}

GCS Upload Integration

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);
  });
}

Handling Large Files

// 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();
}

Retry Logic for Failed Downloads

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();
});

Recording Lifecycle Management

// 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 }}
    );
  }
}

Resources

  • **Recordings API**: https://developers.zoom.us/docs/api/rest/reference/zoom-api/methods/#tag/Cloud-Recording
  • **AWS S3 SDK**: https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/
  • **Google Cloud Storage**: https://cloud.google.com/storage/docs/reference/libraries