所有文章
通过 HTTPS 在本地测试 Zoom Webhook
ZoomwebhookslocalhostHMAC verification

通过 HTTPS 在本地测试 Zoom Webhook

要在 localhost 测试 Zoom Webhook,请用 npx portpreview PORT公开本地 POST 路由,把生成的 HTTPS 地址填为事件通知端点,并在点击 Validate 前实现endpoint.url_validation挑战。普通事件必须基于未经修改的请求体和时间戳验证x-zm-signature,以幂等方式持久化,并在三秒内返回 2xx。

Zoom 事件订阅如何到达 localhost

Zoom Webhook 会针对 Meetings、Webinars、Phone、Team Chat、Rooms 等产品中已订阅的事件发送 JSON HTTP POST。事件目录和字段取决于应用类型、已启用产品、账户权限、scope 及当前平台版本。只选择处理器能够理解的事件,并以应用创建流程显示的最新 schema 为准。

端点必须是公开 HTTPS,具备完整域名、有效 CA 证书链、TLS 1.2 或更高版本,并支持 JSON POST。http://localhost:3000不符合要求;PortPreview 在公网终止 HTTPS,再把请求转发至本地进程。具体要求应以Zoom Webhook 官方文档为准。

创建保留原始请求体的 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);

五分钟新鲜度窗口只是示例应用的安全策略,不能替代 HMAC 验证。应根据时钟同步和交付预期调整;日志只记录失败类别,不记录 secret token 或完整请求体。

启动本地隧道

  1. 启动应用,确认端口 3000 上的路由能够接收本地 POST。
  2. 另开终端运行npx portpreview 3000,并替换为实际端口。
  3. 将路径追加到生成的域名,例如https://YOUR-TUNNEL.portpreview.dev/webhooks/zoom
  4. 验证和事件测试期间保持应用与隧道运行。

新隧道若更换主机名,Zoom 会把它视为新端点,必须更新并重新验证。URL 应直接指向 POST 处理器;重定向不适合可靠交付,Zoom 不重试 3xx。

在 Zoom 中添加事件订阅

在 Zoom App Marketplace 打开已创建应用,进入当前创建流程中的 Features 或 Access,启用 Event Subscriptions,添加订阅,选择事件类型和 receiver,再粘贴完整 HTTPS URL。可用选项随应用类型和账户配置变化;修改已发布应用可能需要重新审核。

把 webhook secret token 存入被忽略的本地环境变量,如ZOOM_WEBHOOK_SECRET_TOKEN,修改后重启服务。它不是 OAuth client secret、access token,也不是旧 verification token。

正确实现端点 URL 验证

点击 Validate 后,Zoom 发送eventendpoint.url_validation的 POST,payload 含plainToken。以 webhook secret token 为密钥、仅以 plain token 为消息计算 HMAC SHA-256,将结果编码成小写十六进制,并返回原样plainTokenencryptedToken

const encryptedToken = createHmac('sha256', webhookSecret)
  .update(event.payload.plainToken)
  .digest('hex');

return {
  plainToken: event.payload.plainToken,
  encryptedToken
};

必须在三秒内返回 HTTP 200 JSON。不要 hash 整个验证请求,不要使用 OAuth secret、Base64 或v0=前缀。首次验证成功前无法保存端点。Zoom 还会每 72 小时自动复验;连续失败会通知所有者,六次后禁用订阅。一时测试结束后删除订阅,生产 challenge 处理则应永久可用。

验证普通 Zoom Webhook 请求

读取x-zm-request-timestamp并精确构造:

v0:{x-zm-request-timestamp}:{raw request body}

以 webhook secret token 为密钥计算 HMAC SHA-256,编码为十六进制,加上v0=,再与x-zm-signature做恒定时间比较。必须使用原始请求体;解析 JSON 后调用JSON.stringify可能改变空白或属性格式。业务逻辑前拒绝缺失、畸形、无效或过旧签名,并同步系统时钟。

旧 webhook verification token 已弃用,原定于 2025 年 6 月停止使用。新代码应采用 Zoom 文档中的 secret-token HMAC,而不是旧教程的Authorization相等判断。详见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);
  }
}

不要把到达顺序当作事务日志。网络延迟、重试和并行处理会打乱顺序;保存提供商事件时间,必要时采用单调状态规则。未知类型也应在安全持久化后确认并保持可观测。

满足三秒交付期限

Zoom 要求成功交付在三秒内得到 HTTP 200 或 204。验证请求和最小 envelope,写入持久 inbox 或 queue 后立即响应;视频、CRM、日历、邮件及 analytics 应交给 worker。

当前文档称,符合条件的服务器和连接失败会重试三次:初次约 5 分钟后,随后 20 分钟,再随后 60 分钟。2xx 成功;3xx 重定向和 4xx 客户端错误不重试。策略可能变化,按精确间隔配置告警前应复查官方页面。

让每个事件保持幂等

首次操作已提交后仍可能因超时而重试,因此副作用前要去重。有x-zm-request-id时使用它;缺失时,从已验证的不可变事件数据或原始请求体的密码学摘要生成稳定键。唯一性必须由存储层保证,不能只靠内存缓存。

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 必须原子完成。worker 失败时重试 job,而不是要求 Zoom 再投递。参阅Webhook 重试与幂等指南

排查验证与交付故障

Validate 报错

检查公网 HTTPS、精确路径、无重定向、正确本地端口,以及三秒内返回 HTTP 200 JSON、原 plain token 和仅针对该 token 的小写十六进制 HMAC。

所有普通事件都无法通过签名验证

确认使用 webhook secret token,在 JSON 中间件前获取原始字节,使用准确 timestamp,在v0:timestamp:body中保留两个冒号,并只给最终签名加v0=

验证成功但收不到事件

确认订阅已启用并保存,事件与 receiver 正确,账户确实产生事件,复验正常,且隧道 URL 未改变。

处理器成功但 Zoom 仍重试

查看公网响应时间和状态,而不只看本地日志。快速持久化、返回 2xx、异步处理,并用防重复 inbox 避免再次执行副作用。

回放捕获事件时验证失败

旧 timestamp 应被新鲜度检查拒绝,修改 JSON 也会破坏 HMAC。端到端测试应产生新事件;业务逻辑测试仅在测试框架中绕过入口验证,把脱敏 fixture 交给 dispatcher。参阅Webhook 回放调试指南

Zoom Webhook 测试安全清单

  • secret token 存在忽略的环境文件中,泄露后立即轮换。
  • 信任任何 payload 字段前验证原始请求体 HMAC。
  • 限制 timestamp 容差并同步服务器时钟。
  • 验证事件类型、账户上下文、对象标识、content type 和大小。
  • 日志中隐藏姓名、邮箱、会议主题、聊天和录制数据。
  • 配置允许时分离开发与生产端点或 secret。
  • 本地会话结束后删除临时 URL 和订阅。

生产设计与本地验证一致:稳定 HTTPS 入口、永久 challenge 处理、raw-body HMAC、持久幂等、三秒内确认和隔离 worker。常规追踪请参阅本地 Webhook 调试指南

常见问题

如何在 localhost 验证 Zoom Webhook URL?
通过 HTTPS 公开本地路由,然后响应 endpoint.url_validation,返回原始 plainToken,以及用 Zoom webhook secret token 计算的十六进制 HMAC SHA-256 encryptedToken。
如何验证普通 Zoom Webhook 签名?
构造 v0:{x-zm-request-timestamp}:{raw body},用 webhook secret token 计算 HMAC SHA-256,给十六进制摘要添加 v0=,再与 x-zm-signature 比较。
为什么 URL 验证成功,事件签名却失败?
两种流程签名的消息不同:验证只处理 plainToken,普通事件则处理版本、时间戳和精确原始请求体。重新序列化 JSON 也会破坏签名。
Zoom Webhook 必须多快响应?
当前文档要求三秒内返回 HTTP 200 或 204。先持久化或入队已验证事件并响应,再异步执行耗时业务逻辑。