Marketplace status: The dlzoom Zoom app is submitted and awaiting Marketplace approval. The hosted broker at
https://zoom-broker.dlzoom.workers.devis live today, but you must create your own Zoom OAuth app (or self-host the worker) until Zoom publishes the listing. Server-to-Server OAuth continues to work for admins/automation.
Download Zoom cloud recordings from the command line.
Built for power users and teams running custom transcription pipelines: get clean audio (M4A) and a diarization‑first minimal STJ file you can feed into your ASR of choice (e.g., Whisper) to add richer context than Zoom’s default transcription and support languages Zoom doesn’t handle well.
- 🎯 Purpose-built for transcription workflows: audio (M4A) + minimal STJ diarization JSON by default
- 🔄 Resilient downloads: resume partials, retries with backoff, dedupe by size
- 🧰 Automation-first: JSON output, batch by date range, structured logs, file/folder templates
- 🔐 Secure by design: OAuth via broker (no client secret in CLI), S2S for admins, no secrets in logs
- 🐳 Docker image includes ffmpeg; no local deps required
- 🧪 Comprehensive tests and CI
Authentication note Hosted user sign‑in (
dlzoom login) already uses the shared broker endpoint. Until Zoom finishes Marketplace review you still bring your own Zoom OAuth app (or self‑host) before runningdlzoom login, or use Server‑to‑Server (S2S) OAuth.
Requires Python 3.11+ and ffmpeg (Docker users: both included in the image).
# Install uv (if missing)
curl -LsSf https://astral.sh/uv/install.sh | sh
# Authenticate once
uvx dlzoom login
# Or for S2S automation:
# ZOOM_ACCOUNT_ID=... ZOOM_CLIENT_ID=... ZOOM_CLIENT_SECRET=... uvx dlzoom whoami
# Try dlzoom instantly (no install) after auth is configured
uvx dlzoom download 123456789 --check-availabilityOutputs include audio (M4A), transcript (VTT), chat (TXT), timeline (JSON), metadata JSON, and a minimal STJ diarization file (<name>_speakers.stjson) for your ASR pipeline.
- I'm downloading my own recordings → User OAuth
- Run
dlzoom login- uses our hosted OAuth broker by default (open source, auditable code inzoom-broker/) - Until Zoom publishes the Marketplace listing, create your own Zoom OAuth app (or self-host the broker) before logging in so the hosted flow can exchange tokens.
- Or self-host: deploy the Cloudflare Worker in
zoom-broker/and rundlzoom login --auth-url <your-worker-url>
- Run
- I'm an admin or running automation/CI → Server-to-Server (S2S) OAuth
- Set
ZOOM_ACCOUNT_ID,ZOOM_CLIENT_ID,ZOOM_CLIENT_SECRETand rundlzoom. - Scopes: add
account:read:admin+cloud_recording:read:list_account_recordings:{admin|master}(or the:mastervariant) so account-wide recording fetches work. - Verify scopes any time with
dlzoom whoami --json.
- Set
Links to both flows are in Authentication below.
- Use your preferred ASR (e.g., Whisper, cloud STT) on the M4A file.
- dlzoom also emits a minimal STJ diarization file you can use to tag speakers or structure prompts.
- Works well when you need extra context beyond Zoom’s default transcript, or for languages/dialects Zoom struggles with.
Examples:
# Download and name outputs
dlzoom download 123456789 --output-name my_meeting
# Resulting files include (when available from Zoom):
# my_meeting.m4a (or extracted from MP4 if audio-only not provided)
# my_meeting_transcript.vtt
# my_meeting_chat.txt
# my_meeting_timeline.json # only when Zoom provides timeline blobs
# my_meeting_speakers.stjson # generated when timelines existTuning diarization output:
# Disable diarization JSON entirely
dlzoom download 123456789 --skip-speakers
# Handle multi-user timestamps and include unknown speakers
dlzoom download 123456789 --speakers-mode multiple --include-unknown
# Merge and minimum-segment tuning
dlzoom download 123456789 --stj-min-seg-sec 1.0 --stj-merge-gap-sec 1.5
# Env toggle to disable generation by default
export DLZOOM_SPEAKERS=0STJ spec: https://github.com/yaniv-golan/STJ/blob/main/spec/latest/stj-specification.md
Every generated STJ file includes metadata.source.extensions.zoom and metadata.extensions.dlzoom
entries so you can trace the diarization back to the exact Zoom meeting (meeting/account IDs,
scope used, host details, CLI parameters, and a scrubbed summary of the downloaded recording
files).
Speaker IDs inside the STJ file are human-friendly slugs (e.g., yaniv-golan), while the raw Zoom participant/user IDs are preserved under speakers[].extensions.zoom for lossless correlation.
# Browse last 7 days
dlzoom recordings --range last-7-days
# Specific window
dlzoom recordings --from-date 2025-01-01 --to-date 2025-01-31
# Filter by topic
dlzoom recordings --range today --topic "standup"
# Inspect instances of a specific meeting (recurring/PMI)
dlzoom recordings --meeting-id 123456789
# Download (audio + transcript + chat + timeline)
dlzoom download 123456789
# Check availability without downloading
dlzoom download 123456789 --check-availability
# This exits non-zero if Zoom cannot find the recording or Zoom returns an error.
# Wait up to 60 minutes for processing
dlzoom download 123456789 --wait 60
# Custom naming and output directory
dlzoom download 123456789 --output-name "my_meeting" --output-dir ./recordings
# Batch download with explicit name reused per meeting
dlzoom download --from-date 2024-04-01 --to-date 2024-04-07 --output-name finance_sync
# Batch download without --output-name automatically appends UTC timestamps
# (e.g., 123456789_20240401-150000) to avoid overwriting recurring meetings.
dlzoom download --from-date 2024-04-01 --to-date 2024-04-07
# Dry run
dlzoom download 123456789 --dry-run
# Non-zero exit when meeting not found or batch fails
dlzoom download 123456789 --check-availability || echo "missing"
# Tip: meeting IDs with spaces pasted from Zoom are normalized automatically
dlzoom download "882 9060 9309"Date-range downloads (--from-date/--to-date) reuse any explicit --output-name you provide; otherwise they append a UTC timestamp (or the recording UUID when no timestamp is available) to prevent recurring IDs from overwriting each other. Pair --from-date/--to-date with --dry-run to preview every meeting in the range without downloading files, --wait 30 to keep polling for in-progress recordings before the downloads begin (the CLI exits instead of attempting a doomed download if the wait times out), --log-file ~/dlzoom.jsonl to capture structured results for every meeting, or --check-availability to scan the whole window without downloading anything. If any meeting in the batch fails, dlzoom download --from ... exits non-zero so CI/CD jobs can detect partial failures.
Batch by date window and automate:
#!/bin/bash
for id in 111111111 222222222 333333333; do
dlzoom download "$id" --output-dir ./recordings
doneJSON output for pipelines:
dlzoom download 123456789 --json > recording.jsonThe JSON payload lists every downloaded artifact (audio/video/transcripts/chats/timelines/speaker STJ files) so automation can inspect all outputs.
dlzoom needs to know which Zoom API surface to call when enumerating or batch-downloading recordings. Use the --scope/--user-id flags on recordings and download to control this.
- Endpoint:
GET /v2/accounts/me/recordings - Required scopes:
account:read:adminandcloud_recording:read:list_account_recordings:{admin|master}(granular scopes). Classicrecording:read:adminalone is insufficient. - Usage:
# S2S with full account visibility dlzoom recordings --scope account --from-date 2025-02-01 --to-date 2025-02-15 --json dlzoom download --from-date 2025-02-01 --to-date 2025-02-05 --scope account - Troubleshooting 403/4711 errors:
- Run
dlzoom whoami --jsonto inspect the token's actual scopes. - Add BOTH scopes above to your S2S app and ensure your admin role exposes granular recording scopes.
- Try the
:mastervariant if your account uses a master/sub-account hierarchy. - If Zoom still hides the granular scopes, create a General (Unlisted) app as a fallback.
- Run
- Endpoint:
GET /v2/users/{userId}/recordings - User OAuth:
user_id="me"resolves automatically. - S2S fallback: pass an explicit email/UUID via
--user-idor setZOOM_S2S_DEFAULT_USER.dlzoom recordings --scope user --user-id host@example.com --from-date 2025-02-01 --to-date 2025-02-05 dlzoom download --from-date 2025-02-01 --to-date 2025-02-05 --scope user --user-id user@example.com
Choose your preferred method.
curl -LsSf https://astral.sh/uv/install.sh | sh
uvx dlzoom download 123456789 --check-availabilitypip install dlzoom
# or with uv (fast)
uv pip install dlzoom
# or install as a tool (isolated)
uv tool install dlzoom# Includes Python + ffmpeg
docker run -it --rm \
-v $(pwd)/recordings:/app/downloads \
-e ZOOM_ACCOUNT_ID="your_account_id" \
-e ZOOM_CLIENT_ID="your_client_id" \
-e ZOOM_CLIENT_SECRET="your_secret" \
yanivgolan1/dlzoom:latest \
download 123456789
# Or GHCR
docker run -it --rm \
-v $(pwd)/recordings:/app/downloads \
-e ZOOM_ACCOUNT_ID="your_account_id" \
-e ZOOM_CLIENT_ID="your_client_id" \
-e ZOOM_CLIENT_SECRET="your_secret" \
ghcr.io/yaniv-golan/dlzoom:latest \
download 123456789git clone https://github.com/yaniv-golan/dlzoom.git
cd dlzoom
python3.11 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -e .- Python 3.11+
- ffmpeg (for audio extraction from MP4)
# macOS
brew install ffmpeg
# Ubuntu/Debian
sudo apt install ffmpeg
# Windows (Chocolatey)
choco install ffmpeg
# Windows (winget)
winget install ffmpegBy default, dlzoom login uses our hosted OAuth broker at https://zoom-broker.dlzoom.workers.dev:
dlzoom loginAbout the hosted broker:
- Open source: All code is in
zoom-broker/and auditable - Privacy: Only stores session data temporarily (max 10 minutes), does not log or persist tokens
- Generic: Works with any Zoom OAuth app (you create your own app in Zoom Marketplace)
- Secure: Runs on Cloudflare Workers with automatic HTTPS
Self-hosting (optional):
If you prefer to run your own broker:
cd zoom-broker
npx wrangler secret put ZOOM_CLIENT_ID
npx wrangler secret put ZOOM_CLIENT_SECRET
npx wrangler secret put ALLOWED_ORIGIN
npx wrangler kv namespace create AUTH
npx wrangler deploy
# Use your broker
dlzoom login --auth-url https://<your-worker>.workers.dev
# Or set permanently: export DLZOOM_AUTH_URL=https://<your-worker>.workers.devThe Worker supports automatic CI/CD via Cloudflare's Git integration (pushes to main auto-deploy, PRs get preview URLs). See zoom-broker/DEPLOYMENT.md for setup details.
dlzoom now auto-loads S2S credentials from your platform config directory, so S2S works from any folder (just like OAuth tokens). Create one config file and be done:
| Platform | Config directory | Example path |
|---|---|---|
| Linux / WSL / other Unix | ~/.config/dlzoom/ |
~/.config/dlzoom/config.json |
| macOS | ~/Library/Application Support/dlzoom/ |
~/Library/Application Support/dlzoom/config.json |
| Windows | %APPDATA%\dlzoom\ |
%APPDATA%\dlzoom\config.json |
The CLI looks for config.json, config.yaml, or config.yml (in that order). Example JSON:
{
"zoom_account_id": "your_account_id",
"zoom_client_id": "your_client_id",
"zoom_client_secret": "your_client_secret",
"zoom_s2s_default_user": "host@example.com" // optional
}Example YAML:
zoom_account_id: your_account_id
zoom_client_id: your_client_id
zoom_client_secret: your_client_secretTips:
- Create the directory if it doesn’t exist and set restrictive permissions (
chmod 600on macOS/Linux). - Use JSON/YAML interchangeably—fields match their environment variable counterparts.
- Add optional defaults like
log_level,output_dir, orzoom_s2s_default_user.
export ZOOM_ACCOUNT_ID="your_account_id"
export ZOOM_CLIENT_ID="your_client_id"
export ZOOM_CLIENT_SECRET="your_client_secret"This is ideal for CI/CD. Env vars override the user config file (unless you pass an explicit --config path).
- dlzoom automatically loads the first
.envfile it finds when walking up from the current directory (without clobbering existing env vars). SetDLZOOM_NO_DOTENV=1to skip this behavior. - For multiple Zoom accounts, point commands at explicit files:
dlzoom download --config ./account-b.yaml 123456789 - Priority (highest → lowest): explicit
--config, environment variables, user config file, project.env, defaults.
Optional scopes for User OAuth (improve fidelity):
- Required:
cloud_recording:read:list_user_recordings,cloud_recording:read:list_recording_files - Optional:
meeting:read:meeting(better recurrence detection),user:read:user(enableswhoamidetails)
Use --filename-template and --folder-template to structure outputs.
Variables:
{topic},{meeting_id},{host_email}{start_time:%Y%m%d}(strftime format),{duration}
Examples:
dlzoom download 123456789 \
--filename-template "{start_time:%Y%m%d}_{topic}"
dlzoom download 123456789 \
--folder-template "{start_time:%Y}/{start_time:%m}"
dlzoom download 123456789 \
--filename-template "{host_email}_{topic}_{start_time:%Y%m%d}"Example GitHub Actions job (S2S):
name: archive-zoom
on:
schedule: [{ cron: '0 3 * * *' }]
workflow_dispatch: {}
jobs:
run:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
- run: uv tool install dlzoom
- env:
ZOOM_ACCOUNT_ID: ${{ secrets.ZOOM_ACCOUNT_ID }}
ZOOM_CLIENT_ID: ${{ secrets.ZOOM_CLIENT_ID }}
ZOOM_CLIENT_SECRET: ${{ secrets.ZOOM_CLIENT_SECRET }}
run: |
mkdir -p recordings
dlzoom recordings --range last-7-days -j > list.json
dlzoom download 123456789 --output-dir recordings --json > recording.jsondlzoom download — options
dlzoom download [OPTIONS] MEETING_ID
Options:
--output-dir, -o PATH Output directory (default: current directory)
--output-name, -n TEXT Base filename (default: meeting_id)
--verbose, -v Show detailed operation information
--debug, -d Show full API responses and trace
--json, -j JSON output mode (machine-readable)
--check-availability, -c Check if recording is ready
--recording-id TEXT Select specific recording by UUID
--wait MINUTES Wait for recording processing (timeout)
--skip-transcript Skip transcript download
--skip-chat Skip chat log download
--skip-timeline Skip timeline download
--skip-speakers Do not generate minimal STJ speakers file (default: generate)
--speakers-mode [first|multiple]
When multiple users are listed for a timestamp (default: first)
--stj-min-seg-sec FLOAT Drop segments shorter than this duration (seconds) [default: 1.0]
--stj-merge-gap-sec FLOAT Merge adjacent same-speaker segments within this gap (seconds) [default: 1.5]
--include-unknown Include segments with unknown speaker (otherwise drop)
--dry-run Show what would be downloaded
--log-file PATH Write structured log (JSONL format)
--config PATH Path to config file (JSON/YAML)
--filename-template TEXT Custom filename template
--folder-template TEXT Custom folder structure template
--help Show this message and exit
--version Show version and exit
dlzoom recordings — options
dlzoom recordings [OPTIONS]
User-wide mode (default):
--from-date TEXT Start date (YYYY-MM-DD)
--to-date TEXT End date (YYYY-MM-DD)
--range [today|yesterday|last-7-days|last-30-days]
Quick date range (exclusive with --from-date/--to-date)
--topic TEXT Substring filter on topic
--limit INTEGER Max results (0 = unlimited) [default: 1000]
--page-size INTEGER [Advanced] Results per API request (Zoom max 300) [default: 300]
Meeting-scoped mode (replaces `download --list`):
--meeting-id TEXT Exact meeting ID or UUID to list instances
Common options:
--json, -j JSON output mode (silent)
--verbose, -v Verbose human output
--debug, -d Debug logging
--config PATH Path to config file
--help Show this message and exit
Common errors and fixes:
- Authentication failed → Ensure your platform config file (
~/.config/dlzoom/config.json,~/Library/Application Support/dlzoom/config.json, or%APPDATA%\dlzoom\config.json) or env vars containZOOM_ACCOUNT_ID,ZOOM_CLIENT_ID,ZOOM_CLIENT_SECRET. - Invalid meeting ID → Paste only the ID/UUID. Spaces are fine; they’re removed automatically.
- ffmpeg not found → Install ffmpeg (or use Docker image). Needed when audio-only is unavailable and dlzoom extracts audio from MP4.
- No secrets in logs; rigorous input validation; atomic file writes.
- User OAuth tokens stored under your OS config directory (0600): macOS
~/Library/Application Support/dlzoom/tokens.json, Linux~/.config/dlzoom/tokens.json, Windows%APPDATA%\dlzoom\tokens.json. Override withDLZOOM_TOKENS_PATHif needed. S2S credentials via env or config file. - OAuth broker: restrict CORS with
ALLOWED_ORIGINin production. Seezoom-broker/README.md. - If your working directory is cloud‑synced (iCloud/Dropbox/etc.), consider env vars instead of a
.envfile or place config outside the synced folder.
We welcome contributions! See CONTRIBUTING.md. Please also review our CODE_OF_CONDUCT.md.
Quick start for contributors:
python3.11 -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
pytest tests/ -v- Releases: see CHANGELOG.md
- Roadmap highlights:
- 🎨 More output formats (TSV)
- 🔐 Token encryption via system keychain
- 📱 Multiple profiles support
- 📦 Optional SBOM generation in CI
MIT — see LICENSE.
- 🐛 Report bugs
- 💡 Request features
- 💬 GitHub Discussions
- 📖 Documentation / Architecture: see
docs/
Built with:
- Click / Rich‑Click — CLI framework
- Rich — terminal output
- Requests — HTTP client
- pytest — testing framework
