Setting the file. One moment.
Subchapter 114.11
references/meeting-webhooks-oauth-refresh-orchestration.mdMarkdown5 KBView on GitHub
This guide implements one solution that handles all three simultaneously:
Use this skill chain:
References
App Typeszoom-general to classify the requestzoom-oauth for token brokerage and refresh controlzoom-rest-api to create the meetingzoom-webhooks to receive real-time updatesMinimal flow:
client request
-> TokenBroker.getToken()
-> POST /v2/users/{userId}/meetings
-> persist meeting + idempotency key
-> Zoom sends webhooks to your ingress
-> verify signature
-> enqueue event
-> projection worker updates meeting stateWebhook subscription note:
zoom-generalzoom-oauthzoom-rest-apizoom-webhooksTokenBroker: central access token cache + refresh lock.MeetingService: REST calls using broker.WebhookIngress: signature validation + URL validation + event enqueue.ProjectionWorker: applies events to meeting state.type TokenState = { accessToken: string; expiresAt: number; refreshing?: Promise<string> };
export class TokenBroker {
private state: TokenState = { accessToken: '', expiresAt: 0 };
constructor(
private accountId: string,
private clientId: string,
private clientSecret: string,
) {}
async getToken(): Promise<string> {
const now = Date.now();
if (this.state.accessToken && now < this.state.expiresAt - 60_000) {
return this.state.accessToken;
}
if (!this.state.refreshing) {
this.state.refreshing = this.refresh();
this.state.refreshing.finally(() => { this.state.refreshing = undefined; });
}
return this.state.refreshing;
}
invalidate() {
this.state.accessToken = '';
this.state.expiresAt = 0;
}
async forceRefresh(): Promise<string> {
this.invalidate();
return this.getToken();
}
private async refresh(): Promise<string> {
const q = new URLSearchParams({ grant_type: 'account_credentials', account_id: this.accountId });
const basic = Buffer.from(`${this.clientId}:${this.clientSecret}`).toString('base64');
const res = await fetch(`https://zoom.us/oauth/token?${q.toString()}`, {
method: 'POST',
headers: { Authorization: `Basic ${basic}` },
});
if (!res.ok) throw new Error(`token_refresh_failed:${res.status}`);
const data = await res.json() as { access_token: string; expires_in: number };
this.state.accessToken = data.access_token;
this.state.expiresAt = Date.now() + data.expires_in * 1000;
return this.state.accessToken;
}
}export async function createMeeting(tokenBroker: TokenBroker, userId: string, payload: object) {
async function call(): Promise<Response> {
const token = await tokenBroker.getToken();
return fetch(`https://api.zoom.us/v2/users/${encodeURIComponent(userId)}/meetings`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
}
let res = await call();
if (res.status === 401) {
await tokenBroker.forceRefresh();
res = await call(); // retry once with fresh token
}
if (!res.ok) throw new Error(`create_meeting_failed:${res.status}`);
return res.json();
}import crypto from 'crypto';
import type { Request, Response } from 'express';
export function verifyZoomSignature(req: Request, secret: string): boolean {
const ts = String(req.headers['x-zm-request-timestamp'] || '');
const sig = String(req.headers['x-zm-signature'] || '');
const rawBody = (req as any).rawBody || JSON.stringify(req.body);
const msg = `v0:${ts}:${rawBody}`;
const expected = `v0=${crypto.createHmac('sha256', secret).update(msg).digest('hex')}`;
return sig === expected;
}
export async function handleWebhook(req: Request, res: Response, secret: string, enqueue: (e: any) => Promise<void>) {
if (req.body?.event === 'endpoint.url_validation') {
const plainToken = req.body.payload?.plainToken;
const encryptedToken = crypto.createHmac('sha256', secret).update(plainToken).digest('hex');
return res.json({ plainToken, encryptedToken });
}
if (!verifyZoomSignature(req, secret)) {
return res.status(401).send('invalid_signature');
}
await enqueue(req.body); // durable queue write
return res.status(200).send('ok');
}last_event_ts and reject stale writes when necessary.userId/email instead of relying on me.express.json({ verify }) and use it for signature verification.