A self-contained service that joins Zoom meetings through a headless Chromium browser and records the meeting audio to an MP3 file. It exposes a small HTTP API for joining meetings on demand or on a schedule, and runs entirely in Docker.
It is published for educational purposes and for personal use - to let you record meetings you take part in yourself, with the consent of everyone present, so you can focus on the conversation instead of taking notes.
No Zoom desktop app, no Zoom SDK key and no API credentials are required. The bot joins via the public Zoom Web Client, behaves like a normal participant, sets a display name, mutes its own microphone and camera, handles the passcode and waiting room, and records the mixed meeting audio through a virtual audio device.
Recording consent. The bot is a visible participant, but you are still responsible for complying with the laws and meeting-host policies that apply to recording. Make sure every participant has consented.
- Features
- How it works
- Project layout
- Quick start
- Configuration
- HTTP API
- Supported URLs
- Continuous recording across restarts
- Deployment
- Client example (Python)
- Troubleshooting
- Limitations
- Disclaimer
- License
- Headless - joins Zoom via Playwright-driven Chromium on a virtual display; no GUI, no Zoom client install.
- Audio recording - captures the meeting's mixed audio to
recordings/<job_id>.mp3via a PulseAudio null-sink + FFmpeg. - On-demand and scheduled -
POST /jointo record now, orPOST /schedulesfor one-time and recurring (daily / weekly / biweekly / monthly) recordings. - Concurrent sessions - each recording runs on its own virtual X display and audio sink, so multiple meetings record in parallel.
- Passcode, waiting room, mute - filled / handled automatically.
- Multiple URLs per job - pass a list and the bot tries them in order.
- Auto-reconnect - optionally rejoins if the meeting ends and the host restarts it, so a recurring or restarted meeting is captured as one continuous session.
- Webhooks - optional callbacks on start, progress, reconnect and completion, so you can plug in your own post-processing (transcription, summaries, storage, ...).
- Stateless integration - the service has no external dependencies; everything is driven over plain HTTP.
POST /join -> SessionManager -> RecordingSession
|
|- Xvfb (virtual display :N)
|- PulseAudio (null-sink = virtual speaker)
|- Chromium/Playwright -> Zoom Web Client
\- FFmpeg (sink monitor -> /recordings/<job>.mp3)
- A request creates a
RecordingSessionwith a uniquejob_id. - The session allocates a free virtual X display (Xvfb) and a dedicated PulseAudio null-sink, so its audio never mixes with other concurrent sessions.
- Chromium launches on that display with fake media devices (silent mic, black camera), navigates to the Zoom Web Client, fills the name / passcode, mutes itself, and enters the meeting.
- FFmpeg records the sink's monitor (everything the bot "hears") into an MP3.
- When the meeting ends, you call
/stop, or the timeout is reached, FFmpeg finalizes the file and all resources are torn down. If awebhook_urlwas given, a final callback is sent.
.
├── app/
│ ├── main.py # FastAPI app + all HTTP endpoints
│ ├── session.py # RecordingSession: the full record lifecycle
│ ├── session_manager.py # concurrency, display allocation, final webhook
│ ├── bot.py # MeetingBot (browser infra) + ZoomWebBot (Zoom UI flow)
│ ├── recorder.py # FFmpeg start/stop, duration probe
│ ├── scheduler.py # APScheduler-based one-time/recurring schedules
│ ├── models.py # ScheduleEntry data model + JSON (de)serialization
│ ├── utils.py # Zoom URL parsing, display allocation helper
│ └── exceptions.py # typed errors (kicked, wrong password, timeouts, ...)
├── docker/
│ ├── entrypoint.sh # starts D-Bus, PulseAudio, then uvicorn
│ ├── pulse-default.pa
│ └── pulse-client.conf
├── Dockerfile
├── docker-compose.yml
├── requirements.txt
└── .env.example
cp .env.example .env # optional: tweak defaults
docker compose up -d --build
# Join a meeting and record it for up to 60 minutes:
curl -X POST http://localhost:8000/join \
-H 'Content-Type: application/json' \
-d '{"url": "https://zoom.us/j/12345678901?pwd=abcdef", "display_name": "Recorder", "duration_minutes": 60}'
# => {"job_id": "…", "status": "starting"}The recording appears at ./recordings/<job_id>.mp3. Follow progress with
docker compose logs -f zoom-bot.
All configuration is via environment variables (see .env.example):
| Variable | Default | Description |
|---|---|---|
BOT_DISPLAY_NAME |
Recording Bot |
Name shown in the meeting when a request omits display_name |
MAX_CONCURRENT_SESSIONS |
5 |
Max meetings recorded simultaneously |
SESSION_TIMEOUT_HOURS |
4 |
Hard cap on a recording when no duration_minutes / stop_at is given |
RECORDING_PROGRESS_INTERVAL_MIN |
30 |
Interval for progress webhook events |
LOG_LEVEL |
INFO |
Python logging level |
The service listens on container port 8000 (mapped to host 8000 in
docker-compose.yml). All bodies are JSON.
POST /join - start recording a meeting. Returns 202 Accepted with
{job_id, status}. Recording happens in the background.
| Endpoint | Method | Description |
|---|---|---|
/join |
POST | Start a recording (above). 202 |
/stop/{job_id} |
POST | Stop a running recording; the MP3 is finalized |
/status/{job_id} |
GET | Current status of a session |
/sessions |
GET | List sessions in the current process (not persisted) |
/recordings |
GET | List MP3 files on disk with sizes |
/health |
GET | {"status": "ok"} |
Session status values: starting, joining, recording, ended, error,
stopped.
Schedules are persisted to schedules/schedules.json and survive restarts.
| Endpoint | Method | Description |
|---|---|---|
/schedules |
POST | Create a one-time (run_at) or recurring schedule. 201 |
/schedules |
GET | List all schedules |
/schedules/{id} |
GET | Fetch one |
/schedules/{id} |
PATCH | `{"active": true |
/schedules/{id} |
DELETE | Remove. 204 |
/schedules/{id}/exceptions |
POST | {"date": "YYYY-MM-DD"} to skip one occurrence |
/schedules/{id}/exceptions/{date} |
DELETE | Remove a skip |
Recurring fields:
frequency:daily|weekly|biweekly|monthlydays: for weekly / biweekly, e.g.["mon","wed","fri"]day_of_month: for monthly (1-31)time:"HH:MM"timezone: IANA name, e.g."Europe/Moscow"end_date: optional ISO datetime after which the schedule stops
Example - record a standup every weekday at 10:00 Moscow time:
curl -X POST http://localhost:8000/schedules \
-H 'Content-Type: application/json' \
-d '{
"url": "https://zoom.us/j/12345678901?pwd=abc",
"frequency": "weekly",
"days": ["mon","tue","wed","thu","fri"],
"time": "10:00",
"timezone": "Europe/Moscow",
"duration_minutes": 45,
"webhook_url": "http://my-app/recorded"
}'If a request includes webhook_url, the bot POSTs JSON to it at key moments. The
base payload is:
{
"job_id": "…",
"schedule_id": null,
"status": "ended",
"error": null,
"recording_path": "/recordings/<job_id>.mp3",
"duration_seconds": 3600.0,
"started_at": "2026-06-03T10:00:05Z",
"ended_at": "2026-06-03T11:00:05Z"
}Events (distinguished by an extra event field):
| When | Payload |
|---|---|
| Recording started | base payload |
| Progress tick | base + "event": "progress", "elapsed_seconds" (every RECORDING_PROGRESS_INTERVAL_MIN) |
| Reconnecting | base + "event": "reconnecting", "remaining_seconds" (only with allow_reconnect) |
| Finished | base payload, sent once when the session ends for any reason |
recording_path is the path inside the container (/recordings/...), which maps
to ./recordings/... on the host. Mount that volume into your consumer to read files.
Only Zoom Web Client links are accepted:
https://zoom.us/j/<meeting_id>https://<subdomain>.zoom.us/j/<meeting_id>?pwd=<passcode>
zoommtg:// deep links are not supported. The pwd query parameter is used as the
passcode; you can also rely on the in-meeting passcode prompt if the meeting requires
one and it is not in the URL (the bot will fail with wrong_password if none is
available).
Sometimes a meeting ends and the host immediately starts it again (for example a
recurring call that restarts on the host's side). With allow_reconnect: true and a
reconnect_window_minutes (or duration / stop_at) that covers the gap, the bot
keeps retrying to rejoin after the meeting ends, so the restarted meeting is captured
as one continuous session. Each rejoin continues writing to the same MP3 output.
- A Linux host where you control the Docker runtime (a plain VPS or dedicated
server). The container needs elevated capabilities for Chromium
(
SYS_ADMIN,seccomp:unconfined,shm_size: 2gb), already set indocker-compose.yml. Many managed/serverless container platforms forbid these, so prefer a VPS. - ~2 GB RAM free per concurrent recording (Chromium is the heavy part), plus disk for the MP3s.
- Outbound HTTPS to
zoom.us.
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER # log out/in so the group takes effectgit clone <your-repo-url> zoom-recorder-bot
cd zoom-recorder-bot
cp .env.example .env # edit if needed
docker compose up -d --build
curl http://localhost:8000/health # {"status":"ok"}The first build downloads Chromium and its dependencies and takes a few minutes.
Recordings land in ./recordings, schedules persist in ./schedules.
restart: unless-stopped in compose already restarts the container after a crash or
a Docker restart. To also start the whole stack on boot via systemd:
# /etc/systemd/system/zoom-recorder.service
[Unit]
Description=zoom-recorder-bot
Requires=docker.service
After=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/opt/zoom-recorder-bot
ExecStart=/usr/bin/docker compose up -d --build
ExecStop=/usr/bin/docker compose down
[Install]
WantedBy=multi-user.targetsudo systemctl daemon-reload
sudo systemctl enable --now zoom-recorderDo not expose port 8000 to the public internet directly. Bind it to localhost
and put a TLS-terminating reverse proxy in front. Change the compose port mapping to
"127.0.0.1:8000:8000", then:
server {
listen 443 ssl;
server_name recorder.example.com;
ssl_certificate /etc/letsencrypt/live/recorder.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/recorder.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_read_timeout 300s; # long-running joins
}
}Use certbot for the certificate.
The service has no built-in authentication - anyone who can reach it can start recordings. Always restrict access. Options:
-
HTTP Basic auth at the proxy (simplest):
sudo htpasswd -c /etc/nginx/.htpasswd recorder
location / { auth_basic "recorder"; auth_basic_user_file /etc/nginx/.htpasswd; proxy_pass http://127.0.0.1:8000; }
-
Firewall to a known client IP (
ufw allow from <ip> to any port 443). -
Private network / VPN (e.g. WireGuard, Tailscale) and never publish the port.
import time
import httpx
BASE = "https://recorder.example.com"
AUTH = ("recorder", "your-password") # if using Basic auth
# 1. Start recording
r = httpx.post(f"{BASE}/join", auth=AUTH, json={
"url": "https://zoom.us/j/12345678901?pwd=abc",
"display_name": "Recorder",
"duration_minutes": 60,
})
job_id = r.json()["job_id"]
print("started", job_id)
# 2. Poll until it finishes
while True:
s = httpx.get(f"{BASE}/status/{job_id}", auth=AUTH).json()
print(s["status"])
if s["status"] in ("ended", "error", "stopped"):
print("recording at", s["filename"])
break
time.sleep(10)To stop early: httpx.post(f"{BASE}/stop/{job_id}", auth=AUTH).
| Symptom | Likely cause / fix |
|---|---|
join_timeout: Name input not found |
Zoom UI changed, slow network, or wrong URL. Check docker compose logs zoom-bot; the bot saves screenshots to /tmp/zoom_bot_*.png inside the container. |
wrong_password |
Meeting needs a passcode and none was provided in pwd or the prompt. |
meeting_not_found |
The meeting has not started or the ID is invalid. |
| Container exits with PulseAudio error | The host blocks the required capabilities; ensure SYS_ADMIN + seccomp:unconfined + shm_size are allowed. |
| Empty / silent MP3 | The bot never actually entered the meeting, or audio failed to auto-join. Check logs around audio modal. |
429 Maximum concurrent sessions reached |
Raise MAX_CONCURRENT_SESSIONS (and give the host more RAM). |
Inspect a session at any time:
docker compose logs -f zoom-bot
docker compose exec zoom-bot ls -la /tmp # screenshots- Audio only - the MP3 contains the meeting's mixed audio; no video is recorded.
- No auth built in - protect it at the network / proxy layer (see above).
- UI-coupled - selectors target the current Zoom Web Client and may need updating
if Zoom changes its UI; they live in
app/bot.py(SELECTORS). - In-memory session list -
/joinsessions are not persisted across restarts (only schedules are). A restart abandons any in-flight recordings.
This project is published for educational purposes and for personal use by individuals recording meetings they themselves take part in, with the consent of all participants. It is not a tool for covert recording, for circumventing access controls, or for joining meetings you are not entitled to join.
The software is provided "as is", without warranty of any kind, express or implied (see LICENSE). The author is not responsible for how others use this software. You are solely responsible for ensuring that your use complies with all applicable laws (including recording- and privacy-consent laws) and with the terms of any service you connect it to.
Automating a web client may conflict with the operator's Terms of Service. Using this software against a third-party service is done at your own risk, and you are responsible for reviewing and complying with that service's terms.
This project implements general-purpose browser automation and contains no code, assets, or credentials belonging to any third party. "Zoom" is a trademark of Zoom Video Communications, Inc. This project is independent and is not affiliated with, endorsed by, or sponsored by Zoom Video Communications, Inc. The name is used only nominatively to describe interoperability.
MIT - see LICENSE.
{ "url": "https://zoom.us/j/12345678901?pwd=abc", // single URL, or: "urls": ["https://zoom.us/j/111...", "https://zoom.us/j/222..."], // tried in order "display_name": "Recorder", // optional; falls back to BOT_DISPLAY_NAME "duration_minutes": 60, // optional hard stop "stop_at": "2026-06-03T15:00:00Z", // optional absolute stop (overrides duration) "allow_reconnect": false, // rejoin if the host ends the meeting early "reconnect_window_minutes": 90, // how long to keep retrying when allow_reconnect "webhook_url": "http://you/hook" // optional callback (see Webhooks) }