Generated banner for Build Zoom Rest API App
Generated logo for Build Zoom Rest API App

Build Zoom Rest API App

OpenAIOfficialCreated by ZoomDistributed by OpenAI

  • Skill
  • Tool integration
  • Software development
  • MIT
  • Medium risk

From the author

Use when calling REST APIs.
— the description by Zoom

AutoGineer summary

Written by AutoGineer from the skill’s own files, not by the author

This skill provides a structured approach to interacting with the Zoom REST API. It offers guidance on selecting appropriate authentication flows, managing resources such as meetings and users, and implementing robust API interaction patterns like pagination and rate-limit handling. It includes reference documentation for various Zoom product areas, including account management and AI Companion services, to assist developers in building API-integrated applications.

What it does

  • Provides endpoint inventories for Zoom API product areas.
  • Outlines authentication requirements for user-level, account-level, and server-to-server workflows.
  • Defines patterns for implementing API wrappers with pagination and retry logic.
  • Offers guidance on webhook processing, including signature verification.
  • Assists in troubleshooting common API issues like scope mismatches and rate limits.

When to use it

  • When developing applications that need to programmatically manage Zoom meetings, users, or recordings.
  • When integrating Zoom account settings or dashboard metrics into custom software.
  • When setting up event-driven workflows using Zoom webhooks.
  • When debugging authentication or scope-related errors during Zoom API integration.

Advantages

  • Centralizes access to official Zoom API endpoint documentation.
  • Standardizes the implementation of API interaction patterns.
  • Clarifies the selection of correct authentication scopes for different API operations.
  • Provides a clear methodology for handling API rate limits and resource management.

Usage examples

“How do I list meeting participants using the Zoom API?”
The agent provides the correct endpoint path from the dashboard references and explains the necessary OAuth scopes and pagination requirements.
“What authentication should I use for an account-level Zoom integration?”
The agent guides you through the account-level OAuth flow requirements as defined in the authentication documentation.

Install

  1. Download the skill as a ZIP:

    https://autogineer.com/agent-skills/openai/build-zoom-rest-api-app/download
  2. Unzip it into the cross-client skills folder of your project:

    mkdir -p .agents/skills && unzip build-zoom-rest-api-app.zip -d .agents/skills

Documentation by Zoom

Build Zoom REST API App

Use this skill when the task needs deterministic Zoom API calls or resource management from application code.

Workflow

  1. Define the resource and actor: user, meeting, webinar, recording, docs, chat, phone, account, or admin-level workflow.

  2. Select the endpoint and required scopes from the reference files before coding.

  3. Confirm auth fit: user-level OAuth for user-owned resources, account-level OAuth for admin workflows, and only use server-to-server OAuth where the target API documents support for it.

  4. Implement narrow API wrappers with explicit pagination, retry, idempotency, and rate-limit handling.

  5. Treat webhook processing as a separate event-ingestion path with signature verification and replay protection.

  6. Debug by checking token audience, missing scopes, resource ownership, account settings, API enablement, and rate-limit headers.

References

Files

examples/webhook-server.md

Webhook Server - Express.js with CRC Validation and Signature Verification

Production-ready webhook server implementation for receiving Zoom webhook events with CRC (Challenge-Response Check) validation and HMAC signature verification.

For comprehensive webhook documentation, see the webhooks skill.

Quick Start

1. Install Dependencies
npm install express body-parser crypto
2. Basic Webhook Server
const express = require('express');
const crypto = require('crypto');
const app = express();

// Zoom webhook secret token (from your app's Feature page)
const WEBHOOK_SECRET_TOKEN = process.env.ZOOM_WEBHOOK_SECRET;

// Parse JSON bodies
app.use(express.json());

// Webhook endpoint
app.post('/webhook', (req, res) => {
  const { event, payload } = req.body;

  // Handle CRC validation (Challenge-Response Check)
  if (event === 'endpoint.url_validation') {
    return handleCRC(req, res);
  }

  // Verify signature
  if (!verifySignature(req)) {
    console.error('Invalid signature');
    return res.status(401).send('Unauthorized');
  }

  // Handle events
  handleEvent(event, payload);

  // Always respond with 200 within 3 seconds
  res.status(200).send();
});

app.listen(3000, () => {
  console.log('Webhook server running on port 3000');
});

CRC (Challenge-Response Check) Validation

When you add a webhook URL or make changes, Zoom sends a validation request. You must respond within 3 seconds.

CRC Flow
  1. Zoom sends POST with event: "endpoint.url_validation"

  2. Your server hashes the plainToken using your webhook secret

  3. Respond with JSON containing both plainToken and encryptedToken

Implementation
function handleCRC(req, res) {
  const { plainToken } = req.body.payload;

  // Hash the plainToken with HMAC-SHA256
  const encryptedToken = crypto
    .createHmac('sha256', WEBHOOK_SECRET_TOKEN)
    .update(plainToken)
    .digest('hex');

  // Respond within 3 seconds
  res.status(200).json({
    plainToken,
    encryptedToken
  });

  console.log('CRC validation successful');
}
CRC Request Example
{
  "event": "endpoint.url_validation",
  "payload": {
    "plainToken": "qgg8vlvZRS6UYooatFL8Aw"
  },
  "event_ts": 1654503849680
}
CRC Response Example
{
  "plainToken": "qgg8vlvZRS6UYooatFL8Aw",
  "encryptedToken": "23a89b634c017e5364a1c8d9c8ea909b60dd5599e2bb04bb1558d9c3a121faa5"
}

Signature Verification

Verify that webhook requests actually come from Zoom by checking the HMAC signature.

Signature Verification Flow
  1. Extract x-zm-signature and x-zm-request-timestamp headers

  2. Construct message: v0:{timestamp}:{body}

  3. Hash message with HMAC-SHA256 using your webhook secret

  4. Prepend v0= to the hash

  5. Compare with x-zm-signature header

Implementation
function verifySignature(req) {
  const signature = req.headers['x-zm-signature'];
  const timestamp = req.headers['x-zm-request-timestamp'];

  if (!signature || !timestamp) {
    console.error('Missing signature headers');
    return false;
  }

  // Construct the message
  const message = `v0:${timestamp}:${JSON.stringify(req.body)}`;

  // Hash the message
  const hashForVerify = crypto
    .createHmac('sha256', WEBHOOK_SECRET_TOKEN)
    .update(message)
    .digest('hex');

  // Prepend v0=
  const computedSignature = `v0=${hashForVerify}`;

  // Compare signatures
  return signature === computedSignature;
}
Signature Headers Example
POST /webhook HTTP/1.1
Host: example.com
x-zm-signature: v0=a05d830fa017433bc47887f835a00b9ff33d3882f22f63a2986a8es270341
x-zm-request-timestamp: 1658940994
Content-Type: application/json

{"event":"meeting.started","payload":{...}}

Event Handling

Event Router
function handleEvent(event, payload) {
  switch (event) {
    case 'meeting.created':
      handleMeetingCreated(payload);
      break;

    case 'meeting.started':
      handleMeetingStarted(payload);
      break;

    case 'meeting.ended':
      handleMeetingEnded(payload);
      break;

    case 'meeting.participant_joined':
      handleParticipantJoined(payload);
      break;

    case 'recording.completed':
      handleRecordingCompleted(payload);
      break;

    default:
      console.log(`Unhandled event: ${event}`);
  }
}
Event Handlers
function handleMeetingStarted(payload) {
  const { id, uuid, topic, start_time } = payload.object;
  console.log(`Meeting started: ${topic} (ID: ${id})`);

  // Your logic: Send notifications, start recording, etc.
  // Example: Trigger auto-recording
  // await startCloudRecording(id);
}

function handleMeetingEnded(payload) {
  const { id, uuid, topic, duration } = payload.object;
  console.log(`Meeting ended: ${topic} (Duration: ${duration}min)`);

  // Your logic: Process analytics, trigger workflows, etc.
}

function handleRecordingCompleted(payload) {
  const { id, uuid, topic, recording_files } = payload.object;
  console.log(`Recording ready: ${topic}`);

  // Download recordings (see recording-pipeline.md)
  recording_files.forEach(file => {
    console.log(`- ${file.file_type}: ${file.download_url}`);
    // downloadRecording(file.download_url, file.id);
  });
}

function handleParticipantJoined(payload) {
  const { participant } = payload.object;
  console.log(`Participant joined: ${participant.user_name}`);

  // Your logic: Track attendance, send welcome message, etc.
}

Complete Production Server

const express = require('express');
const crypto = require('crypto');
const app = express();

const WEBHOOK_SECRET_TOKEN = process.env.ZOOM_WEBHOOK_SECRET;
const PORT = process.env.PORT || 3000;

// Middleware
app.use(express.json());

// Request logging
app.use((req, res, next) => {
  console.log(`${new Date().toISOString()} - ${req.method} ${req.path}`);
  next();
});

// Webhook endpoint
app.post('/webhook', async (req, res) => {
  try {
    const { event, payload, event_ts } = req.body;

    // CRC validation
    if (event === 'endpoint.url_validation') {
      return handleCRC(req, res);
    }

    // Verify signature
    if (!verifySignature(req)) {
      console.error('Signature verification failed');
      return res.status(401).send('Unauthorized');
    }

    // Log event
    console.log(`Event received: ${event} at ${new Date(event_ts)}`);

    // Handle event asynchronously
    setImmediate(() => {
      handleEvent(event, payload).catch(error => {
        console.error('Error handling event:', error);
      });
    });

    // Respond immediately (within 3 seconds)
    res.status(200).send();

  } catch (error) {
    console.error('Webhook error:', error);
    res.status(500).send('Internal Server Error');
  }
});

// CRC validation
function handleCRC(req, res) {
  const { plainToken } = req.body.payload;

  const encryptedToken = crypto
    .createHmac('sha256', WEBHOOK_SECRET_TOKEN)
    .update(plainToken)
    .digest('hex');

  res.status(200).json({ plainToken, encryptedToken });
  console.log('CRC validation successful');
}

// Signature verification
function verifySignature(req) {
  const signature = req.headers['x-zm-signature'];
  const timestamp = req.headers['x-zm-request-timestamp'];

  if (!signature || !timestamp) {
    return false;
  }

  const message = `v0:${timestamp}:${JSON.stringify(req.body)}`;

  const hashForVerify = crypto
    .createHmac('sha256', WEBHOOK_SECRET_TOKEN)
    .update(message)
    .digest('hex');

  const computedSignature = `v0=${hashForVerify}`;

  return signature === computedSignature;
}

// Event handler
async function handleEvent(event, payload) {
  switch (event) {
    case 'meeting.started':
      await handleMeetingStarted(payload);
      break;

    case 'meeting.ended':
      await handleMeetingEnded(payload);
      break;

    case 'recording.completed':
      await handleRecordingCompleted(payload);
      break;

    // Add more event handlers as needed
    default:
      console.log(`Unhandled event: ${event}`);
  }
}

async function handleMeetingStarted(payload) {
  console.log(`Meeting started: ${payload.object.topic}`);
  // Your business logic
}

async function handleMeetingEnded(payload) {
  console.log(`Meeting ended: ${payload.object.topic}`);
  // Your business logic
}

async function handleRecordingCompleted(payload) {
  console.log(`Recording completed: ${payload.object.topic}`);
  // Download logic (see recording-pipeline.md)
}

// Health check
app.get('/health', (req, res) => {
  res.status(200).json({ status: 'ok', timestamp: new Date().toISOString() });
});

// Start server
app.listen(PORT, () => {
  console.log(`Webhook server running on port ${PORT}`);
  console.log(`Webhook endpoint: ${process.env.PUBLIC_BASE_URL || 'https://YOUR_PUBLIC_BASE_URL'}/webhook`);
});

// Graceful shutdown
process.on('SIGTERM', () => {
  console.log('SIGTERM received, shutting down gracefully');
  process.exit(0);
});

Webhook Retry Policy

Zoom automatically retries failed webhooks 3 times with exponential backoff:

  1. First retry: 5 minutes after initial failure

  2. Second retry: 20 minutes after first retry

  3. Third retry: 60 minutes after second retry

Retry Conditions

Zoom retries for:

  • HTTP status codes ≥ 500

  • Network errors (connection refused, timeout, etc.)

Zoom does NOT retry for:

  • HTTP status codes 200-299 (success)

  • HTTP status codes 300-399 (redirects)

  • HTTP status codes 400-499 (client errors)

Handling Retries
// Track processed events to avoid duplicate processing
const processedEvents = new Set();

app.post('/webhook', (req, res) => {
  const { event, event_ts, payload } = req.body;

  // Create unique event ID
  const eventId = `${event}-${event_ts}-${payload.object?.id || ''}`;

  // Check if already processed (duplicate due to retry)
  if (processedEvents.has(eventId)) {
    console.log(`Duplicate event: ${eventId}`);
    return res.status(200).send();  // Still return 200
  }

  // Mark as processed
  processedEvents.add(eventId);

  // Handle event
  handleEvent(event, payload);

  res.status(200).send();

  // Clean up old entries after 2 hours
  setTimeout(() => processedEvents.delete(eventId), 2 * 60 * 60 * 1000);
});

Webhook Revalidation

Zoom automatically revalidates webhook endpoints every 72 hours. If revalidation fails 6 consecutive times, Zoom disables the webhook.

Revalidation Notifications
  • 2 failures: First email notification

  • 4 failures: Second email notification

  • 6 failures: Webhook disabled

Ensure Uptime
// Health check with monitoring
app.get('/health', (req, res) => {
  // Check dependencies (database, external APIs, etc.)
  const isHealthy = checkDependencies();

  if (isHealthy) {
    res.status(200).json({
      status: 'ok',
      timestamp: new Date().toISOString(),
      uptime: process.uptime()
    });
  } else {
    res.status(503).json({
      status: 'unhealthy',
      timestamp: new Date().toISOString()
    });
  }
});

function checkDependencies() {
  // Check database connection, external APIs, etc.
  return true;
}

Environment Variables

# .env
ZOOM_WEBHOOK_SECRET=your_webhook_secret_token_here
PORT=3000
NODE_ENV=production
Loading Environment Variables
require('dotenv').config();

const WEBHOOK_SECRET_TOKEN = process.env.ZOOM_WEBHOOK_SECRET;

if (!WEBHOOK_SECRET_TOKEN) {
  throw new Error('ZOOM_WEBHOOK_SECRET environment variable is required');
}

Deployment

Requirements
  1. HTTPS required - Zoom only sends to HTTPS endpoints

  2. Public URL - Endpoint must be publicly accessible

  3. TLS 1.2+ - Valid certificate from a Certificate Authority (CA)

  4. FQDN - Fully qualified domain name (not IP address)

  5. Response time - Respond within 3 seconds

Deployment Options
  • Heroku: git push heroku main

  • AWS Lambda: Use API Gateway + Lambda function

  • Vercel/Netlify: Serverless functions

  • Self-hosted: Nginx + Node.js + Let's Encrypt

ngrok for Local Development
# Install ngrok
npm install -g ngrok

# Start your server
node server.js

# In another terminal, expose to public URL
ngrok http 3000

# Use the HTTPS URL in Zoom webhook configuration
# Example: https://abc123.ngrok.io/webhook

Testing

Test CRC Validation
WEBHOOK_BASE_URL="http://YOUR_DEV_HOST:3000"

curl -X POST "$WEBHOOK_BASE_URL/webhook" \
  -H "Content-Type: application/json" \
  -d '{
    "event": "endpoint.url_validation",
    "payload": {
      "plainToken": "test_token_123"
    },
    "event_ts": 1654503849680
  }'

Expected response:

{
  "plainToken": "test_token_123",
  "encryptedToken": "..."
}
Test Event Handling
# Generate valid signature
TIMESTAMP=$(date +%s)
MESSAGE="v0:${TIMESTAMP}:{\"event\":\"meeting.started\",\"payload\":{\"object\":{\"id\":\"123\",\"topic\":\"Test\"}}}"
SIGNATURE="v0=$(echo -n "$MESSAGE" | openssl dgst -sha256 -hmac "YOUR_SECRET" -binary | xxd -p)"

WEBHOOK_BASE_URL="http://YOUR_DEV_HOST:3000"

curl -X POST "$WEBHOOK_BASE_URL/webhook" \
  -H "Content-Type: application/json" \
  -H "x-zm-signature: $SIGNATURE" \
  -H "x-zm-request-timestamp: $TIMESTAMP" \
  -d '{"event":"meeting.started","payload":{"object":{"id":"123","topic":"Test Meeting"}}}'

Common Event Types

EventDescription
meeting.createdMeeting created
meeting.updatedMeeting details changed
meeting.deletedMeeting deleted
meeting.startedMeeting begins
meeting.endedMeeting ends
meeting.participant_joinedParticipant joins
meeting.participant_leftParticipant leaves
recording.completedCloud recording ready
recording.transcript_completedTranscript ready
user.createdUser created
user.updatedUser updated
user.deletedUser deleted

See complete event catalog: webhooks skill

Resources

Requirements and compatibility

Written for: OpenAI Codex

Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta

“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.

Security

What our scanner noticed· Medium risk

  • Possible data-exfiltration endpoints
  • Instructions that use the network

A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.

Attribution and licence

Created by
Zoom
Publisher
OpenAI (Official)
Distributed by
OpenAI
Source
openai/plugins · plugins/zoom/skills/rest-api · commit d420d13
Licence
MIT License (redistribution allowed)
  • LICENSE (added by AutoGineer from plugins/zoom/LICENSE)
  • ATTRIBUTION.md (written by AutoGineer)
Synced

AutoGineer is not affiliated with or endorsed by Zoom or OpenAI.

Download ZIP

The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download build-zoom-rest-api-app.zip