Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

zoom-recorder-bot

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.


Table of contents


Features

  • 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>.mp3 via a PulseAudio null-sink + FFmpeg.
  • On-demand and scheduled - POST /join to record now, or POST /schedules for 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.

How it works

POST /join -> SessionManager -> RecordingSession
                                      |
                                      |- Xvfb              (virtual display :N)
                                      |- PulseAudio        (null-sink = virtual speaker)
                                      |- Chromium/Playwright -> Zoom Web Client
                                      \- FFmpeg  (sink monitor -> /recordings/<job>.mp3)
  1. A request creates a RecordingSession with a unique job_id.
  2. The session allocates a free virtual X display (Xvfb) and a dedicated PulseAudio null-sink, so its audio never mixes with other concurrent sessions.
  3. 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.
  4. FFmpeg records the sink's monitor (everything the bot "hears") into an MP3.
  5. When the meeting ends, you call /stop, or the timeout is reached, FFmpeg finalizes the file and all resources are torn down. If a webhook_url was given, a final callback is sent.

Project layout

.
├── 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

Quick start

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.

Configuration

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

HTTP API

The service listens on container port 8000 (mapped to host 8000 in docker-compose.yml). All bodies are JSON.

Recording

POST /join - start recording a meeting. Returns 202 Accepted with {job_id, status}. Recording happens in the background.

{
  "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)
}
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

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 | monthly
  • days: 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"
  }'

Webhooks

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.

Supported URLs

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).

Continuous recording across restarts

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.


Deployment

1. Server requirements

  • 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 in docker-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.

2. Install Docker

curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER   # log out/in so the group takes effect

3. Run the service

git 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.

4. Run on boot (systemd)

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.target
sudo systemctl daemon-reload
sudo systemctl enable --now zoom-recorder

5. Reverse proxy + TLS (nginx)

Do 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.

6. Protect the API

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.


Client example (Python)

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).

Troubleshooting

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

Limitations

  • 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 - /join sessions are not persisted across restarts (only schedules are). A restart abandons any in-flight recordings.

Disclaimer

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.

License

MIT - see LICENSE.

About

A self-contained service that joins Zoom meetings through a headless Chromium browser and records the meeting audio to an MP3 file

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages