Tous les articles
Tester les webhooks Zoom en local avec HTTPS
ZoomwebhookslocalhostHMAC verification

Tester les webhooks Zoom en local avec HTTPS

Pour tester un webhook Zoom sur localhost, publiez votre route POST avec npx portpreview PORT, indiquez cette URL HTTPS comme endpoint de notification et gérez le défi endpoint.url_validation avant de cliquer sur Validate. Pour les événements ordinaires, vérifiez x-zm-signature à partir du corps brut et de l’horodatage, enregistrez chaque livraison de façon idempotente et répondez en 2xx sous trois secondes.

Comment les abonnements aux événements Zoom atteignent localhost

Les webhooks Zoom sont des requêtes HTTP POST JSON émises pour les événements auxquels l’application est abonnée : Meetings, Webinars, Phone, Team Chat, Rooms et autres produits. Le catalogue et les champs varient selon le type d’application, les produits activés, les droits du compte, les scopes et la version actuelle de la plateforme. Ne sélectionnez que les événements compris par votre gestionnaire et suivez le schéma affiché dans le parcours de création.

L’endpoint doit être une adresse HTTPS publique avec nom de domaine complet, chaîne de certificats émise par une autorité reconnue, TLS 1.2 ou supérieur et prise en charge des POST JSON. http://localhost:3000 ne convient donc pas. PortPreview fournit la terminaison HTTPS publique et transmet les requêtes au processus local. La documentation officielle des webhooks Zoom reste la référence pour les exigences et comportements actuels.

Créer une route Express qui conserve le corps brut

La signature couvre le texte exact du corps. Capturez les octets avant tout middleware JSON susceptible de parser puis de sérialiser les données :

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 fenêtre de fraîcheur de cinq minutes est ici une politique de sécurité applicative, jamais un remplacement de la vérification HMAC. Adaptez-la à la synchronisation des horloges et aux délais attendus. Dans les journaux, notez seulement la catégorie d’erreur, sans secret ni corps complet.

Démarrer le tunnel local

  1. Démarrez l’application et vérifiez que la route reçoit un POST local sur le port 3000.
  2. Dans un autre terminal, lancez npx portpreview 3000, avec le véritable port de votre application.
  3. Ajoutez le chemin à l’origine générée, par exemple https://YOUR-TUNNEL.portpreview.dev/webhooks/zoom.
  4. Laissez l’application et le tunnel actifs pendant la validation et les essais.

Un nouveau nom d’hôte constitue un nouvel endpoint pour Zoom : mettez l’URL à jour et validez-la. Elle doit arriver directement sur le gestionnaire POST, car les redirections nuisent à la livraison et Zoom ne retente pas les réponses 3xx.

Ajouter l’abonnement aux événements dans Zoom

Dans Zoom App Marketplace, ouvrez l’application créée puis la zone Features ou Access indiquée par le parcours actuel. Activez Event Subscriptions, ajoutez un abonnement, choisissez les types d’événement et le destinataire, puis collez l’URL HTTPS complète. Les choix dépendent du type d’application et du compte ; modifier une application publiée peut imposer une nouvelle revue.

Placez le webhook secret token dans une variable locale ignorée telle que ZOOM_WEBHOOK_SECRET_TOKEN, puis redémarrez le serveur. Ce secret n’est ni l’OAuth client secret, ni un access token, ni l’ancien verification token.

Implémenter correctement la validation de l’URL de l’endpoint

Au clic sur Validate, Zoom envoie un POST avec l’événement endpoint.url_validation et un plainToken. Calculez un HMAC SHA-256 dont la clé est le webhook secret token et le message ce seul token, encodez le résultat en hexadécimal minuscule, puis renvoyez le plainToken inchangé et l’encryptedToken.

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

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

Répondez HTTP 200 avec ce JSON sous trois secondes. Ne hachez pas la requête entière, n’utilisez pas le secret OAuth, n’encodez pas en Base64 et n’ajoutez pas v0=. L’endpoint ne peut être enregistré avant cette validation. Zoom décrit aussi une revalidation automatique toutes les 72 heures : les échecs sont signalés au propriétaire et six échecs consécutifs désactivent l’abonnement. Supprimez les abonnements temporaires et maintenez ce traitement en production.

Vérifier les requêtes webhook Zoom ordinaires

Pour un événement normal, lisez x-zm-request-timestamp et construisez exactement :

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

Appliquez HMAC SHA-256 avec le webhook secret token, encodez en hexadécimal, préfixez par v0= et comparez avec x-zm-signature en temps constant. Il faut le corps original : un aller-retour par JSON.stringify peut modifier les espaces ou la forme des propriétés. Rejetez les signatures absentes, mal formées, fausses ou trop anciennes avant la logique métier, et synchronisez l’horloge système.

L’ancien webhook verification token, dont l’arrêt était prévu en juin 2025, ne doit plus servir à un contrôle d’égalité d’Authorization. Utilisez le flux HMAC documenté et consultez le guide de vérification des signatures de webhook.

Distribuer les types d’événement propres au fournisseur

Un payload signé doit encore être validé selon son schéma et son contexte d’autorisation. Pour les réunions, meeting ID et UUID n’ont pas le même rôle ; l’UUID est particulièrement utile pour corréler les réunions récurrentes.

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);
  }
}

L’ordre d’arrivée n’est pas un journal transactionnel : latence réseau, reprises et parallélisme peuvent le bouleverser. Conservez l’horodatage fournisseur, appliquez des règles d’état monotones et rendez les types inconnus observables après persistance sûre.

Respecter le délai de livraison de trois secondes

Zoom attend HTTP 200 ou 204 sous trois secondes. Vérifiez la requête, validez l’enveloppe minimale, écrivez dans une boîte de réception durable ou une file, puis répondez ; vidéo, CRM, calendrier, e-mail et analytique appartiennent aux workers.

La documentation actuelle annonce trois nouvelles tentatives pour les pannes serveur ou connexion éligibles : environ 5 minutes après l’essai initial, puis 20 minutes après cette reprise et enfin 60 minutes après la deuxième. Les 2xx réussissent ; les 3xx et erreurs client 4xx ne sont pas retentés. Vérifiez la documentation avant d’adosser des alertes à ces délais.

Rendre chaque événement idempotent

Une reprise peut suivre un timeout ambigu alors que la première transaction a réussi. Dédupliquez avant tout effet de bord. Utilisez x-zm-request-id lorsqu’il existe ; sinon, dérivez une clé stable de données immuables vérifiées ou d’un condensat cryptographique du corps brut. Imposez l’unicité dans le stockage, pas seulement en mémoire.

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 });
});

La réservation dans l’inbox et la création du job doivent être atomiques. En cas d’échec du worker, retentez le job sans réclamer une nouvelle livraison à Zoom. Le guide des reprises et de l’idempotence détaille ces frontières.

Diagnostiquer les échecs de validation et de livraison

Validate échoue

Contrôlez l’URL HTTPS publique, le chemin exact, l’absence de redirection, le port local actif, un HTTP 200 JSON et le HMAC hexadécimal minuscule du seul token, le tout sous trois secondes.

Tous les événements échouent à la vérification

Vérifiez le bon webhook secret token, capturez les octets avant le middleware JSON, reprenez l’horodatage exact, les deux deux-points de v0:timestamp:body et le préfixe final v0=.

La validation passe, mais aucun événement n’arrive

Vérifiez que l’abonnement est activé et enregistré, que les bons événements et destinataires sont choisis, que le compte les produit, que la revalidation est saine et que l’URL du tunnel n’a pas changé.

Le gestionnaire réussit, mais Zoom retente

Mesurez le statut et la latence publics. Persistez rapidement, répondez 2xx et traitez en asynchrone ; l’inbox idempotente neutralise les doublons.

Un événement capturé échoue lors d’une relecture

Un ancien horodatage doit échouer et toute modification du JSON invalide le HMAC. Utilisez un événement frais pour le test de bout en bout ; pour la logique métier, appelez seulement le dispatcher avec une fixture nettoyée. Le guide de rejeu des webhooks explique cette séparation.

Liste de contrôle de sécurité pour tester les webhooks Zoom

  • Conservez et renouvelez les secrets dans des fichiers d’environnement ignorés.
  • Vérifiez le HMAC sur le corps brut avant de faire confiance au payload.
  • Imposez une tolérance temporelle et synchronisez l’horloge.
  • Validez type d’événement, compte, identifiants, type de contenu et taille.
  • Masquez noms, e-mails, sujets, chats et enregistrements dans les logs.
  • Séparez endpoints ou secrets de développement et de production si possible.
  • Supprimez URL et abonnements temporaires après la session.

Le modèle de production est celui éprouvé en local : entrée HTTPS stable, défi toujours disponible, HMAC sur corps brut, idempotence durable, accusé sous trois secondes et workers isolés. Pour le traçage, consultez le guide de débogage local des webhooks.

Questions fréquentes

Comment valider une URL de webhook Zoom sur localhost ?
Exposez la route locale en HTTPS, puis répondez à endpoint.url_validation avec le plainToken original et son encryptedToken HMAC SHA-256 hexadécimal calculé avec le webhook secret token.
Comment vérifier la signature d’un webhook Zoom ordinaire ?
Construisez v0:{x-zm-request-timestamp}:{raw body}, calculez son HMAC SHA-256 avec le webhook secret token, préfixez le condensat hexadécimal par v0= et comparez-le à x-zm-signature.
Pourquoi la validation d’URL réussit-elle alors que la signature échoue ?
Les messages signés diffèrent : la validation ne hache que plainToken, tandis qu’un événement signe la version, l’horodatage et le corps brut exact. Parser puis resérialiser le JSON peut casser la signature.
Sous quel délai un webhook Zoom doit-il répondre ?
La documentation actuelle exige HTTP 200 ou 204 sous trois secondes. Persistez ou mettez en file l’événement vérifié, répondez, puis exécutez le traitement lent en asynchrone.