Para probar webhooks de Zoom en localhost, expón tu ruta POST local con npx portpreview PORT, introduce esa ruta HTTPS pública como endpoint de notificaciones de eventos e implementa el desafío endpoint.url_validation de Zoom antes de hacer clic en Validate. Para los eventos normales, verifica x-zm-signature usando el cuerpo de la solicitud sin modificar y la marca de tiempo, guarda el evento de forma idempotente y devuelve un código 2xx en menos de tres segundos.
Cómo llegan las suscripciones de eventos de Zoom a localhost
Los webhooks de Zoom son notificaciones HTTP POST en JSON para eventos suscritos de productos como Meetings, Webinars, Phone, Team Chat, Rooms y otros servicios disponibles para tu aplicación. El catálogo exacto de eventos y sus campos depende del tipo de aplicación, los productos habilitados, los permisos de la cuenta, los scopes y la plataforma actual de Zoom. Selecciona solo eventos que tu handler sepa procesar y usa el esquema vigente que aparece en el flujo de creación de la aplicación.
Tu endpoint debe ser HTTPS y accesible públicamente, con un nombre de dominio completo, una cadena de certificados válida emitida por una CA, TLS 1.2 o posterior y compatibilidad con solicitudes POST en JSON. Una URL de loopback como http://localhost:3000 no cumple esos requisitos. PortPreview proporciona el acceso HTTPS público y reenvía las solicitudes a tu proceso local.
La documentación oficial de webhooks de Zoom es la fuente de referencia para los requisitos del endpoint, la validación de desafío y respuesta, las firmas de eventos, el comportamiento de entrega y los pasos de configuración actuales.
Crea una ruta de Express que conserve el cuerpo sin procesar
La firma de la solicitud de Zoom cubre el texto exacto del cuerpo. Captura los bytes antes de que cualquier middleware JSON los analice y vuelva a serializarlos. El siguiente ejemplo gestiona la validación y la verificación de eventos normales en una sola ruta:
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);
La ventana de vigencia de cinco minutos es una política de seguridad de la aplicación en este ejemplo, no un sustituto de la verificación HMAC. Elige una tolerancia adecuada para la sincronización de tus relojes y las condiciones de entrega previstas. Registra únicamente la categoría del motivo de los fallos, nunca el token secreto ni el cuerpo completo de la solicitud.
Inicia el túnel local
- Ejecuta la aplicación y confirma que la ruta acepta un POST local en el puerto 3000.
- Abre otra terminal y ejecuta
npx portpreview 3000. Sustituye 3000 por el puerto que realmente usa tu aplicación. - Añade la ruta al origen generado, por ejemplo
https://YOUR-TUNNEL.portpreview.dev/webhooks/zoom. - Mantén en ejecución la aplicación y el túnel durante la validación y las pruebas de eventos.
Cuando un túnel nuevo tiene otro hostname, Zoom lo considera un endpoint diferente. Actualiza y valida la nueva URL antes de esperar eventos. La URL debe resolver directamente al handler POST; las redirecciones no son adecuadas para una entrega fiable de webhooks y Zoom no reintenta respuestas 3xx.
Añade la suscripción de eventos en Zoom
En Zoom App Marketplace, abre la aplicación que creaste y ve a su sección Features o Access, según se muestre en el flujo de creación actual. Habilita Event Subscriptions, añade una suscripción, elige los tipos de evento y el receptor y, después, pega la URL HTTPS completa del endpoint. Los receptores y eventos disponibles varían según el tipo de aplicación y la configuración de la cuenta. Es posible que las aplicaciones publicadas tengan que pasar otra revisión cuando cambien sus suscripciones.
Copia el webhook secret token asociado a la aplicación en una variable de entorno local ignorada, como ZOOM_WEBHOOK_SECRET_TOKEN. No es un OAuth client secret, un access token ni el verification token obsoleto. Reinicia el servidor local después de cambiar su entorno.
Implementa correctamente la validación de la URL del endpoint
Al hacer clic en Validate, Zoom envía un POST cuyo event es endpoint.url_validation. El payload contiene plainToken. Calcula un HMAC SHA-256 usando el webhook secret token como clave y ese plain token como mensaje, codifica el digest como hexadecimal en minúsculas y responde con un JSON que contenga tanto el plainToken sin modificar como el encryptedToken resultante.
const encryptedToken = createHmac('sha256', webhookSecret)
.update(event.payload.plainToken)
.digest('hex');
return {
plainToken: event.payload.plainToken,
encryptedToken
};
Responde con HTTP 200 y el cuerpo JSON en menos de tres segundos. No calcules el hash de toda la solicitud de validación, no uses el OAuth client secret, no codifiques el digest en Base64 ni añadas el prefijo v0= al digest de validación. Esos elementos pertenecen a otros flujos. El endpoint no se puede guardar hasta que la validación inicial se complete correctamente.
La documentación actual de Zoom también describe una revalidación automática cada 72 horas. Tras varios fallos de revalidación, se envían notificaciones al propietario de la aplicación; después de seis fallos consecutivos, Zoom deshabilita la suscripción de eventos y deja de enviarlos. Por tanto, un túnel de desarrollo que se haya cerrado fallará en una validación posterior. Elimina las suscripciones temporales al terminar las pruebas y mantén disponible permanentemente la gestión del desafío en producción.
Verifica las solicitudes normales de webhooks de Zoom
La validación de la URL demuestra que el endpoint conoce el secreto en el momento del desafío. La verificación de eventos normales demuestra, por separado, que el cuerpo recibido coincide con el HMAC enviado por Zoom. Lee x-zm-request-timestamp y construye exactamente este mensaje:
v0:{x-zm-request-timestamp}:{raw request body}
Calcula el hash de ese mensaje con HMAC SHA-256 usando el webhook secret token como clave, codifica el digest como hexadecimal, antepone v0= y compáralo con x-zm-signature mediante una comparación en tiempo constante. El cuerpo debe ser el cuerpo original de la solicitud. Analizar el JSON y después llamar a JSON.stringify puede cambiar los espacios o el formato de las propiedades e invalidar una firma correcta.
Rechaza las firmas ausentes, mal formadas, no válidas o excesivamente antiguas antes de ejecutar la lógica de negocio. Mantén sincronizada la hora del sistema. El antiguo webhook verification token quedó obsoleto y su retirada estaba prevista para junio de 2025; el código nuevo debe usar el flujo HMAC con secret token documentado por Zoom, no una comparación de igualdad de Authorization copiada de un tutorial antiguo. Consulta la guía de verificación de firmas de webhooks para conocer los detalles sobre el cuerpo sin procesar y la comparación segura frente a ataques de temporización.
Distribuye los tipos de evento específicos del proveedor
Un payload verificado aún requiere validar el esquema y comprobar la autorización. En los eventos de reuniones, identificadores como el meeting ID y el UUID tienen funciones distintas; las reuniones repetidas o recurrentes hacen que la correlación por UUID sea importante. Trata los campos del payload según el evento y sigue la referencia vigente de cada suscripción.
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);
}
}
No supongas que el orden de los eventos constituye un registro de transacciones. La latencia de red, los reintentos y el procesamiento paralelo pueden producir un orden de llegada inesperado. Guarda la marca de tiempo del evento proporcionada por Zoom y aplica reglas de estado monotónicas cuando corresponda. Los tipos de evento desconocidos deben ser observables y confirmarse después de guardarlos de forma segura, en lugar de hacer fallar repetidamente el endpoint.
Cumple el plazo de entrega de tres segundos
Zoom espera un HTTP 200 o 204 en menos de tres segundos para considerar que la entrega se completó correctamente. Verifica la solicitud, valida el envelope mínimo, escribe en una bandeja de entrada duradera o una cola y responde. El procesamiento de video, las actualizaciones del CRM, las llamadas al calendario, el correo y la analítica deben ejecutarse en workers.
Según la documentación actual de Zoom sobre la entrega de notificaciones, los fallos de servidor y conexión que cumplen los requisitos se reintentan tres veces: aproximadamente cinco minutos después del intento inicial, 20 minutos después de ese reintento y 60 minutos después del segundo reintento. Zoom considera los códigos 2xx como éxito; no reintenta redirecciones 3xx ni errores de cliente 4xx. Como estas políticas pueden cambiar, vuelve a consultar la página oficial antes de crear alertas operativas basadas en intervalos exactos.
Haz que cada evento sea idempotente
Puede producirse un reintento tras un timeout ambiguo aunque tu primer intento ya haya hecho commit. Deduplica antes de generar efectos secundarios. El header x-zm-request-id aparece en la estructura de solicitud documentada por Zoom, pero el código debe tolerar que no esté presente si existen diferencias entre productos o versiones. Úsalo cuando esté disponible; de lo contrario, deriva una clave estable de datos inmutables del evento ya verificados o de un digest criptográfico del cuerpo sin procesar verificado. Impón la unicidad en el almacenamiento, no solo mediante una caché en memoria.
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 });
});
Haz que la reserva de la entrada en el inbox y la creación del job sean atómicas. Si un worker falla, reintenta el job sin pedirle a Zoom que vuelva a entregar el evento. La guía de reintentos e idempotencia explica las tablas de inbox, las claves únicas y los límites de los efectos secundarios.
Soluciona problemas de validación y entrega
Validate informa de un fallo
Comprueba que la URL sea HTTPS pública, incluya la ruta exacta, no tenga redirecciones y llegue al puerto local activo. Confirma que la respuesta sea un JSON HTTP 200 con el plain token original y el HMAC hexadecimal en minúsculas calculado únicamente sobre ese token. Mide el tiempo total de respuesta; la validación debe completarse en menos de tres segundos.
Todos los eventos normales fallan al verificar la firma
Comprueba que copiaste el webhook secret token y no un OAuth client secret ni el verification token antiguo. Captura el cuerpo como bytes sin procesar antes del middleware JSON, usa exactamente el header de marca de tiempo, incluye los dos puntos en el mensaje v0:timestamp:body y añade v0= únicamente a la firma final del evento.
La validación funciona, pero los eventos no llegan
Asegúrate de que la suscripción esté habilitada y guardada, que estén seleccionados los tipos de evento y el receptor previstos y que tu cuenta o tus usuarios generen esos eventos. Comprueba el estado de revalidación y los requisitos de publicación de la aplicación. Confirma que la URL del túnel no haya cambiado desde la validación.
El handler termina correctamente, pero Zoom reintenta
Revisa la latencia y el estado de la respuesta pública, no solo los logs locales. El trabajo lento puede superar los tres segundos aunque finalmente termine. Guarda rápido, devuelve un 2xx y procesa de forma asíncrona. Un inbox resistente a duplicados evita que un reintento repita el efecto secundario.
Un evento capturado falla al reproducirlo más tarde
La comprobación de vigencia debe rechazar una marca de tiempo antigua y cualquier cambio en el JSON invalida el HMAC. Para pruebas de extremo a extremo, genera un evento nuevo del proveedor. Para probar la lógica de negocio, guarda una fixture analizada y anonimizada y llama al dispatcher omitiendo la verificación de entrada únicamente en el entorno de pruebas. La guía de reproducción de webhooks explica esta separación.
Lista de seguridad para probar webhooks de Zoom
- Guarda los webhook secret tokens en archivos de entorno ignorados y rota las credenciales expuestas.
- Verifica el HMAC con el cuerpo sin procesar antes de confiar en cualquier campo del payload.
- Aplica una tolerancia a la marca de tiempo y sincroniza el reloj del servidor para reducir el riesgo de ataques de repetición.
- Valida el tipo de evento, el contexto de la cuenta, los identificadores de objetos, el tipo de contenido y el tamaño del cuerpo.
- Oculta en los logs los nombres de participantes, direcciones de correo, temas de reuniones, contenido del chat y datos de grabaciones.
- Usa endpoints o secretos distintos para desarrollo y producción cuando la configuración de tu aplicación lo permita.
- Elimina las URL públicas y suscripciones temporales cuando termine la sesión local.
El patrón preparado para producción es el mismo que se valida en local: entrada HTTPS estable, gestión permanente del desafío, verificación HMAC del cuerpo sin procesar, idempotencia duradera, respuesta en menos de tres segundos y workers aislados. Para el seguimiento general de solicitudes y la comprobación de rutas, consulta la guía para depurar webhooks en local.
