DevLab / integrations / zoom-meeting-scheduler-via-api
INTEGRATIONS PYTHON BEGINNER

Zoom Meeting Scheduler via API

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

Python3.10+
OSLinux / macOS / Windows
Lines~191
Packages1 deps
Install Dependencies
$ pip install requests
Usage
$ python3 zoom_schedule.py --topic 'Sprint Review' --start '2025-09-01T14:00:00' --duration 60
#integrations#python#beginner#requests

Zoom Meeting Scheduler via API

Problem Statement

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.

How It Works

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:

  1. Read ZOOM_ACCOUNT_ID, ZOOM_CLIENT_ID, and ZOOM_CLIENT_SECRET from the environment.
  2. POST them to https://zoom.us/oauth/token with grant_type=account_credentials to receive a short-lived bearer token (valid for ~1 hour).
  3. POST a JSON payload to https://api.zoom.us/v2/users/me/meetings containing the topic, start time, duration, and settings.
  4. Parse the JSON response and print the 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.

Full Script

#!/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())

Configuration

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:

  1. Go to marketplace.zoom.us → Develop → Build App → Server-to-Server OAuth.
  2. Copy the Account ID, Client ID, and Client Secret.
  3. Under Scopes, add meeting:write:admin (or meeting:write for user-level).
  4. Activate the app.

Store secrets in your shell profile, a .env file loaded by direnv, or your CI/CD secret manager — never commit them.

Usage Examples

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

Error Handling

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.

Extending Ideas

  • Recurring meetings: Set "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.
  • Slack integration: Wrap main() in a small function and post join_url to a Slack webhook using requests.post(SLACK_WEBHOOK, json={"text": url}).
  • Calendar invites: After creating the meeting, feed the join_url and start_time into icalendar or Google Calendar API to send .ics invitations to attendees.
  • Token caching across calls: Persist the bearer token (with expiry) to a temp file or Redis so a long-running worker doesn't re-authenticate on every invocation.
  • Attendee registration: Switch approval_type to 1 and add a registrants list to require sign-up before joining — useful for webinars and training sessions.
// RELATED_SCRIPTS
← Back to DevLab valtersit.com