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.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- zoom-api Installs to ~/.agents/skills/zoom-api/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install zoom-api The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project zoom-api Installs to .agents/skills/zoom-api/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/zoom-api.zipunzip -o zoom-api.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) zoom-api PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
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
- You need general guidance on receiving webhooks from any provider: use webhook-receivers
- Meetings are in Microsoft Teams: use microsoft-graph
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.
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
- Read
AGENTS.md; search for existing code:rg -n "zoom.us|api.zoom|ZOOM_" --glob '!node_modules'. - Look for the old JWT app type (
jsonwebtokensigning with an API key/secret againstapi.zoom.us). JWT apps were retired in 2023; migrate them to Server-to-Server OAuth. - Confirm which Zoom account and app (Marketplace, “Develop” -> “Build App”) the credentials belong to. Calls need network access.
Choose the app type
| Situation | App type | Grant |
|---|---|---|
| Your own company account, backend automation | Server-to-Server OAuth | account_credentials |
| Customers connect their own Zoom accounts | General app, user-managed or admin-managed | Authorization code (+ refresh) |
| Embed meetings in your web or mobile UI | General app with Meeting SDK enabled | SDK 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});merefers 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 youragendatag before creating another. - Update with
PATCH /meetings/{meetingId}(204, no body). Delete withDELETE /meetings/{meetingId}; addschedule_for_reminder=trueonly if cancellation emails are wanted. Deleting or emailing is outward-facing; confirm with the user for real accounts. start_urlcontains 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.
| Event | Typical action |
|---|---|
meeting.started / meeting.ended | Update session status, start billing or follow-up jobs |
meeting.participant_joined / _left | Attendance; match on participant.email when present |
recording.completed | Queue download of recording_files[] |
app_deauthorized | Delete 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_timewith a trailingZis UTC and ignorestimezone; withoutZit is local totimezone.meeting.id(numeric) is reused by recurring meetings;uuididentifies 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.yaml
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.