Чтобы протестировать вебхуки Zoom на localhost, опубликуйте локальный POST-маршрут командой npx portpreview PORT, укажите полученный публичный HTTPS-адрес как endpoint для уведомлений и реализуйте ответ на проверку endpoint.url_validation до нажатия Validate. Для обычных событий проверяйте x-zm-signature по неизменённому телу запроса и временной метке, сохраняйте событие идемпотентно и возвращайте 2xx не позднее чем через три секунды.
Как подписки на события Zoom доходят до localhost
Вебхуки Zoom — это JSON-уведомления в виде HTTP POST для событий, на которые подписано приложение, в таких продуктах, как Meetings, Webinars, Phone, Team Chat, Rooms и других доступных ему сервисах. Точный набор событий и полей зависит от типа приложения, включённых продуктов, прав аккаунта, scopes и текущей версии платформы Zoom. Выбирайте только те события, которые понимает ваш обработчик, и ориентируйтесь на актуальную схему события в интерфейсе создания приложения.
Endpoint должен быть доступен из интернета по HTTPS, иметь полное доменное имя, корректную цепочку сертификатов от доверенного центра сертификации, поддерживать TLS 1.2 или новее и принимать JSON POST-запросы. Loopback-адрес вроде http://localhost:3000 этим требованиям не соответствует. PortPreview принимает публичный HTTPS-трафик и пересылает запросы локальному процессу.
Официальная документация Zoom по вебхукам — основной источник актуальных требований к endpoint, challenge-response валидации, подписям событий, доставке и настройке.
Создайте Express-маршрут, сохраняющий исходное тело запроса
Подпись запроса Zoom вычисляется по точному тексту тела. Получите исходные байты до того, как JSON-middleware разберёт и заново сериализует данные. В примере ниже один маршрут обрабатывает и валидацию URL, и проверку обычных событий:
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-проверке. Выберите допуск с учётом синхронизации часов и ожидаемых задержек доставки. Записывайте в логи только категорию причины ошибки, но никогда не секретный токен или полное тело запроса.
Запустите локальный туннель
- Запустите приложение и убедитесь, что маршрут принимает локальный POST-запрос на порту 3000.
- Откройте другой терминал и выполните
npx portpreview 3000. Замените 3000 на фактический порт приложения. - Добавьте путь маршрута к полученному origin, например
https://YOUR-TUNNEL.portpreview.dev/webhooks/zoom. - Не останавливайте приложение и туннель во время валидации и тестирования событий.
Если у нового туннеля другое имя хоста, Zoom считает его другим endpoint. Обновите URL и провалидируйте его заново, прежде чем ждать события. URL должен вести непосредственно на POST-обработчик: редиректы ненадёжны для доставки вебхуков, а ответы 3xx Zoom не повторяет.
Добавьте подписку на события в Zoom
В Zoom App Marketplace откройте созданное приложение и перейдите в раздел Features или Access согласно текущему интерфейсу сборки приложения. Включите Event Subscriptions, добавьте подписку, выберите типы событий и получателя, затем вставьте полный HTTPS URL endpoint. Доступные получатели и события зависят от типа приложения и настроек аккаунта. После изменения подписок опубликованному приложению может потребоваться повторная проверка.
Сохраните связанный с приложением webhook secret token в локальной переменной окружения, исключённой из системы контроля версий, например ZOOM_WEBHOOK_SECRET_TOKEN. Это не OAuth client secret, не access token и не устаревший verification token. После изменения окружения перезапустите локальный сервер.
Правильно реализуйте валидацию URL endpoint
После нажатия Validate Zoom отправляет POST-запрос, в котором event равен endpoint.url_validation. В payload находится plainToken. Вычислите HMAC SHA-256, используя webhook secret token как ключ, а plain token — как сообщение, представьте результат строчными шестнадцатеричными символами и верните JSON с неизменённым plainToken и полученным encryptedToken.
const encryptedToken = createHmac('sha256', webhookSecret)
.update(event.payload.plainToken)
.digest('hex');
return {
plainToken: event.payload.plainToken,
encryptedToken
};
Верните HTTP 200 с JSON-телом в течение трёх секунд. Не хешируйте весь запрос валидации, не используйте OAuth client secret, не кодируйте digest в Base64 и не добавляйте к результату валидации префикс v0= — это относится к другим сценариям. Сохранить endpoint до успешной первичной валидации нельзя.
В актуальной документации Zoom также описана автоматическая повторная валидация каждые 72 часа. При повторяющихся ошибках уведомления получает владелец приложения; после шести последовательных сбоев Zoom отключает подписку и прекращает отправлять события. Поэтому закрытый туннель разработки позднее не пройдёт очередную проверку. Удаляйте временные подписки после тестов, а в production держите обработчик challenge постоянно доступным.
Проверяйте обычные запросы вебхуков Zoom
Валидация URL подтверждает, что в момент challenge endpoint знает секрет. Проверка обычного события отдельно подтверждает, что полученное тело соответствует HMAC, отправленному Zoom. Прочитайте x-zm-request-timestamp и составьте в точности такую строку:
v0:{x-zm-request-timestamp}:{raw request body}
Вычислите HMAC SHA-256 этой строки с webhook secret token в качестве ключа, представьте digest в шестнадцатеричном виде, добавьте префикс v0= и сравните результат с x-zm-signature за постоянное время. Использовать нужно исходное тело запроса. Разбор JSON с последующим вызовом JSON.stringify может изменить пробелы или формат свойств и сделать корректную подпись недействительной.
Отклоняйте отсутствующие, некорректно сформированные, неверные или недопустимо старые подписи до выполнения бизнес-логики. Синхронизируйте системные часы. Старый webhook verification token был признан устаревшим, а его отключение планировалось на июнь 2025 года; новый код должен использовать описанную Zoom HMAC-схему с secret token, а не сравнение заголовка Authorization из старых руководств. Подробности о сохранении raw body и сравнении за постоянное время есть в руководстве по проверке подписей вебхуков.
Маршрутизируйте типы событий, специфичные для провайдера
Даже payload с корректной подписью требует проверки схемы и авторизации. В событиях встреч идентификаторы 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);
}
}
Не воспринимайте порядок событий как журнал транзакций. Задержки сети, повторные доставки и параллельная обработка могут неожиданно менять порядок поступления. Сохраняйте временную метку события от провайдера и при необходимости применяйте монотонные правила перехода состояния. Неизвестные типы событий должны быть наблюдаемыми и подтверждаться после безопасного сохранения, а не приводить к бесконечным ошибкам endpoint.
Укладывайтесь в трёхсекундный срок доставки
Для успешной доставки Zoom ожидает HTTP 200 или 204 в течение трёх секунд. Проверьте запрос, провалидируйте минимальную оболочку, запишите событие в надёжный inbox или очередь и ответьте. Обработку видео, обновления CRM, обращения к календарям, отправку писем и аналитику выполняйте в workers.
Согласно актуальной документации Zoom о доставке уведомлений, подходящие под условия ошибки сервера и соединения повторяются три раза: примерно через пять минут после первой попытки, затем через 20 минут после этого повтора и ещё через 60 минут после второго повтора. Ответ 2xx считается успехом; редиректы 3xx и клиентские ошибки 4xx Zoom не повторяет. Политика может измениться, поэтому перед настройкой операционных оповещений на точные интервалы ещё раз проверьте официальную страницу.
Сделайте обработку каждого события идемпотентной
Повторная доставка может произойти после неоднозначного timeout, когда первая попытка уже зафиксировала изменения. Устраняйте дубликаты до побочных эффектов. Заголовок x-zm-request-id указан в документированной структуре запросов Zoom, но код должен допускать его отсутствие в отдельных продуктах или версиях. Используйте его, когда он есть; иначе получайте стабильный ключ из проверенных неизменяемых данных события или криптографического digest проверенного исходного тела. Обеспечивайте уникальность в хранилище, а не только в кэше памяти.
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 и создание задачи должны быть атомарными. Если worker завершился ошибкой, повторите задачу, не запрашивая повторную доставку у Zoom. В руководстве по повторам и идемпотентности разобраны inbox-таблицы, уникальные ключи и границы побочных эффектов.
Диагностика проблем с валидацией и доставкой
Validate сообщает об ошибке
Проверьте, что URL доступен из интернета по HTTPS, содержит точный маршрут, не перенаправляет запрос и ведёт на работающий локальный порт. Ответ должен быть HTTP 200 JSON с исходным plain token и записанным строчными шестнадцатеричными символами HMAC только этого токена. Измерьте полное время ответа: валидация должна завершиться в течение трёх секунд.
Все обычные события не проходят проверку подписи
Убедитесь, что скопировали webhook secret token, а не OAuth client secret или устаревший verification token. Получайте тело как исходные байты до JSON-middleware, используйте точный заголовок timestamp, сохраните оба двоеточия в сообщении v0:timestamp:body и добавляйте v0= только к итоговой подписи события.
Валидация проходит, но события не приходят
Убедитесь, что подписка включена и сохранена, выбраны нужные типы событий и получатель, а аккаунт или пользователи действительно создают такие события. Проверьте статус повторной валидации и требования к публикации приложения. Убедитесь, что URL туннеля не изменился после валидации.
Обработчик завершается успешно, но Zoom повторяет доставку
Проверяйте время и статус публичного ответа, а не только локальные логи. Медленная работа может занять больше трёх секунд, даже если в итоге завершается успешно. Быстро сохраняйте событие, возвращайте 2xx и обрабатывайте асинхронно. Защищённый от дубликатов inbox не позволит повтору заново выполнить побочный эффект.
Сохранённое событие не проходит проверку при позднем воспроизведении
Проверка давности должна отклонить старую временную метку, а любое изменение JSON нарушает HMAC. Для end-to-end тестов создавайте новое событие у провайдера. Для тестирования бизнес-логики сохраните обезличенную разобранную fixture и вызывайте dispatcher, обходя входную проверку только в тестовой среде. Это разделение подробнее описано в руководстве по отладке повторного воспроизведения вебхуков.
Чек-лист безопасности при тестировании вебхуков Zoom
- Храните webhook secret tokens в исключённых из контроля версий файлах окружения и меняйте скомпрометированные данные.
- Проверяйте HMAC по исходному телу до того, как доверять любому полю payload.
- Ограничивайте допустимую давность timestamp и синхронизируйте часы сервера, чтобы снизить риск replay-атак.
- Проверяйте тип события, контекст аккаунта, идентификаторы объектов, content type и размер тела.
- Скрывайте в логах имена участников, адреса электронной почты, темы встреч, содержимое чатов и данные записей.
- Используйте разные endpoint или secrets для разработки и production, если конфигурация приложения это позволяет.
- Удаляйте временные публичные URL и подписки после завершения локальной сессии.
Готовая к production схема повторяет проверенную локально: стабильный HTTPS-вход, постоянно доступная обработка challenge, HMAC-проверка raw body, надёжная идемпотентность, подтверждение менее чем за три секунды и изолированные workers. Для общей трассировки запросов и проверки маршрутов используйте руководство по локальной отладке вебхуков.
