Scheduling a Zoom meeting is trivial when you're sitting in the Zoom web UI — but it becomes painful the moment you need to automate it. Release managers w
Scheduling a Zoom meeting is trivial when you're sitting in the Zoom web UI — but it becomes painful the moment you need to automate it. Release managers want to spin up a recurring standup the moment a CI pipeline passes. DevOps teams want a script that creates a "Post-Incident Review" meeting whenever PagerDuty fires a critical alert. Support engineers want to generate a one-off client call from a ticket and drop the join link back into the ticket automatically.
This script gives you a single, dependency-light command-line tool that talks directly to the Zoom REST API v2 to create a scheduled meeting and print the join URL. It reads credentials from environment variables, validates the start time and duration, handles common API errors gracefully, and returns clean machine-readable output so it can be piped into Slack, email, or a ticketing system.
The script uses Zoom's Server-to-Server OAuth flow (the modern, recommended replacement for JWT apps, which Zoom deprecated in June 2023). The flow is:
ZOOM_ACCOUNT_ID, ZOOM_CLIENT_ID, and ZOOM_CLIENT_SECRET from the environment.https://zoom.us/oauth/token with grant_type=account_credentials to receive a short-lived bearer token (valid for ~1 hour).https://api.zoom.us/v2/users/me/meetings containing the topic, start time, duration, and settings.join_url, start_url, meeting ID, and passcode.The script is written as a small ZoomClient class that encapsulates the token exchange and request logic, with a thin argparse CLI on top. This separation makes it easy to import ZoomClient into other tools (a Slack bot, an Airflow task, a Lambda handler) without duplicating auth code. All network calls use requests.Session for connection reuse, and every failure mode raises a specific exception with a human-readable message.
#!/usr/bin/env python3
"""
Zoom Meeting Scheduler via API
Creates a scheduled Zoom meeting via the Zoom REST API v2 using
Server-to-Server OAuth credentials, and prints the join URL.
Usage:
python3 zoom_schedule.py --topic "Sprint Review" \
--start "2025-09-01T14:00:00" --duration 60
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from datetime import datetime
from typing import Any
import requests
# ---------------------------------------------------------------------------
# Configuration (all overridable via environment variables)
# ---------------------------------------------------------------------------
ZOOM_ACCOUNT_ID = os.environ.get("ZOOM_ACCOUNT_ID", "")
ZOOM_CLIENT_ID = os.environ.get("ZOOM_CLIENT_ID", "")
ZOOM_CLIENT_SECRET = os.environ.get("ZOOM_CLIENT_SECRET", "")
ZOOM_USER_ID = os.environ.get("ZOOM_USER_ID", "me") # "me" = the token owner
ZOOM_TIMEZONE = os.environ.get("ZOOM_TIMEZONE", "UTC")
ZOOM_OAUTH_URL = "https://zoom.us/oauth/token"
ZOOM_API_BASE = "https://api.zoom.us/v2"
HTTP_TIMEOUT = 15 # seconds
class ZoomError(RuntimeError):
"""Raised when the Zoom API returns an error or credentials are missing."""
class ZoomClient:
"""Minimal Zoom REST API v2 client using Server-to-Server OAuth."""
def __init__(
self,
account_id: str,
client_id: str,
client_secret: str,
timeout: int = HTTP_TIMEOUT,
) -> None:
if not (account_id and client_id and client_secret):
raise ZoomError(
"Missing Zoom credentials. Set ZOOM_ACCOUNT_ID, "
"ZOOM_CLIENT_ID, and ZOOM_CLIENT_SECRET."
)
self._account_id = account_id
self._client_id = client_id
self._client_secret = client_secret
self._timeout = timeout
self._session = requests.Session()
self._access_token: str | None = None
# -- auth ---------------------------------------------------------------
def _get_access_token(self) -> str:
"""Exchange S2S credentials for a bearer token (cached per instance)."""
if self._access_token:
return self._access_token
resp = self._session.post(
ZOOM_OAUTH_URL,
params={
"grant_type": "account_credentials",
"account_id": self._account_id,
},
auth=(self._client_id, self._client_secret),
timeout=self._timeout,
)
if resp.status_code != 200:
raise ZoomError(
f"OAuth failed ({resp.status_code}): {resp.text}"
)
self._access_token = resp.json()["access_token"]
return self._access_token
def _headers(self) -> dict[str, str]:
return {
"Authorization": f"Bearer {self._get_access_token()}",
"Content-Type": "application/json",
}
# -- meetings -----------------------------------------------------------
def create_meeting(
self,
topic: str,
start_time: str,
duration_minutes: int,
timezone: str = ZOOM_TIMEZONE,
agenda: str = "",
user_id: str = ZOOM_USER_ID,
) -> dict[str, Any]:
"""Create a scheduled meeting and return the raw API response dict."""
payload: dict[str, Any] = {
"topic": topic,
"type": 2, # 2 = scheduled meeting (1=instant, 3=recurring, 8=recurring fixed)
"start_time": start_time,
"duration": duration_minutes,
"timezone": timezone,
"agenda": agenda,
"settings": {
"join_before_host": False,
"waiting_room": True,
"mute_upon_entry": True,
"approval_type": 2, # 2 = no registration required
},
}
url = f"{ZOOM_API_BASE}/users/{user_id}/meetings"
resp = self._session.post(
url, headers=self._headers(), json=payload, timeout=self._timeout
)
if resp.status_code not in (200, 201):
# Zoom returns structured errors; surface them clearly.
try:
detail = resp.json().get("message", resp.text)
except ValueError:
detail = resp.text
raise ZoomError(
f"Meeting creation failed ({resp.status_code}): {detail}"
)
return resp.json()
def _validate_start(value: str) -> str:
"""Ensure the start time is ISO-8601 (naive or with offset)."""
try:
datetime.fromisoformat(value)
except ValueError as exc:
raise argparse.ArgumentTypeError(
f"Invalid --start '{value}'. Use ISO-8601, e.g. 2025-09-01T14:00:00"
) from exc
return value
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
description="Schedule a Zoom meeting via the Zoom API v2.",
epilog="Requires ZOOM_ACCOUNT_ID, ZOOM_CLIENT_ID, ZOOM_CLIENT_SECRET.",
)
p.add_argument("--topic", required=True, help="Meeting topic/title.")
p.add_argument(
"--start",
required=True,
type=_validate_start,
help="Start time in ISO-8601 (e.g. 2025-09-01T14:00:00).",
)
p.add_argument(
"--duration",
type=int,
default=60,
help="Duration in minutes (default: 60).",
)
p.add_argument("--agenda", default="", help="Optional meeting agenda.")
p.add_argument(
"--timezone",
default=ZOOM_TIMEZONE,
help=f"IANA timezone (default: {ZOOM_TIMEZONE}).",
)
p.add_argument(
"--json",
action="store_true",
help="Emit the full API response as JSON instead of a summary.",
)
return p
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
if args.duration <= 0:
print("error: --duration must be a positive integer", file=sys.stderr)
return 2
try:
client = ZoomClient(
account_id=ZOOM_ACCOUNT_ID,
client_id=ZOOM_CLIENT_ID,
client_secret=ZOOM_CLIENT_SECRET,
)
meeting = client.create_meeting(
topic=args.topic,
start_time=args.start,
duration_minutes=args.duration,
timezone=args.timezone,
agenda=args.agenda,
)
except ZoomError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
except requests.RequestException as exc:
print(f"network error: {exc}", file=sys.stderr)
return 1
if args.json:
print(json.dumps(meeting, indent=2))
else:
print(f"Meeting created: {meeting.get('topic')}")
print(f" ID: {meeting.get('id')}")
print(f" Start: {meeting.get('start_time')} ({meeting.get('timezone')})")
print(f" Duration: {meeting.get('duration')} min")
print(f" Join URL: {meeting.get('join_url')}")
print(f" Host URL: {meeting.get('start_url')}")
if meeting.get("password"):
print(f" Passcode: {meeting.get('password')}")
return 0
if __name__ == "__main__":
sys.exit(main())
| Variable | Required | Default | Purpose |
|---|---|---|---|
ZOOM_ACCOUNT_ID |
✅ | — | Account ID from your Server-to-Server OAuth app. |
ZOOM_CLIENT_ID |
✅ | — | Client ID of the S2S OAuth app. |
ZOOM_CLIENT_SECRET |
✅ | — | Client secret of the S2S OAuth app. |
ZOOM_USER_ID |
❌ | me |
Email or user ID whose calendar the meeting is created on. me = the token owner. |
ZOOM_TIMEZONE |
❌ | UTC |
IANA timezone used when --start has no offset. |
To get the three required values:
meeting:write:admin (or meeting:write for user-level).Store secrets in your shell profile, a .env file loaded by direnv, or your CI/CD secret manager — never commit them.
1. Standard one-hour meeting:
$ export ZOOM_ACCOUNT_ID="abc123"
$ export ZOOM_CLIENT_ID="xyz789"
$ export ZOOM_CLIENT_SECRET="supersecret"
$ python3 zoom_schedule.py \
--topic "Sprint Review" \
--start "2025-09-01T14:00:00" \
--duration 60 \
--timezone "America/New_York"
Meeting created: Sprint Review
ID: 91234567890
Start: 2025-09-01T14:00:00 (America/New_York)
Duration: 60 min
Join URL: https://zoom.us/j/91234567890?pwd=abc...
Host URL: https://zoom.us/s/91234567890?zak=...
Passcode: 4aX9pQ
2. Machine-readable output for piping into Slack:
$ python3 zoom_schedule.py \
--topic "Post-Incident Review" \
--start "2025-09-02T09:30:00" \
--duration 30 \
--json | jq -r '.join_url'
https://zoom.us/j/91234567890?pwd=abc...
3. Missing credentials — clean failure:
$ unset ZOOM_CLIENT_SECRET
$ python3 zoom_schedule.py --topic "Test" --start "2025-09-01T14:00:00"
error: Missing Zoom credentials. Set ZOOM_ACCOUNT_ID, ZOOM_CLIENT_ID, and ZOOM_CLIENT_SECRET.
$ echo $?
1
| Failure | Detection | Behaviour |
|---|---|---|
| Missing env vars | ZoomClient.__init__ |
Raises ZoomError, printed to stderr, exit code 1. |
| Bad OAuth credentials | Non-200 from /oauth/token |
Raises ZoomError with response body. |
Invalid --start format |
argparse type validator |
Prints usage error, exit code 2. |
Non-positive --duration |
Explicit check in main |
Prints error, exit code 2. |
| Zoom API error (e.g. 429 rate limit, 400 bad payload) | Non-2xx from /meetings |
Extracts Zoom's message field and raises ZoomError. |
| Network timeouts / DNS failures | requests.RequestException |
Caught in main, exit code 1. |
| Malformed JSON from Zoom | resp.json() ValueError |
Falls back to raw text in the error message. |
The script never prints secrets or tokens. All errors go to stderr so stdout stays clean for piping.
"type": 8 in the payload and add a recurrence object ({"type": 2, "repeat_interval": 1, "weekly_days": "1,3,5"}) for a Mon/Wed/Fri standup.main() in a small function and post join_url to a Slack webhook using requests.post(SLACK_WEBHOOK, json={"text": url}).join_url and start_time into icalendar or Google Calendar API to send .ics invitations to attendees.approval_type to 1 and add a registrants list to require sign-up before joining — useful for webinars and training sessions.