đ Executive Summary
TL;DR: Managing Zoom cloud recordings for long-term storage in Dropbox is often unscalable due to Zoomâs retention limits and manual overhead. This guide provides a robust Python script leveraging Zoom and Dropbox APIs to automate the fetching, downloading, and secure uploading of recordings, ensuring data durability and compliance. The solution involves setting up Server-to-Server OAuth for Zoom and an API app for Dropbox, then scheduling the script with cron for continuous synchronization.
đŻ Key Takeaways
- Zoomâs Server-to-Server OAuth app type is ideal for backend integrations, requiring
recording:read:adminscope to access account-wide recordings. - Dropbox API app setup involves choosing âScoped accessâ (preferably âApp folderâ) and generating an access token with
files.content.writeandfiles.content.readpermissions. - The Python synchronization script leverages
requestsfor API calls andpython-dotenvfor secure management of API credentials, downloading MP4 files locally before uploading. - To retrieve all account recordings with Server-to-Server OAuth, the script must first list all Zoom users and then iterate through each user to fetch their recordings within a specified date range.
- Automated scheduling via
cron(e.g., daily at 3 AM) is essential for continuous, hands-off synchronization, requiring full paths for the Python interpreter and script location.
Syncing Zoom Recordings to DropBox for Long-term Storage
Introduction
In todayâs remote-first world, Zoom recordings are critical assets for businesses and educational institutions. They capture important meetings, training sessions, and collaborative discussions. However, managing these recordings can quickly become a challenge. Zoomâs cloud storage, while convenient, often comes with retention limits or can become expensive for long-term archival. Manually downloading recordings from Zoom and then uploading them to a separate storage solution like Dropbox is a tedious, error-prone, and unscalable task for System Administrators and DevOps Engineers.
This tutorial will guide you through automating the synchronization of your Zoom cloud recordings to Dropbox. By leveraging the Zoom API and Dropbox API, we will build a robust Python script that periodically fetches new recordings and securely uploads them for long-term storage, ensuring data durability, accessibility, and compliance without the manual overhead.
Prerequisites
Before we begin, ensure you have the following:
- Zoom Account: A Pro, Business, or Enterprise account with cloud recording enabled. Youâll need admin access to create a Server-to-Server OAuth app.
- Dropbox Account: A personal or business account with sufficient storage space. Youâll need access to create a Dropbox API app.
- Python 3.x: Installed on your local machine or server.
-
Python Libraries: You will need the
requestslibrary for making HTTP requests andpython-dotenvfor managing environment variables. Install them usingpip install requests python-dotenv. -
API Credentials:
- Zoom Server-to-Server OAuth App credentials (Account ID, Client ID, Client Secret).
- Dropbox API App with a generated access token.
Step-by-Step Guide
Step 1: Set up Zoom Server-to-Server OAuth App
The Server-to-Server OAuth app type is ideal for backend integrations that donât require user interaction. Follow these steps to obtain your Zoom API credentials:
- Log in to the Zoom App Marketplace as an administrator.
- Navigate to âDevelopâ in the top-right corner, then choose âBuild Appâ.
- Select âServer-to-Server OAuthâ as the app type and click âCreateâ.
- Provide an App Name (e.g., âDropbox Syncâ) and click âCreateâ.
- On the âApp Credentialsâ page, note down your Account ID, Client ID, and Client Secret. Keep these secure.
- Go to the âInformationâ tab and fill in the required fields (short description, long description, company name, developer contact info).
- Navigate to the âScopesâ tab. Click âAdd Scopesâ and search for
recording:read:admin. Select this scope and click âDoneâ. This scope allows your app to view all account recordings. - Go to the âActivationâ tab and click âActivate your appâ. Your app is now ready to generate access tokens.
Step 2: Set up Dropbox API App and Generate Access Token
Youâll need a Dropbox access token to authenticate your script. Hereâs how to get one:
- Log in to the Dropbox App Console.
- Click âCreate appâ.
- Choose âScoped accessâ.
- For the type of access, select âApp folderâ (recommended for isolating access) or âFull Dropboxâ (if you need broader access). For this tutorial, âApp folderâ is sufficient and more secure.
- Provide a unique name for your app (e.g., âZoom Recording Syncâ) and click âCreate appâ.
- On the appâs settings page, navigate to the âPermissionsâ tab.
- Under âIndividual scopesâ, grant the following permissions:
files.content.write-
files.content.read(optional, if you want to check for existing files) -
files.metadata.write(optional, if you want to add metadata)
- Click âSubmitâ to save the permissions.
- Go back to the âSettingsâ tab. Scroll down to the âGenerated access tokenâ section. Click the âGenerateâ button.
- Copy the generated Access Token. This token provides your script with direct access to your Dropbox account within the specified permissions. Store it securely.
Step 3: Develop the Python Synchronization Script
Now, letâs write the Python script that will orchestrate the sync. Create a file named sync_zoom_to_dropbox.py and another named config.env.
First, populate your config.env file with the credentials you gathered:
ZOOM_ACCOUNT_ID=[YOUR_ZOOM_ACCOUNT_ID]
ZOOM_CLIENT_ID=[YOUR_ZOOM_CLIENT_ID]
ZOOM_CLIENT_SECRET=[YOUR_ZOOM_CLIENT_SECRET]
DROPBOX_ACCESS_TOKEN=[YOUR_DROPBOX_ACCESS_TOKEN]
DROPBOX_FOLDER=/Zoom Recordings/
Next, hereâs the Python script (sync_zoom_to_dropbox.py):
import requests
import os
import json
from datetime import datetime, timedelta
from dotenv import load_dotenv
# Load environment variables from config.env
load_dotenv('config.env')
# Zoom API Credentials
ZOOM_ACCOUNT_ID = os.getenv('ZOOM_ACCOUNT_ID')
ZOOM_CLIENT_ID = os.getenv('ZOOM_CLIENT_ID')
ZOOM_CLIENT_SECRET = os.getenv('ZOOM_CLIENT_SECRET')
# Dropbox API Credentials
DROPBOX_ACCESS_TOKEN = os.getenv('DROPBOX_ACCESS_TOKEN')
DROPBOX_FOLDER = os.getenv('DROPBOX_FOLDER', '/Zoom Recordings/') # Default to /Zoom Recordings/
# Constants
ZOOM_API_BASE = 'https://api.zoom.us/v2'
DROPBOX_API_BASE = 'https://content.dropboxapi.com/2'
ZOOM_OAUTH_URL = 'https://oauth.zoom.us/oauth/token'
# Temporary download directory
DOWNLOAD_DIR = 'logs/zoom_downloads/' # Using logs/ instead of /tmp/
def get_zoom_oauth_token():
"""Obtains a Zoom access token using Server-to-Server OAuth."""
url = ZOOM_OAUTH_URL
headers = {
'Content-Type': 'application/x-www-form-urlencoded'
}
data = {
'grant_type': 'account_credentials',
'account_id': ZOOM_ACCOUNT_ID,
'client_id': ZOOM_CLIENT_ID,
'client_secret': ZOOM_CLIENT_SECRET
}
try:
response = requests.post(url, headers=headers, data=data)
response.raise_for_status() # Raise an exception for HTTP errors
return response.json()['access_token']
except requests.exceptions.RequestException as e:
print(f"Error getting Zoom OAuth token: {e}")
return None
def list_zoom_recordings(token, start_date, end_date):
"""Lists Zoom cloud recordings for a given date range."""
# We will list recordings for the whole account (using a fake user_id 'me' with admin scope)
# The 'me' endpoint for recordings requires user context, but account-level queries can be done
# via report/meetings. For simplicity and broad application with server-to-server,
# we simulate listing "all" recordings that the admin scope can see by iterating users or querying reports.
# For a direct solution targeting account recordings, the /report/meetings endpoint is better.
# However, to demonstrate fetching individual recording details (which are usually user-scoped),
# we'll use a simpler approach of listing "all" possible if admin scope allows.
# A more robust enterprise solution would iterate users or use the report API.
# For this example, let's assume 'me' refers to the API owner or we fetch all.
# A cleaner approach for S2S OAuth would be /users/[user_id]/recordings.
# For simplicity, we'll assume we're listing for a specific admin user or account if the token permits.
# Let's adjust to use a more general approach or simply fetch from a date range
# and assume the admin scope covers this. Zoom's API is a bit tricky here with 'me' vs 'account'.
# For Server-to-Server, the preferred way to get account recordings is via 'List all meetings'.
# However, to get recording files, you often need the meeting_id and specific recording_id.
# Let's try listing meetings for a specific user (the app owner or a designated user)
# or list all meetings under the account if the scope supports it directly.
# For server-to-server, listing all recordings typically involves fetching users, then their recordings.
# For a direct, simple tutorial, we'll try to list "meetings" which have recordings.
# A more direct approach for Server-to-Server and account-wide recordings
# is to fetch 'past_meetings' or 'meetings' and then their recordings.
# Let's simplify and assume the admin token can query "all" meetings.
# The /users/{userId}/recordings endpoint is for a specific user.
# For account-wide, you often list users, then their recordings.
# Given the 'recording:read:admin' scope, it's possible to iterate through users.
# For tutorial sake, we'll adapt to a common pattern:
# 1. List users
# 2. For each user, list their recordings.
all_recordings = []
page_number = 1
page_size = 300 # Max page size for users
users_url = f"{ZOOM_API_BASE}/users"
headers = {
'Authorization': f'Bearer {token}',
'Content-Type': 'application/json'
}
# First, list all users in the account
users = []
while True:
params = {
'page_size': page_size,
'page_number': page_number
}
try:
response = requests.get(users_url, headers=headers, params=params)
response.raise_for_status()
user_data = response.json()
users.extend(user_data.get('users', []))
if not user_data.get('next_page_token'):
break
page_number += 1
except requests.exceptions.RequestException as e:
print(f"Error listing Zoom users: {e}")
return []
print(f"Found {len(users)} Zoom users.")
# Then, for each user, list their recordings
for user in users:
user_id = user['id']
recordings_url = f"{ZOOM_API_BASE}/users/{user_id}/recordings"
record_page_token = None
while True:
params = {
'from': start_date.strftime('%Y-%m-%d'),
'to': end_date.strftime('%Y-%m-%d'),
'page_size': 300 # Max page size for recordings
}
if record_page_token:
params['next_page_token'] = record_page_token
try:
response = requests.get(recordings_url, headers=headers, params=params)
response.raise_for_status()
recordings_data = response.json()
meetings = recordings_data.get('meetings', [])
for meeting in meetings:
# Filter for recordings that have actual download files
if 'recording_files' in meeting and meeting['recording_files']:
# Add user info to recording for better context/naming
meeting['user_email'] = user['email']
all_recordings.append(meeting)
record_page_token = recordings_data.get('next_page_token')
if not record_page_token:
break
except requests.exceptions.RequestException as e:
print(f"Error listing recordings for user {user['email']}: {e}")
break # Move to next user if there's an issue
return all_recordings
def download_recording(recording_url, filename):
"""Downloads a recording file from Zoom."""
os.makedirs(DOWNLOAD_DIR, exist_ok=True)
file_path = os.path.join(DOWNLOAD_DIR, filename)
try:
# For authenticated recording URLs, no token needed here.
# But if it requires authentication, you'd add Authorization header.
# Zoom's recording download_url usually contains a short-lived token.
print(f"Downloading: {filename} from {recording_url}")
response = requests.get(recording_url, stream=True)
response.raise_for_status()
with open(file_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
print(f"Downloaded: {filename}")
return file_path
except requests.exceptions.RequestException as e:
print(f"Error downloading {filename}: {e}")
return None
def upload_to_dropbox(file_path, dropbox_path):
"""Uploads a file to Dropbox."""
headers = {
'Authorization': f'Bearer {DROPBOX_ACCESS_TOKEN}',
'Content-Type': 'application/octet-stream',
'Dropbox-API-Arg': json.dumps({
"path": dropbox_path,
"mode": "overwrite",
"autorename": False, # Setting to False for direct overwrite. True allows 'filename (1).ext'
"mute": False
})
}
try:
with open(file_path, 'rb') as f:
print(f"Uploading {os.path.basename(file_path)} to Dropbox at {dropbox_path}")
response = requests.post(
f"{DROPBOX_API_BASE}/files/upload",
headers=headers,
data=f
)
response.raise_for_status()
print(f"Uploaded {os.path.basename(file_path)} successfully.")
return True
except requests.exceptions.RequestException as e:
print(f"Error uploading {os.path.basename(file_path)} to Dropbox: {e}")
if response and response.status_code == 409: # Conflict, e.g., folder doesn't exist
print("Dropbox error 409: Path conflict, ensure parent folders exist or correct path.")
return False
def sanitize_filename(filename):
"""Sanitizes a string to be a valid filename, replacing forbidden characters."""
forbidden_chars = '&#/\\:*?"<>|' # Replace angle brackets for WAF safety
for char in forbidden_chars:
filename = filename.replace(char, '_')
return filename
def main():
zoom_token = get_zoom_oauth_token()
if not zoom_token:
print("Failed to get Zoom access token. Exiting.")
return
# Define date range for recordings (e.g., last 7 days)
end_date = datetime.now()
start_date = end_date - timedelta(days=7) # Sync recordings from the last 7 days
print(f"Fetching Zoom recordings from {start_date.strftime('%Y-%m-%d')} to {end_date.strftime('%Y-%m-%d')}")
recordings = list_zoom_recordings(zoom_token, start_date, end_date)
if not recordings:
print("No new recordings found for the specified period.")
return
print(f"Found {len(recordings)} meetings with recordings.")
for meeting in recordings:
meeting_topic = sanitize_filename(meeting.get('topic', 'Untitled Meeting'))
meeting_uuid = meeting.get('uuid') # Unique ID for the meeting
meeting_start = datetime.fromisoformat(meeting['start_time'].replace('Z', '+00:00')).strftime('%Y-%m-%d_%H-%M')
user_email = meeting.get('user_email', 'unknown_user') # Get user email from earlier step
# Filter for 'mp4' recording files (video)
video_files = [f for f in meeting.get('recording_files', []) if f['file_type'] == 'MP4' and f['status'] == 'completed']
if not video_files:
print(f"No completed MP4 recordings found for meeting '{meeting_topic}' ({meeting_uuid}). Skipping.")
continue
for recording_file in video_files:
file_id = recording_file['id']
download_url = recording_file['download_url']
file_size_bytes = recording_file['file_size'] # Can use this for logging/progress
# Construct a unique and descriptive filename
# Format: 'YYYY-MM-DD_HH-MM_MeetingTopic_UserEmail_UUID_ID.mp4'
filename = f"{meeting_start}_{meeting_topic}_{sanitize_filename(user_email)}_{meeting_uuid}_{file_id}.mp4"
local_file_path = download_recording(download_url, filename)
if local_file_path:
# Construct Dropbox path: /Zoom Recordings/YYYY-MM/filename.mp4
dropbox_subfolder = datetime.fromisoformat(meeting['start_time'].replace('Z', '+00:00')).strftime('%Y-%m')
dropbox_path = os.path.join(DROPBOX_FOLDER, dropbox_subfolder, filename).replace('\\', '/') # Ensure forward slashes for Dropbox
upload_to_dropbox(local_file_path, dropbox_path)
# Clean up local file after upload
os.remove(local_file_path)
print(f"Cleaned up local file: {local_file_path}")
if __name__ == "__main__":
main()
Logic Explanation:
- The script loads API credentials from
config.env. -
get_zoom_oauth_token(): Authenticates with Zoom using your Server-to-Server OAuth app credentials to retrieve a temporary access token. -
list_zoom_recordings(): This is a critical function. For Server-to-Server OAuth withrecording:read:adminscope, to get all recordings, you typically need to list all users in the account first, then iterate through each user to fetch their recordings within a specified date range. The script fetches recordings from the last 7 days by default. -
download_recording(): Takes a Zoom recording download URL and saves the MP4 file to a local directory (logs/zoom_downloads/). -
upload_to_dropbox(): Uses the Dropbox API/files/uploadendpoint to push the downloaded file to your designated Dropbox folder. It ensures the path uses forward slashes and overwrites existing files if names match. -
sanitize_filename(): A utility to remove potentially problematic characters from filenames to ensure compatibility across file systems and APIs. -
main(): Orchestrates the entire process: gets a Zoom token, lists recordings, downloads each completed MP4 file, constructs a unique Dropbox path (including a YYYY-MM subfolder for organization), uploads the file, and then deletes the local copy.
Step 4: Schedule the Script with Cron
To automate the synchronization, you can schedule the Python script to run periodically using a tool like cron on Linux systems. This example will run the script once every night at 3:00 AM.
- Open your crontab for editing:
crontab -e
- Add the following line to the end of the file. Adjust the path to your Python executable and script location as needed. Remember to specify the full path to your Python interpreter.
# Run Zoom to Dropbox sync script every day at 3 AM
0 3 * * * /usr/bin/python3 /path/to/your/script/sync_zoom_to_dropbox.py >> /home/user/logs/zoom_dropbox_sync.log 2>error.log
- Save and exit the crontab editor (usually by pressing
Ctrl+X, thenY, thenEnter).
Explanation of the Cron Job:
-
0 3 * * *: This schedule means âat 0 minutes past 3 AM, every day of every month.â -
/usr/bin/python3: The full path to your Python 3 interpreter. Confirm this path on your system. -
/path/to/your/script/sync_zoom_to_dropbox.py: The full path to your Python script. -
>> /home/user/logs/zoom_dropbox_sync.log: Redirects standard output (success messages) to a log file, appending to it. -
2>error.log: Redirects standard error (error messages) to a separate file namederror.login the current working directory of the cron job (usually the userâs home directory unless specified otherwise). This is crucial for debugging.
Common Pitfalls
- API Rate Limits: Both Zoom and Dropbox APIs have rate limits. If you process a very large number of recordings in a short period, your requests might get temporarily blocked. The script does not include explicit retry logic or exponential back-off, which would be recommended for production environments.
-
Authentication/Authorization Errors:
-
Zoom: Ensure your Zoom Client ID, Client Secret, and Account ID are correct and that the Server-to-Server OAuth app is activated with the
recording:read:adminscope. Zoom access tokens are short-lived (1 hour), but theget_zoom_oauth_tokenfunction handles refreshing it for each run. -
Dropbox: Verify your Dropbox Access Token is correct and has the necessary
files.content.writepermission. If your token expires or is revoked, youâll need to generate a new one.
-
Zoom: Ensure your Zoom Client ID, Client Secret, and Account ID are correct and that the Server-to-Server OAuth app is activated with the
-
File Size/Network Issues: Transferring very large recording files can be slow and prone to network interruptions. The current script does not implement chunked uploads for Dropbox (which is required for files over 150MB with a single API call); for larger files, youâd need to use the
/files/upload_session/start,/files/upload_session/append, and/files/upload_session/finishendpoints. - Recording Availability: Zoom recordings might not be immediately available after a meeting ends. The script should ideally be scheduled to run after a sufficient delay (e.g., several hours) to allow Zoom to process and finalize recordings.
Conclusion
Automating the synchronization of Zoom recordings to Dropbox provides a robust, scalable, and hands-off solution for long-term storage and archival. Youâve learned how to set up API credentials for both platforms, developed a Python script to fetch and upload recordings, and scheduled this process using cron. This setup frees up valuable time for SysAdmins and DevOps Engineers, reduces the risk of data loss, and ensures your critical meeting data is securely stored and accessible.
For further enhancements, consider implementing robust error handling, detailed logging, support for chunked uploads for larger files, and potentially containerizing your script with Docker for easier deployment and management. You could also extend this solution to process other recording file types (e.g., audio-only, chat transcripts) or integrate with notification systems.
đ Read the original article on TechResolve.blog
â Support my work
If this article helped you, you can buy me a coffee:

Top comments (0)