localhostでZoom Webhookを試すには、npx portpreview PORTでローカルのPOSTルートを公開し、そのHTTPS URLを通知先に設定して、Validateを押す前にendpoint.url_validationへ応答できるようにします。通常イベントでは、未変更のリクエスト本文と時刻からx-zm-signatureを検証し、冪等に保存して3秒以内に2xxを返します。
Zoomのイベントサブスクリプションをlocalhostへ届ける仕組み
Zoom Webhookは、Meetings、Webinars、Phone、Team Chat、Roomsなどで購読したイベントをJSONのHTTP POSTとして送ります。利用できるイベントとフィールドは、アプリ種別、有効な製品、アカウント権限、scope、現在のZoomプラットフォームによって変わります。ハンドラーが理解できるイベントだけを選び、アプリ作成画面の最新スキーマに従ってください。
エンドポイントには、FQDN、有効なCA証明書チェーン、TLS 1.2以上、JSON POST対応を備えた公開HTTPSが必要です。http://localhost:3000は条件を満たしません。PortPreviewが公開HTTPSを受け、ローカルプロセスへ転送します。最新要件の基準はZoom Webhook公式ドキュメントです。
raw bodyを保持するExpressルートを作る
署名対象は本文の正確な文字列です。JSONミドルウェアが解析・再シリアライズする前にバイト列を取得します。
import crypto from 'node:crypto';
import express from 'express';
const app = express();
app.post('/webhooks/zoom', express.raw({ type: 'application/json', limit: '1mb' }), async (req, res) => {
const rawBody = req.body.toString('utf8');
let event;
try {
event = JSON.parse(rawBody);
} catch {
return res.status(400).json({ error: 'Invalid JSON' });
}
const secret = process.env.ZOOM_WEBHOOK_SECRET_TOKEN;
if (!secret) return res.sendStatus(500);
if (event.event === 'endpoint.url_validation') {
const plainToken = event.payload?.plainToken;
if (typeof plainToken !== 'string') return res.sendStatus(400);
const encryptedToken = crypto
.createHmac('sha256', secret)
.update(plainToken)
.digest('hex');
return res.status(200).json({ plainToken, encryptedToken });
}
const timestamp = req.get('x-zm-request-timestamp') ?? '';
const received = req.get('x-zm-signature') ?? '';
const message = `v0:${timestamp}:${rawBody}`;
const expected = `v0=${crypto
.createHmac('sha256', secret)
.update(message)
.digest('hex')}`;
const a = Buffer.from(received);
const b = Buffer.from(expected);
const valid = a.length === b.length && crypto.timingSafeEqual(a, b);
if (!valid) return res.sendStatus(401);
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(age) || age > 300) return res.sendStatus(401);
const requestId = req.get('x-zm-request-id');
const deliveryKey = requestId || crypto.createHash('sha256').update(rawBody).digest('hex');
await saveWebhookOnce({ provider: 'zoom', deliveryKey, event });
return res.sendStatus(200);
});
app.listen(3000);
5分の鮮度制限はこの例のセキュリティ方針であり、HMAC検証の代わりではありません。時計同期と配送条件に合わせて調整し、secret tokenや本文全体をログへ残さないでください。
ローカルトンネルを起動する
- アプリを起動し、ポート3000のルートがローカルPOSTを受けることを確認します。
- 別の端末で
npx portpreview 3000を実行します。実際のポートへ置き換えてください。 - 生成されたoriginへ
https://YOUR-TUNNEL.portpreview.dev/webhooks/zoomのようにルートを加えます。 - 検証とテスト中はアプリとトンネルを動かし続けます。
ホスト名が変わればZoomには別エンドポイントとして見えます。新URLを設定して再検証してください。URLはPOSTハンドラーへ直接届く必要があり、Zoomは3xxを再試行しません。
Zoomでイベントサブスクリプションを追加する
Zoom App Marketplaceで作成済みアプリを開き、現在の作成フローにあるFeaturesまたはAccessへ進みます。Event Subscriptionsを有効化し、購読、イベント種別、receiverを選び、完全なHTTPS URLを貼ります。選択肢はアプリ種別とアカウント構成で異なり、公開済みアプリの変更には再審査が必要な場合があります。
webhook secret tokenはZOOM_WEBHOOK_SECRET_TOKENなどのgit管理外の環境変数へ保存し、サーバーを再起動します。OAuth client secret、access token、旧verification tokenとは別物です。
エンドポイントURL検証を正しく実装する
Validateを押すと、Zoomはeventがendpoint.url_validationのPOSTを送り、payloadにplainTokenを含めます。webhook secret tokenを鍵、plain tokenだけをメッセージとしてHMAC SHA-256を計算し、小文字16進数のencryptedTokenと未変更のplainTokenをJSONで返します。
const encryptedToken = createHmac('sha256', webhookSecret)
.update(event.payload.plainToken)
.digest('hex');
return {
plainToken: event.payload.plainToken,
encryptedToken
};
3秒以内にHTTP 200で返します。リクエスト全体をhashせず、OAuth secret、Base64、v0=を使わないでください。初回検証前は保存できません。Zoomは72時間ごとに自動再検証し、失敗を所有者へ通知、6回連続で失敗すると購読を停止します。一時購読は削除し、本番のchallenge処理は常時稼働させます。
通常のZoom Webhookリクエストを検証する
x-zm-request-timestampを読み、次の文字列を正確に作ります。
v0:{x-zm-request-timestamp}:{raw request body}
webhook secret tokenを鍵にHMAC SHA-256を計算して16進数化し、v0=を付け、x-zm-signatureと定数時間比較します。元の本文が必須で、JSONをparseしてJSON.stringifyすると空白や形式が変わり得ます。欠落、不正、古すぎる署名は業務処理前に拒否し、システム時計を同期します。
旧webhook verification tokenは非推奨で、2025年6月に終了予定でした。古いAuthorization一致判定ではなくZoomのsecret-token HMAC方式を使い、Webhook署名検証ガイドも参照してください。
プロバイダー固有のイベント種別を振り分ける
署名済みpayloadにもschema検証と認可確認が必要です。meeting IDとUUIDは用途が異なり、繰り返し会議ではUUID単位の関連付けが重要です。
async function processZoomEvent(event) {
switch (event.event) {
case 'meeting.started':
await markMeetingStarted({
uuid: event.payload.object.uuid,
startedAt: event.payload.object.start_time
});
break;
case 'meeting.ended':
await markMeetingEnded({
uuid: event.payload.object.uuid,
endedAt: event.payload.object.end_time
});
break;
default:
await recordUnhandledZoomEvent(event.event);
}
}
到着順をトランザクションログと見なさないでください。遅延、再試行、並列処理で順序は変わります。提供元の時刻を保存し、必要なら単調な状態遷移を適用します。未知イベントも安全に保存して確認応答し、監視可能にします。
3秒の配送期限を守る
Zoomは3秒以内のHTTP 200または204を成功とします。署名と最小限のenvelopeを検査し、永続inboxまたはqueueへ書いて応答します。動画処理、CRM、カレンダー、メール、analyticsはworkerへ移します。
現行資料では、対象となるserver/connection障害を約5分後、次に20分後、さらに60分後の計3回再試行します。2xxは成功ですが、3xx redirectと4xx client errorは再試行しません。正確な間隔をalertへ使う前に公式情報を再確認してください。
すべてのイベントを冪等にする
最初の処理がcommit済みでもtimeoutで再送されることがあります。副作用の前に重複排除します。x-zm-request-idがあれば使い、なければ検証済みの不変データかraw bodyの暗号学的digestから安定キーを作ります。メモリcacheだけでなくstorageの一意制約で守ります。
await db.transaction(async (tx) => {
const claimed = await tx.webhookInbox.insertOnce({
provider: 'zoom',
deliveryKey,
eventType: event.event,
payload: event
});
if (!claimed) return;
await tx.jobs.enqueue({ type: 'process-zoom-event', deliveryKey });
});
inbox確保とjob作成はatomicにします。worker失敗時はZoomへ再送を求めずjobを再実行します。詳しくは再試行と冪等性のガイドを参照してください。
検証と配送の問題を切り分ける
Validateが失敗する
公開HTTPS、正確なroute、redirectなし、稼働中のport、元tokenと小文字hex HMACを返すHTTP 200 JSON、3秒以内の応答を確認します。
通常イベントの署名がすべて失敗する
正しいwebhook secret token、JSON処理前のraw bytes、正確なtimestamp、v0:timestamp:bodyの2つのcolon、最終署名だけのv0=を確認します。
検証は通るがイベントが来ない
購読が有効・保存済みか、イベントとreceiverが正しいか、対象アカウントで発生したか、再検証状態、トンネルURL変更を確認します。
処理成功後もZoomが再試行する
ローカルログだけでなく公開応答のstatusとlatencyを測ります。素早く保存して2xxを返し非同期処理します。
保存したイベントを後で再生すると失敗する
古いtimestampは鮮度検査で拒否され、JSON変更はHMACを壊します。end-to-endには新規イベントを使い、業務ロジックはテスト時だけ検証後のsanitized fixtureをdispatcherへ渡します。Webhook再生デバッグガイドも参照してください。
Zoom Webhookテストのセキュリティチェックリスト
- secret tokenは管理外の環境ファイルへ置き、漏えい時はrotateする。
- payloadを信頼する前にraw bodyのHMACを検証する。
- 時刻許容幅を設け、server clockを同期する。
- event type、account、object ID、content type、body sizeを検査する。
- 参加者名、email、議題、chat、録画情報をlogでredactする。
- 可能なら開発と本番のendpointまたはsecretを分離する。
- セッション終了時に一時URLと購読を削除する。
本番でも、安定したHTTPS、常時challenge処理、raw-body HMAC、永続的な冪等性、3秒未満の応答、独立workerという同じ設計を使います。追跡方法はローカルWebhookデバッグガイドを参照してください。
