Développement

Recevoir les webhooks WhatsApp Cloud API avec Node.js et Express

Construire un récepteur de webhooks WhatsApp Cloud API en Node.js et Express : vérification GET, réception des messages, et validation de la signature X-Hub-Signature-256.

Envoyer un message WhatsApp, c’est la moitié du travail. Pour qu’un chatbot existe — pour réagir à ce qu’écrit un client — il faut recevoir. C’est le rôle des webhooks : Meta appelle votre serveur à chaque événement, et votre serveur décide quoi en faire. Ce tutoriel construit, pas à pas, un récepteur de webhooks WhatsApp en Node.js et Express : la vérification initiale, la réception des messages, et — point trop souvent négligé — la vérification de la signature qui garantit que la requête vient bien de Meta.

C’est un satellite du cluster WhatsApp Cloud API en 2026. Il suppose que vous avez déjà un compte et un jeton, obtenus dans le tutoriel créer son compte Meta sans BSP. Tout le code est vérifié contre la documentation officielle Meta.

Le principe en deux temps

Un webhook, c’est une URL HTTPS de votre serveur que Meta appelle. Sa mise en place comporte deux phases distinctes, sur la même URL. La vérification d’abord : au moment où vous enregistrez l’URL dans le tableau de bord Meta, une requête GET vous est envoyée pour prouver que vous contrôlez bien ce serveur. La réception ensuite : une fois vérifiée, l’URL reçoit en POST tous les événements — messages entrants, accusés de remise, changements de statut. Votre serveur doit gérer ces deux verbes HTTP.

Pourquoi des webhooks plutôt qu’un sondage

On pourrait imaginer interroger l’API en boucle pour savoir si un message est arrivé. Mauvaise idée : du gaspillage de requêtes, de la latence, et surtout l’API WhatsApp ne le permet pas. Les messages entrants ne sont disponibles que par webhook. C’est un choix d’architecture de Meta : la plateforme vous pousse l’événement à l’instant où il se produit, plutôt que de vous laisser le réclamer. Le modèle est plus efficace et plus réactif — à condition d’avoir un serveur prêt à recevoir, ce que ce tutoriel met en place.

Ce qu’il faut

Trois choses. Node.js et Express, pour le serveur. Une URL publique en HTTPS : Meta n’appelle pas un localhost. En développement, un tunnel comme ngrok expose votre machine ; en production, c’est l’URL de votre serveur déployé. Enfin, deux secrets : le jeton de vérification (une chaîne que vous inventez et redonnerez à Meta) et l’app secret de votre application Meta (pour valider la signature). On les range dans des variables d’environnement, jamais dans le code.

Le squelette Express

On initialise le projet et on installe Express :

npm init -y
npm install express

Un détail capital pour la suite : la vérification de signature exige le corps brut de la requête (les octets exacts reçus), or Express le consomme en le transformant en objet JSON. On configure donc le parseur pour conserver une copie brute au passage :

const express = require('express');
const crypto = require('crypto');
const app = express();

app.use(express.json({
  verify: (req, res, buf) => { req.rawBody = buf; }
}));

const VERIFY_TOKEN = process.env.WHATSAPP_VERIFY_TOKEN;
const APP_SECRET   = process.env.WHATSAPP_APP_SECRET;

Phase 1 — la vérification (GET)

Lors de l’enregistrement, Meta envoie un GET avec trois paramètres de requête : hub.mode (qui vaut subscribe), hub.verify_token (le secret que vous avez donné à Meta) et hub.challenge (une valeur aléatoire). Votre serveur vérifie le mode et le token, puis renvoie le challenge tel quel avec un code 200 :

app.get('/webhook', (req, res) => {
  const mode = req.query['hub.mode'];
  const token = req.query['hub.verify_token'];
  const challenge = req.query['hub.challenge'];

  if (mode === 'subscribe' && token === VERIFY_TOKEN) {
    return res.status(200).send(challenge);
  }
  return res.sendStatus(403);
});

Si le token ne correspond pas, on répond 403 : c’est une tentative non autorisée. Une fois ce GET réussi, Meta considère le canal établi et bascule en mode réception.

Phase 2 — recevoir les messages (POST)

Les événements arrivent désormais en POST, sous forme d’un JSON imbriqué. La charge utile suit toujours la même ossature : un tableau entry, contenant des changes, chacun portant une value où se trouvent les messages. On y pioche le numéro de l’expéditeur et le contenu :

app.post('/webhook', (req, res) => {
  // (la vérification de signature vient juste après)
  res.sendStatus(200); // accuser réception immédiatement

  const value = req.body?.entry?.[0]?.changes?.[0]?.value;
  const message = value?.messages?.[0];

  if (message) {
    const from = message.from;            // numéro de l'expéditeur
    const text = message.text?.body;      // contenu, si type texte
    console.log('Message de ' + from + ' : ' + text);
    // ... déclencher une réponse via l'API d'envoi
  }
});

app.listen(3000, () => console.log('Webhook en écoute sur le port 3000'));

L’usage de l’optional chaining (?.) n’est pas un caprice de style : la charge utile varie selon l’événement (un message texte, un accusé de remise, un statut de template n’ont pas la même structure), et naviguer prudemment évite que le serveur ne plante sur un champ absent.

Vérifier la signature : l’étape non négociable

Telle quelle, votre URL accepterait n’importe quelle requête — y compris celle d’un imposteur qui aurait deviné votre adresse. Meta signe donc chaque charge utile : un en-tête X-Hub-Signature-256 contient un HMAC SHA-256 du corps, calculé avec votre app secret. Votre serveur recalcule ce HMAC et le compare ; s’ils diffèrent, la requête est rejetée.

function verifierSignature(req) {
  const signature = req.get('X-Hub-Signature-256') || '';
  const attendu = 'sha256=' + crypto
    .createHmac('sha256', APP_SECRET)
    .update(req.rawBody)
    .digest('hex');

  const a = Buffer.from(signature);
  const b = Buffer.from(attendu);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Deux précautions de sécurité dans ces quelques lignes. On compare avec crypto.timingSafeEqual plutôt qu’un simple === : cela évite une attaque par mesure du temps de comparaison. Et l’on vérifie d’abord l’égalité des longueurs, car timingSafeEqual lève une erreur si les deux tampons n’ont pas la même taille. On branche cette fonction tout en haut du gestionnaire POST :

app.post('/webhook', (req, res) => {
  if (!verifierSignature(req)) {
    return res.sendStatus(401);
  }
  res.sendStatus(200);
  // ... traitement du message
});

Répondre 200, puis traiter

Un réflexe d’architecture qui fait toute la différence : accuser réception immédiatement, puis traiter. Meta attend un code 200 rapide ; si votre serveur prend trop de temps (parce qu’il appelle une base, un LLM, une API de paiement…), Meta considère l’envoi en échec et réessaie — vous risquez alors de traiter le même message plusieurs fois. La bonne pratique : répondre 200 en premier, puis lancer le traitement lourd de manière asynchrone (file d’attente, worker). Le webhook accuse réception ; le travail se fait derrière.

Boucler la conversation : répondre par l’API d’envoi

Recevoir n’a de sens que pour répondre. Une fois le message extrait, on rappelle l’API d’envoi vue dans le pilier — depuis le même serveur, avec le jeton d’accès. En Node 18+, fetch est disponible nativement :

async function repondre(to, texte) {
  await fetch(
    'https://graph.facebook.com/v23.0/' + process.env.WHATSAPP_PHONE_NUMBER_ID + '/messages',
    {
      method: 'POST',
      headers: {
        'Authorization': 'Bearer ' + process.env.WHATSAPP_TOKEN,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        messaging_product: 'whatsapp',
        to: to,
        type: 'text',
        text: { body: texte }
      })
    }
  );
}

Comme le client vient de vous écrire, la fenêtre de service de 24 heures est ouverte : cette réponse en texte libre est autorisée et gratuite. C’est la boucle complète — Meta livre le message par webhook, votre serveur le lit et répond dans la foulée. Un chatbot n’est rien d’autre que cette boucle, enrichie d’une logique de décision.

Exposer son serveur et l’enregistrer

En développement, on rend le serveur joignable avec un tunnel :

ngrok http 3000

ngrok renvoie une URL HTTPS publique (du type https://xxxx.ngrok-free.app). Dans le tableau de bord Meta, à la configuration des webhooks de l’application, on colle cette URL suivie de /webhook, on saisit le même jeton de vérification que dans le code, et l’on valide : le GET de vérification part aussitôt, et votre serveur doit renvoyer le challenge. Il reste à s’abonner au champ messages pour recevoir les messages entrants — sans cet abonnement, le canal est établi mais muet. En production, on remplace simplement l’URL ngrok par celle du serveur déployé.

De ngrok à la production

ngrok est parfait pour développer, mais éphémère : son URL change à chaque redémarrage et le tunnel ferme avec votre machine. En production, on déploie le serveur sur un hôte joignable — un VPS, par exemple — derrière un reverse-proxy (Nginx, Caddy) qui fournit le HTTPS, indispensable car Meta refuse le HTTP simple. On fait tourner le processus Node sous un gestionnaire comme pm2 pour qu’il redémarre seul en cas de crash, et l’on injecte les secrets (jeton, app secret, jeton de vérification) par variables d’environnement. Le code ne change pas : seule l’URL enregistrée dans Meta passe de l’adresse ngrok à votre domaine.

Comprendre la charge utile

Tous les événements ne sont pas des messages. Dans la value, deux tableaux cohabitent selon le cas. messages contient les messages entrants de vos clients (texte, image, réponse à un bouton…). statuses contient les mises à jour de vos messages sortants : sent, delivered, read, ou failed. Un récepteur complet teste la présence de chacun et route en conséquence : répondre à un message entrant, mettre à jour un suivi de livraison sur un accusé, journaliser un échec. C’est cette distinction qui transforme un simple echo en véritable système conversationnel.

Distinguer les types de messages entrants

Le champ message.type indique la nature du message reçu, et chaque type range son contenu ailleurs. Pour du text, le contenu est dans message.text.body. Quand le client tape sur un bouton, le type est interactive et la réponse se lit dans message.interactive.button_reply (ou list_reply pour une liste) — avec un identifiant que vous aviez défini, ce qui rend le routage trivial. Une image arrive en type image avec un identifiant de média à télécharger séparément ; une position partagée en type location avec latitude et longitude. Un chatbot robuste commence donc par un aiguillage sur message.type, puis traite chaque cas. Les boutons et les listes — le cœur d’un menu interactif — font l’objet d’un satellite dédié.

Idempotence : ne traiter chaque message qu’une fois

Parce que Meta réessaie tant qu’il n’a pas reçu un 200 à temps, le même événement peut vous parvenir plusieurs fois. Si chaque réception déclenche une action (envoyer une réponse, créer une commande, débiter un paiement), le doublon devient un vrai problème. La parade : chaque message porte un identifiant unique dans message.id. On le mémorise (en base, ou en cache Redis) au premier traitement, et l’on ignore tout message dont l’identifiant a déjà été vu. Cette discipline d’idempotence sépare un prototype d’un service de production fiable.

Pièges courants

  • Parser JSON avant de capturer le corps brut. Si vous ne conservez pas rawBody, la signature ne pourra jamais correspondre : le HMAC se calcule sur les octets exacts, pas sur le JSON re-sérialisé.
  • Répondre 200 trop tard. Un traitement synchrone lent déclenche les renvois de Meta et le double-traitement. On accuse réception d’abord.
  • Oublier l’abonnement au champ messages. Webhook vérifié mais aucun message reçu ? L’abonnement n’a pas été coché.
  • Ignorer la signature. Une URL non protégée est une porte ouverte aux faux événements. La vérification X-Hub-Signature-256 est obligatoire en production.
  • Supposer une structure fixe. La charge utile change selon l’événement ; naviguer sans prudence (?.) finit par planter le serveur.
  • Tunnel ngrok expiré. En dev, l’URL ngrok change à chaque redémarrage : il faut la re-déclarer dans Meta, sinon les événements partent dans le vide.

En résumé

  • Un webhook gère deux verbes : GET pour la vérification (renvoyer hub.challenge), POST pour les événements.
  • On conserve le corps brut (rawBody) pour valider la signature X-Hub-Signature-256 avec l’app secret, en comparaison à temps constant.
  • On répond 200 immédiatement, puis on traite de façon asynchrone pour éviter les renvois et le double-traitement.
  • On expose le serveur (ngrok en dev), on enregistre l’URL dans Meta et on s’abonne au champ messages.

Voir aussi

Malick Diallo

Rédaction SenTur

Contributeur SenTur — passionné de tech et de transmission.

Aucun commentaire pour l'instant — lancez la discussion !

Laisser un commentaire

Votre adresse email ne sera pas publiée. La discussion est modérée — restez courtois et constructif.