Skill 132 · Build Zoom REST API App
Subchapter 132.45
concepts/rate-limiting-strategy.mdMarkdown10 KBView on GitHub
Zoom API rate limits by plan, category, and strategies for handling them in production.
Rate limits are per-account (shared by all users and all apps on the account):
| Category | Free | Pro | Business+ |
|---|---|---|---|
| Light | 4/sec, 6,000/day | 30/sec | 80/sec |
| Medium | 2/sec, 2,000/day | 20/sec | 60/sec |
| Heavy | 1/sec, 1,000/day | 10/sec* | 40/sec* |
| Resource-Intensive | 10/min, 30,000/day | 10/min* | 20/min* |
* Combined daily limits:
Business+ includes: Business, Education, Enterprise, and Partners.
| Category | Pro | Business+ |
|---|---|---|
| Light | 20/sec | 40/sec |
| Medium | 10/sec | 20/sec |
| Heavy | 5/sec, 15,000/day* | 10/sec, 30,000/day* |
| Resource-Intensive | 5/min, 15,000/day* | 10/min, 30,000/day* |
* Daily limit shared between Heavy and Resource-Intensive.
| Category | Pro | Business+ |
|---|---|---|
| Light | 20/sec | 40/sec |
| Medium | 10/sec | 20/sec |
| Heavy | 5/sec, 15,000/day* | 10/sec, 30,000/day* |
* Daily limit shared with Resource-Intensive APIs.
| Plan | Uses Limits |
|---|---|
| Pay As You Go (Deprecated) | Pro |
| Annual Prepay Monthly Usage | Pro |
| All other plans | Business+ |
| Light | Medium | Heavy |
|---|---|---|
| Get A Meeting | Create Meeting | Get Daily Usage Report |
| Get Meeting Recordings | List All Recordings | List Devices |
| Add Meeting Registrant | Get Past Meeting Participants | — |
| Update A Meeting | List Meetings | — |
These are separate from account-level rate limits:
| Operation | Limit | Reset |
|---|---|---|
| Meeting/Webinar Create/Update | 100/day per user | 00:00 UTC |
| Registrant Addition | 3/day per registrant | 00:00 UTC |
| Registrant Status Updates | 10/day per registrant | 00:00 UTC |
The 100/day limit applies to all Meeting/Webinar IDs hosted by a specific user. To bulk-create meetings, distribute across multiple host users.
Zoom enforces single-concurrency on certain resource operations:
| Scenario | Behavior |
|---|---|
| Multiple DELETE on same userId | Only 1 concurrent DELETE allowed |
POST to /v2/users | Blocks GET/PATCH/PUT/DELETE until complete |
Error:
{
"code": 429,
"message": "Too many concurrent requests. A request to disassociate this user has already been made."
}Every API response includes rate limit information:
| Header | Description |
|---|---|
X-RateLimit-Category | Light, Medium, Heavy, or Resource-intensive |
X-RateLimit-Type | QPS (per-second) or Daily-limit |
X-RateLimit-Limit | Max requests in current window |
X-RateLimit-Remaining | Requests remaining |
X-RateLimit-Reset | Unix timestamp when per-second limit resets |
Retry-After | ISO 8601 datetime when daily limit resets |
X-RateLimit-Category: Medium
X-RateLimit-Type: QPS
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 55HTTP/1.1 429 Too Many Requests
X-RateLimit-Category: Light
X-RateLimit-Type: QPS
X-RateLimit-Limit: 80
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1705312800HTTP/1.1 429 Too Many Requests
X-RateLimit-Category: Heavy
X-RateLimit-Type: Daily-limit
X-RateLimit-Limit: 60000
X-RateLimit-Remaining: 0
Retry-After: 2025-01-20T00:00:00ZThe simplest retry strategy for handling 429 responses:
async function callZoomAPI(url, options, maxRetries = 5) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
const response = await fetch(url, options);
if (response.status === 429) {
// Check for daily limit (Retry-After header)
const retryAfter = response.headers.get('Retry-After');
if (retryAfter) {
const waitMs = new Date(retryAfter) - Date.now();
console.warn(`Daily limit hit. Retry after: ${retryAfter}`);
if (waitMs > 0 && waitMs < 86400000) {
await sleep(waitMs);
continue;
}
throw new Error(`Daily rate limit hit. Retry after ${retryAfter}`);
}
// Per-second limit — exponential backoff with jitter
const baseDelay = Math.pow(2, attempt) * 1000;
const jitter = baseDelay * 0.2 * Math.random();
const delay = baseDelay + jitter;
console.warn(`Rate limited. Retrying in ${Math.round(delay)}ms (attempt ${attempt + 1})`);
await sleep(delay);
continue;
}
return response;
}
throw new Error('Max retries exceeded for Zoom API');
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}Monitor remaining quota and slow down before hitting limits:
async function throttledRequest(url, options) {
const response = await fetch(url, options);
const remaining = parseInt(response.headers.get('X-RateLimit-Remaining') || '999');
const limit = parseInt(response.headers.get('X-RateLimit-Limit') || '999');
const category = response.headers.get('X-RateLimit-Category');
// Proactive throttling when under 10% quota
if (remaining < limit * 0.1) {
const resetTs = response.headers.get('X-RateLimit-Reset');
if (resetTs) {
const waitMs = (parseInt(resetTs) * 1000) - Date.now();
if (waitMs > 0 && waitMs < 10000) {
console.warn(`[${category}] ${remaining}/${limit} remaining — throttling ${waitMs}ms`);
await sleep(waitMs);
}
} else {
await sleep(1000);
}
}
return response;
}For applications making many concurrent requests:
class ZoomRateLimitedQueue {
constructor(requestsPerSecond = 10, minDelayMs = 100) {
this.queue = [];
this.running = 0;
this.maxConcurrent = requestsPerSecond;
this.minDelayMs = minDelayMs;
this.processing = false;
}
async add(requestFn) {
return new Promise((resolve, reject) => {
this.queue.push({ requestFn, resolve, reject });
this.process();
});
}
async process() {
if (this.processing) return;
this.processing = true;
while (this.queue.length > 0) {
if (this.running >= this.maxConcurrent) {
await sleep(this.minDelayMs);
continue;
}
const { requestFn, resolve, reject } = this.queue.shift();
this.running++;
requestFn()
.then(resolve)
.catch(reject)
.finally(() => {
this.running--;
});
await sleep(this.minDelayMs);
}
this.processing = false;
}
}
// Usage — process 10 requests/sec max
const queue = new ZoomRateLimitedQueue(10, 100);
const userIds = ['user1', 'user2', 'user3', /* ... */];
const results = await Promise.all(
userIds.map(id =>
queue.add(() => zoom.request('GET', `/users/${id}`))
)
);const cache = new Map();
async function cachedGet(path, ttlMs = 60000) {
const cached = cache.get(path);
if (cached && Date.now() - cached.time < ttlMs) {
return cached.data;
}
const data = await zoom.request('GET', path);
cache.set(path, { data, time: Date.now() });
return data;
}// DON'T: Poll for meeting status changes
setInterval(async () => {
const meetings = await zoom.request('GET', `/users/${userId}/meetings`);
}, 60000);
// DO: Receive webhook events
app.post('/webhook', (req, res) => {
handleEvent(req.body);
res.status(200).send();
});See zoom-webhooks for webhook implementation.
// DON'T: Fetch users one by one (N API calls)
for (const id of userIds) {
const user = await zoom.request('GET', `/users/${id}`);
}
// DO: Fetch in bulk (1 API call per page)
const allUsers = await zoom.request('GET', '/users?page_size=300');// Avoid hitting the 100/day per-user limit
const hosts = ['host1@co.com', 'host2@co.com', 'host3@co.com'];
let hostIndex = 0;
for (const meeting of meetingsToCreate) {
const host = hosts[hostIndex % hosts.length];
await zoom.request('POST', `/users/${host}/meetings`, meeting);
hostIndex++;
await sleep(100); // Prevent per-second burst
}For Quality of Service data, use QSS (push-based) instead of polling Reports API:
| Issue | Solution |
|---|---|
| 429 on first request of the day | Another app on account used quota |
| Different limits than documented | Check account type (Free/Pro/Business+) |
| Meeting create fails at 100/day | Per-user limit — distribute across hosts |
| Concurrent DELETE errors | Serialize DELETE operations on same user |
| Daily limit hit unexpectedly | Heavy + Resource-Intensive share quota |