Zoom API skill for Codex

Create meetings and trust every Zoom webhook. Codex picks the right Zoom app type, caches Server-to-Server OAuth tokens instead of fetching one per call, and creates meetings with a stored booking key so retries never double-book. Webhooks are verified against x-zm-signature, pass URL validation, answer within three seconds and are deduplicated, and recordings download with the event's token.

  • Integrations
  • $zoom-api
  • 177 lines
  • 2 files
  • ~2,100 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- zoom-api

Installs to ~/.agents/skills/zoom-api/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $zoom-api, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Integrates with the Zoom REST API v2 and Zoom webhooks - Server-to-Server OAuth and user OAuth apps, creating and updating meetings and webinars, participants and cloud recordings, x-zm-signature verification, endpoint URL validation, rate-limit categories and the app deauthorization flow. Use when an app must create Zoom meetings, react to meeting.started or recording.completed events, download recordings, replace a deprecated JWT app, or debug 401, 429 or webhook validation failures.

Use it when

  • An app must create, update or cancel Zoom meetings or webinars
  • Handling Zoom webhook events such as meeting.ended or recording.completed
  • Migrating an old Zoom JWT app to Server-to-Server OAuth
  • Debugging Zoom 401, 429 or signature verification errors

Use something else when

How to check the result

A tampered webhook body returns 401, URL validation passes in the Marketplace, and creating a meeting twice for one booking produces a single Zoom meeting.

The mistake it prevents

Hashing the parsed and re-serialised JSON instead of the raw request body, so every signature check fails.

~/.agents/skills/zoom-api/SKILL.md

--- name: zoom-api description: Integrates with the Zoom REST API v2 and Zoom webhooks - Server-to-Server OAuth and user OAuth apps, creating and updating meetings and webinars,… license: MIT ---

Done well means: the app type matches who owns the data, tokens are fetched and cached on the server, every webhook is signature-checked and acknowledged within 3 seconds, and meetings are created idempotently so a retry never books two rooms.

Inspect first

  1. Read AGENTS.md; search for existing code: rg -n "zoom.us|api.zoom|ZOOM_" --glob '!node_modules'.
  2. Look for the old JWT app type (jsonwebtoken signing with an API key/secret against api.zoom.us). JWT apps were retired in 2023; migrate them to Server-to-Server OAuth.
  3. Confirm which Zoom account and app (Marketplace, “Develop” -> “Build App”) the credentials belong to. Calls need network access.

Choose the app type

SituationApp typeGrant
Your own company account, backend automationServer-to-Server OAuthaccount_credentials
Customers connect their own Zoom accountsGeneral app, user-managed or admin-managedAuthorization code (+ refresh)
Embed meetings in your web or mobile UIGeneral app with Meeting SDK enabledSDK JWT signed on your server

Use granular scopes and request only what the features need (meeting read/write, recording read, user read). Adding scopes later forces users to re-authorize.

Server-to-Server OAuth token

let cached: { token: string; exp: number } | null = null;

export async function zoomToken(): Promise<string> {
  if (cached && Date.now() < cached.exp - 60_000) return cached.token;
  const basic = Buffer.from(`${process.env.ZOOM_CLIENT_ID}:${process.env.ZOOM_CLIENT_SECRET}`).toString('base64');
  const res = await fetch(
    `https://zoom.us/oauth/token?grant_type=account_credentials&account_id=${process.env.ZOOM_ACCOUNT_ID}`,
    { method: 'POST', headers: { Authorization: `Basic ${basic}` } },
  );
  if (!res.ok) throw new Error(`zoom token ${res.status}: ${await res.text()}`);
  const body = await res.json();               // expires_in is 3600
  cached = { token: body.access_token, exp: Date.now() + body.expires_in * 1000 };
  return cached.token;
}

Share one cached token per process (or in Redis across workers); requesting a new token per call wastes rate limit. For user OAuth apps, refresh with grant_type=refresh_token; the response contains a new refresh token, so persist it atomically or the next refresh fails.

Meetings

async function zoom(path: string, init: RequestInit = {}) {
  const res = await fetch(`https://api.zoom.us/v2${path}`, {
    ...init,
    headers: { Authorization: `Bearer ${await zoomToken()}`, 'Content-Type': 'application/json', ...init.headers },
  });
  if (res.status === 429) throw new RetryableError(res.headers.get('Retry-After'));
  if (!res.ok) throw new Error(`zoom ${res.status} ${await res.text()}`);
  return res.status === 204 ? null : res.json();
}

const meeting = await zoom(`/users/${encodeURIComponent(hostEmail)}/meetings`, {
  method: 'POST',
  body: JSON.stringify({
    topic: 'Onboarding call',
    type: 2,                                 // 1 instant, 2 scheduled, 3 recurring no time, 8 recurring fixed
    start_time: '2026-11-03T15:00:00',       // local time interpreted in `timezone`
    timezone: 'Europe/Berlin',
    duration: 30,
    agenda: `booking:${bookingId}`,
    settings: { waiting_room: true, join_before_host: false, auto_recording: 'none' },
  }),
});
// store meeting.id, meeting.uuid, meeting.join_url; never expose start_url to attendees
  • With Server-to-Server apps, address users explicitly (/users/{userId or email}); me refers to the app’s creator, which is rarely the intended host.
  • Zoom has no idempotency key. Persist a pending row keyed by your booking ID before the call, and on retry search the host’s upcoming meetings (GET /users/{id}/meetings?type=upcoming) for your agenda tag before creating another.
  • Update with PATCH /meetings/{meetingId} (204, no body). Delete with DELETE /meetings/{meetingId}; add schedule_for_reminder=true only if cancellation emails are wanted. Deleting or emailing is outward-facing; confirm with the user for real accounts.
  • start_url contains a host token and expires; generate it on demand for the host only.
  • Meeting UUIDs that start with / or contain // must be double URL-encoded in paths (past instances, recordings).

Webhooks

Configure event subscriptions in the app, copy the Secret Token, and handle two things on the same endpoint: URL validation and signed events.

import crypto from 'node:crypto';

app.post('/webhooks/zoom', express.raw({ type: 'application/json' }), (req, res) => {
  const raw = req.body.toString('utf8');
  const ts = req.header('x-zm-request-timestamp') ?? '';
  const expected = 'v0=' + crypto.createHmac('sha256', process.env.ZOOM_WEBHOOK_SECRET!)
    .update(`v0:${ts}:${raw}`).digest('hex');
  const given = req.header('x-zm-signature') ?? '';
  const ok = given.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));
  if (!ok || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(401);

  const body = JSON.parse(raw);
  if (body.event === 'endpoint.url_validation') {
    const plainToken = body.payload.plainToken;
    const encryptedToken = crypto.createHmac('sha256', process.env.ZOOM_WEBHOOK_SECRET!)
      .update(plainToken).digest('hex');
    return res.json({ plainToken, encryptedToken });
  }
  queue.add('zoom-event', body, { jobId: `${body.event}:${body.payload?.object?.uuid}:${body.event_ts}` });
  res.sendStatus(200);
});
  • Respond 200 or 204 within 3 seconds; do work in a queue. Zoom retries only on 5xx and gives up after three retries (about 5, 20 and 60 minutes later), so a slow handler loses events.
  • Deduplicate on event name + object UUID + event_ts; retries resend the same payload.
  • Zoom re-validates the URL periodically; keep the validation branch in production code.
EventTypical action
meeting.started / meeting.endedUpdate session status, start billing or follow-up jobs
meeting.participant_joined / _leftAttendance; match on participant.email when present
recording.completedQueue download of recording_files[]
app_deauthorizedDelete that user’s tokens and data per Marketplace data-retention rules

Recordings

recording.completed includes a download_token and each file’s download_url. Download with Authorization: Bearer <download_token> (or an API token with recording scope), follow redirects, stream to storage, and verify file_size. Files can take minutes to be ready for large meetings; retry 404s with backoff. Deleting cloud recordings is irreversible on the Zoom side once trash is emptied; get explicit user confirmation before DELETE /meetings/{id}/recordings.

Rate limits

Limits depend on the account plan and the API’s category (Light, Medium, Heavy, Resource-intensive). On 429, honour Retry-After and the X-RateLimit-* headers, back off with jitter, and avoid polling; webhooks replace most polling needs. Some endpoints (for example meeting creation per user) also have daily caps.

Local testing

Expose the endpoint with a tunnel (see $webhook-testing-tools), paste the HTTPS URL into the event subscription, and press Validate. Save real payloads as fixtures, then replay them signed with the same v0:{ts}:{body} scheme in tests.

Verification

  • Token request returns 200 and is reused across calls (log one fetch per hour, not per request).
  • Creating a meeting twice with the same booking ID yields one Zoom meeting.
  • URL validation succeeds in the Marketplace UI; a payload with a tampered body returns 401.
  • A replayed duplicate event is processed once.
  • A recording download completes and its size matches file_size.

Gotchas

  • start_time with a trailing Z is UTC and ignores timezone; without Z it is local to timezone.
  • meeting.id (numeric) is reused by recurring meetings; uuid identifies one instance.
  • Parsing the JSON before hashing breaks the signature; hash the raw body.
  • Webinars are a separate API (/users/{id}/webinars) and need a webinar licence on the host.
  • User OAuth apps on the Marketplace must handle deauthorization and data deletion to pass review.

Output

Report: app type and scopes, token caching approach, endpoints called, webhook events subscribed and how they are verified and deduplicated, and test results.

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Zoom API"
  short_description: "Create meetings and trust every Zoom webhook"
  default_prompt: "$zoom-api Create a Zoom meeting automatically when a customer books a demo"

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc