ITSkillsCenter
E-commerce

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

18 min de lecture

📍 Article principal de la série : WhatsApp Cloud API en 2026 : architecture, webhooks, templates et intégrations.

Recevoir un message WhatsApp en temps réel suppose que Meta puisse pousser l’événement vers votre serveur dès qu’un utilisateur écrit, clique sur un bouton ou que la plateforme met à jour le statut d’un message sortant (envoyé, livré, lu, échec). Cette pousseé prend la forme d’une requête HTTPS POST signée vers une URL publique que vous déclarez dans le tableau de bord Meta. Sans cette URL, vous êtes condamné à un fonctionnement uniquement sortant : vous pouvez envoyer des templates, mais vous ne saurez jamais si quelqu’un répond. Ce tutoriel construit pas à pas un récepteur Node.js + Express qui vérifie la signature HMAC, parse les événements messages et statuses, gère l’idempotence sur la fenêtre de retry observée, et s’expose en HTTPS local via cloudflared.

Prérequis

Avant d’écrire la moindre ligne, assurez-vous d’avoir l’environnement suivant. Node.js 20 LTS ou supérieur, parce que c’est la version officiellement maintenue jusqu’en avril 2026 et qu’elle apporte le module crypto en natif sans flag expérimental. npm 10+ qui arrive avec Node 20. Un compte Meta for Developers actif, avec une application de type Business et le produit WhatsApp ajouté. Un numéro test fourni par Meta lors de la création du produit (vous n’avez pas besoin d’acheter de numéro pour valider la mécanique). L’App Secret de votre application Meta, accessible dans App settings → Basic → App Secret ; vous en aurez besoin pour vérifier la signature HMAC. Et enfin un client de tunnel HTTPS, soit cloudflared (gratuit, illimité, recommandé en 2026), soit ngrok si vous avez déjà un compte. Côté éditeur, n’importe quel IDE fait l’affaire ; les exemples sont en JavaScript pur sans TypeScript pour rester lisibles.

Comprendre le flux webhook Meta → votre serveur

Le webhook WhatsApp Cloud API repose sur deux opérations distinctes que beaucoup de développeurs confondent au début. La première est la vérification : quand vous enregistrez l’URL dans le dashboard, Meta envoie une seule requête GET avec trois paramètres (hub.mode=subscribe, hub.verify_token, hub.challenge). Votre serveur doit comparer le verify_token reçu à celui que vous avez configuré côté serveur, et si la valeur correspond, renvoyer le hub.challenge tel quel en réponse en text/plain avec un statut 200. C’est la poignée de main initiale, elle ne se produit qu’au moment de l’enregistrement ou d’un changement d’URL.

La seconde opération est la réception en temps réel : tant que l’abonnement est actif, Meta envoie une requête POST en JSON à la même URL chaque fois qu’un événement se produit sur votre numéro. Le corps contient un objet racine avec un tableau entry, chaque entrée listant des changes, et chaque changement portant un value qui contient soit un tableau messages (message entrant), soit un tableau statuses (mise à jour de delivery), parfois les deux. Chaque POST est signé via l’en-tête X-Hub-Signature-256 au format sha256=... calculé en HMAC-SHA256 du corps brut avec votre App Secret comme clé. Si vous ne vérifiez pas cette signature, n’importe qui sur Internet peut envoyer de faux messages à votre endpoint.

Meta exige un statut HTTP 200 rapidement (les BSP convergent sur une fenêtre pratique de 5 à 10 secondes), sans quoi l’événement est considéré comme échoué et la plateforme rejouera la livraison selon une politique de retry qui s’étend sur la durée annoncée par Meta (36 heures officiellement, certains BSP signalent jusqu’à sept jours). C’est pour cela qu’on traite le payload de manière asynchrone : on répond 200 immédiatement et on enfile le travail réel ailleurs.

Étape 1 — Initialiser un projet Node.js Express minimal

On commence par un dossier propre et un package.json minimal. Express 4.x reste la version stable largement déployée en production en 2026 ; Express 5.0 est sorti fin 2024 mais introduit des changements de comportement sur la gestion d’erreurs async qu’on n’a pas besoin d’absorber pour ce cas d’usage. On reste donc sur 4.x. On ajoute dotenv pour charger les secrets depuis un fichier .env, et c’est tout — pas besoin d’autre dépendance, le module crypto est dans la bibliothèque standard de Node.

mkdir whatsapp-webhook && cd whatsapp-webhook
npm init -y
npm install express@^4.19.2 dotenv@^16.4.5

Vous devriez voir apparaître un dossier node_modules et un package-lock.json. Vérifiez ensuite que votre package.json ressemble à ceci, en particulier la ligne "type": "module" qui autorise la syntaxe import moderne. Sans ce flag, Node basculerait en CommonJS et il faudrait écrire require() partout.

{
  "name": "whatsapp-webhook",
  "version": "1.0.0",
  "type": "module",
  "main": "server.js",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "dotenv": "^16.4.5",
    "express": "^4.19.2"
  }
}

Le signal de réussite est qu’un npm start ne renverra pas encore d’erreur de fichier manquant — on créera server.js à l’étape suivante. Si vous voyez SyntaxError: Cannot use import statement outside a module, c’est que la ligne type manque ou est mal orthographiée.

Étape 2 — Créer la route GET /webhook pour la vérification

Le squelette du serveur tient en quelques lignes. On charge dotenv, on instancie Express, on déclare la route GET. La logique de vérification est simple : on lit les query params, on compare le token, on renvoie le challenge ou un 403. Important, on doit retourner le challenge sous forme brute (en clair, pas en JSON) parce que Meta lit la réponse comme du texte pour confirmer la correspondance.

// server.js
import 'dotenv/config';
import express from 'express';

const app = express();
const PORT = process.env.PORT || 3000;
const VERIFY_TOKEN = process.env.VERIFY_TOKEN;

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) {
    console.log('Webhook verified');
    return res.status(200).send(challenge);
  }
  return res.sendStatus(403);
});

app.listen(PORT, () => console.log(`Listening on ${PORT}`));

À ce stade, lancer npm start doit afficher Listening on 3000 et l’appel curl "http://localhost:3000/webhook?hub.mode=subscribe&hub.verify_token=test&hub.challenge=42" doit renvoyer 42 si vous avez positionné VERIFY_TOKEN=test. C’est exactement ce que Meta enverra plus tard, mais en HTTPS depuis ses serveurs.

Étape 3 — Créer la route POST /webhook pour recevoir les événements

La route POST est plus subtile parce qu’on a besoin d’accéder au corps brut de la requête pour calculer la signature HMAC, tout en parsant aussi le JSON pour le traiter. Si on utilise le middleware express.json() standard, on perd l’accès au buffer original. La solution propre est de fournir une option verify qui capture le buffer brut sur req.rawBody avant que le parsing JSON ne s’exécute.

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

app.post('/webhook', (req, res) => {
  // Repondre 200 immediatement (fenêtre pratique observée : 5 à 10 secondes)
  res.sendStatus(200);

  // Traiter ensuite le payload de maniere asynchrone
  handleEvent(req.body).catch(err => console.error('Handler error', err));
});

async function handleEvent(payload) {
  console.log('Payload recu:', JSON.stringify(payload, null, 2));
}

Ce pattern « répondre 200 d’abord, traiter ensuite » est crucial : il évite que Meta considère votre endpoint comme lent et déclenche un retry alors que vous êtes encore en train de traiter le premier événement. Le signal de réussite à ce stade est qu’un POST de test depuis Postman avec un JSON quelconque sur /webhook retourne 200 et logue le payload dans le terminal.

Étape 4 — Vérifier la signature HMAC SHA-256

La vérification de signature est la seule barrière entre votre handler et un attaquant qui aurait deviné votre URL. Le principe est documenté côté Meta : la plateforme calcule HMAC-SHA256(app_secret, raw_body) et place le résultat dans l’en-tête X-Hub-Signature-256 sous la forme sha256=<hex>. Vous devez recalculer la même chose côté serveur et comparer en temps constant pour éviter les attaques par mesure de timing.

import crypto from 'node:crypto';

const APP_SECRET = process.env.APP_SECRET;

function verifySignature(req) {
  const header = req.get('x-hub-signature-256');
  if (!header || !header.startsWith('sha256=')) return false;
  const received = Buffer.from(header.slice(7), 'hex');
  const expected = crypto
    .createHmac('sha256', APP_SECRET)
    .update(req.rawBody)
    .digest();
  if (received.length !== expected.length) return false;
  return crypto.timingSafeEqual(received, expected);
}

Branchez ensuite cette fonction au tout début du handler POST, avant même de répondre 200 : si la signature est invalide, renvoyez 401 et n’enfilez aucun traitement. C’est la règle d’or, parce qu’un attaquant qui voit que votre serveur traite quand même les requêtes non signées ira chercher d’autres failles à exploiter.

app.post('/webhook', (req, res) => {
  if (!verifySignature(req)) {
    console.warn('Signature invalide, rejet');
    return res.sendStatus(401);
  }
  res.sendStatus(200);
  handleEvent(req.body).catch(err => console.error('Handler error', err));
});

Le test concret est qu’un POST sans en-tête X-Hub-Signature-256 retourne désormais 401, alors qu’un POST signé correctement (Meta le fera automatiquement) passe à 200.

Étape 5 — Parser les événements messages

Le payload entrant suit toujours la même hiérarchie : entry[].changes[].value.messages[]. Chaque message porte un champ type qui indique sa nature (text, image, audio, document, button, interactive, location, contacts, etc.) et un sous-objet portant le contenu spécifique. Le champ from contient le numéro E.164 sans le +, le champ id contient l’identifiant unique du message côté Meta — celui-là va vous servir pour l’idempotence à l’étape 10.

async function handleEvent(payload) {
  for (const entry of payload.entry || []) {
    for (const change of entry.changes || []) {
      const value = change.value || {};
      for (const message of value.messages || []) {
        await routeMessage(message, value.metadata);
      }
      for (const status of value.statuses || []) {
        await routeStatus(status);
      }
    }
  }
}

async function routeMessage(msg, meta) {
  const from = msg.from;
  switch (msg.type) {
    case 'text':
      console.log(`Texte de ${from}: ${msg.text.body}`);
      break;
    case 'image':
      console.log(`Image de ${from}, media id: ${msg.image.id}`);
      break;
    case 'button':
      console.log(`Clic bouton: ${msg.button.payload}`);
      break;
    case 'interactive':
      const i = msg.interactive;
      const reply = i.button_reply || i.list_reply;
      console.log(`Reponse interactive: ${reply?.id} - ${reply?.title}`);
      break;
    default:
      console.log(`Type non gere: ${msg.type}`);
  }
}

Pour les images, audios, vidéos et documents, vous ne recevez qu’un id de média ; il faudra ensuite faire un appel séparé à l’endpoint /v23.0/{media-id} de la Graph API pour récupérer une URL signée temporaire et télécharger le fichier. Cette logique mériterait son propre tutoriel, mais le squelette ci-dessus vous montre où l’insérer.

Étape 6 — Parser les événements message_status

Quand vous envoyez un message via l’API Cloud, Meta vous renvoie un identifiant. Mais l’envoi côté API ne signifie pas la livraison côté téléphone. Pour suivre le cycle de vie réel, vous devez écouter les événements statuses, qui arrivent sur le même webhook avec quatre valeurs possibles : sent (l’API a accepté et envoyé vers WhatsApp), delivered (le téléphone du destinataire a accusé réception), read (le destinataire a ouvert la conversation) si l’utilisateur a activé les confirmations de lecture, et failed (avec un sous-objet errors qui détaille la cause).

async function routeStatus(status) {
  const id = status.id; // Identifiant du message sortant
  switch (status.status) {
    case 'sent':
      await markSent(id, status.timestamp);
      break;
    case 'delivered':
      await markDelivered(id, status.timestamp);
      break;
    case 'read':
      await markRead(id, status.timestamp);
      break;
    case 'failed':
      const err = status.errors?.[0];
      console.error(`Echec ${id}: ${err?.code} - ${err?.title}`);
      await markFailed(id, err);
      break;
  }
}

Les fonctions markSent, markDelivered, etc. représentent vos écritures en base — un simple UPDATE messages SET status = ? WHERE wa_id = ? suffit. Le timestamp est en secondes Unix (chaîne) ; pensez à le convertir en entier avant de le persister. Les codes d’erreur courants sont documentés dans la table d’erreurs Cloud API ; les plus fréquents sont 131047 (fenêtre de 24 h dépassée) et 131026 (le destinataire n’est pas un utilisateur WhatsApp valide).

Étape 7 — Exposer le serveur local en HTTPS avec cloudflared

Meta refuse les URL en HTTP et n’accepte aucun nom d’hôte non résolvable publiquement. En développement local, on contourne cela avec un tunnel. cloudflared est aujourd’hui l’option la plus pragmatique : gratuit, sans compte requis pour les tunnels « rapides », bande passante illimitée, et déployé partout sur l’infrastructure Cloudflare. L’installation se fait via les paquets officiels documentés sur le site de Cloudflare ; sur macOS via Homebrew, sur Linux via le paquet .deb ou .rpm, et sur Windows via le binaire cloudflared.exe téléchargé depuis le dépôt GitHub officiel.

# Lancement du tunnel rapide vers le port 3000
cloudflared tunnel --url http://localhost:3000

Au bout de quelques secondes, vous verrez apparaître dans le terminal une URL du type https://random-name-1234.trycloudflare.com. C’est cette URL que Meta interrogera. Elle reste vivante tant que la commande tourne ; à chaque relance vous obtiendrez une URL différente, ce qui est gênant pour des tests longs — pour une URL stable il faut créer un tunnel nommé via cloudflared tunnel create et l’attacher à un domaine que vous contrôlez. Pour un développement ponctuel, le tunnel rapide suffit largement.

Étape 8 — Configurer le webhook dans Meta App Dashboard

Direction developers.facebook.com, sélectionnez votre application, puis dans le menu de gauche WhatsApp → Configuration. Sous la section Webhook, cliquez sur Edit. Renseignez l’URL de callback (l’URL cloudflared suivie de /webhook) et le verify token (la même chaîne que vous avez mise dans VERIFY_TOKEN de votre .env). Cliquez sur Verify and save ; Meta enverra immédiatement la requête GET de l’étape 2 et votre serveur doit répondre avec le challenge.

Une fois la vérification passée, vous voyez la liste des champs auxquels vous pouvez vous abonner. Cochez au minimum messages qui couvre à la fois les messages entrants et les statuts ; c’est le seul champ vraiment indispensable pour la majorité des intégrations. Vous pouvez décocher les autres pour limiter le bruit. Sauvegardez : votre webhook est maintenant actif.

Étape 9 — Vérification : envoyer un message au numéro test

Le test fonctionnel se fait en deux temps. D’abord, depuis votre téléphone personnel ajouté en numéro de test dans WhatsApp → API Setup, envoyez un simple « hello » au numéro test fourni par Meta. Vous devriez voir, dans les secondes qui suivent, le payload arriver dans votre terminal. Le log doit afficher quelque chose comme Texte de 221XXXXXXXXX: hello.

Ensuite, déclenchez un envoi sortant via curl sur l’endpoint d’envoi de l’API Cloud (voir le tutoriel d’envoi pour la syntaxe exacte). Quelques secondes après l’appel, vous devriez voir arriver successivement les statuts sent, puis delivered dès que le téléphone du destinataire est en ligne, puis éventuellement read. Si vous ne voyez rien, vérifiez d’abord les logs du tunnel cloudflared (il affiche chaque requête entrante), puis l’onglet Webhooks du dashboard Meta qui montre l’historique des livraisons et les éventuelles erreurs HTTP.

Étape 10 — Idempotence : éviter de traiter deux fois le même événement

Meta documente une politique de retry (la doc Meta indique 36 heures, certains BSP signalent jusqu’à sept jours) : tant qu’un endpoint ne répond pas 200 dans le délai imparti, la plateforme rejouera la livraison à intervalles croissants pendant une semaine. Combiné à un timeout intermittent côté serveur, cela signifie que vous recevrez parfois deux, trois, voire dix fois le même événement. Si votre handler insère naïvement chaque message en base, vous vous retrouvez avec des doublons et des notifications redondantes envoyées à vos utilisateurs internes.

La parade est un mécanisme d’idempotence basé sur le champ id du message ou du statut, qui est garanti unique par Meta. Le pattern minimal est une table processed_events avec event_id en clé primaire ; vous tentez l’INSERT avant de traiter, et vous abandonnez silencieusement si la clé existe déjà.

// Cache memoire simple (a remplacer par Redis ou Postgres en prod)
const seen = new Set();

async function alreadyProcessed(id) {
  if (seen.has(id)) return true;
  seen.add(id);
  return false;
}

async function routeMessage(msg, meta) {
  if (await alreadyProcessed(msg.id)) {
    console.log(`Doublon ignore: ${msg.id}`);
    return;
  }
  // ... traitement metier
}

En production, remplacez le Set par un store persistant. Redis convient très bien avec une commande SET key value NX EX 604800 qui pose la clé seulement si elle n’existe pas, avec une expiration alignée sur la fenêtre de retry observée. Une fois ce mécanisme en place, vous pouvez subir un crash, redémarrer, recevoir les retries — chaque événement ne sera traité qu’une fois.

Erreurs fréquentes

SymptômeCause probableCorrectif
Signature invalide à chaque POSTCalcul HMAC sur JSON.stringify(req.body) au lieu du buffer brutCapturer req.rawBody via l’option verify de express.json
Verify GET retourne 403VERIFY_TOKEN différent entre .env et le dashboardRecopier exactement la même chaîne, sans espace en fin
Webhook timeout, retry stormTraitement synchrone dépassant 20 secondesRépondre 200 immédiatement, traiter en arrière-plan via une queue
Doublons en base après crashPas d’idempotence sur message.idAjouter un store de déduplication TTL 7 jours
URL cloudflared change à chaque relanceTunnel rapide non nomméCréer un tunnel nommé attaché à un domaine
Aucun statut read reçuLe destinataire a désactivé les confirmations de lectureC’est normal, ne pas en dépendre pour la logique métier

Sécurité : ce qu’il faut absolument faire

Trois règles non négociables. Premièrement, vérifier la signature HMAC sur 100 % des POST, sans exception « pour le développement ». Un endpoint laissé ouvert même quelques heures finit par être indexé et bombardé. Deuxièmement, ne jamais commiter le fichier .env ; ajoutez-le à .gitignore immédiatement après l’avoir créé, et utilisez un gestionnaire de secrets (variables d’environnement de votre PaaS, Vault, Doppler) en production. Troisièmement, limiter la taille du corps accepté via express.json({ limit: '1mb' }) pour éviter qu’un attaquant puisse vous envoyer 100 Mo de JSON et saturer la mémoire du processus.

Au-delà de ces trois fondamentaux, ajoutez une journalisation structurée (pino, winston) qui inclut l’event_id dans chaque ligne, des métriques sur le délai entre réception et traitement effectif, et une alerte quand le taux de signatures invalides dépasse un seuil — c’est souvent le premier signal d’une tentative de scan automatisé contre votre endpoint.

Tutoriels frères

Pour aller plus loin

Une fois le récepteur stable, les chantiers naturels sont la mise en file d’attente des événements via Redis Streams, BullMQ ou un broker comme RabbitMQ pour découpler la réception du traitement métier ; la persistance des messages entrants dans Postgres avec un index sur (wa_id, timestamp) pour reconstituer les conversations ; et le téléchargement asynchrone des médias via la Graph API en respectant les URLs signées qui n’ont qu’une validité de cinq minutes. Le passage en production demande aussi de basculer sur un tunnel nommé ou un déploiement direct derrière un reverse proxy TLS (Caddy, Traefik, Nginx) avec un certificat Let’s Encrypt.

FAQ

Puis-je utiliser ngrok à la place de cloudflared ?

Oui, le code ne change pas d’une ligne. La différence est tarifaire : ngrok limite la bande passante et les connexions sur son offre gratuite et impose un compte, tandis que cloudflared en mode tunnel rapide n’a aucune de ces contraintes. Pour un usage ponctuel, les deux font le travail.

Pourquoi répondre 200 avant d’avoir traité l’événement ?

Parce que Meta considère tout dépassement de quelques secondes comme un échec et déclenche un retry. Si votre traitement métier prend cinq secondes mais que la base est lente cinq secondes de plus, vous risquez le timeout. Répondre tout de suite garantit que la livraison est confirmée, et l’idempotence vous protège contre les retries éventuels si votre traitement asynchrone plante derrière.

Que se passe-t-il si je ne vérifie pas la signature ?

N’importe qui ayant deviné votre URL peut envoyer de faux messages, déclencher des actions internes (envoi de templates, débit de crédits), polluer votre base ou générer du spam vers vos utilisateurs internes. La signature est la seule garantie cryptographique que la requête vient bien de Meta.

Comment obtenir l’App Secret ?

Dans App settings → Basic de votre application Meta, cliquez sur Show à côté du champ App Secret et confirmez votre mot de passe Facebook. Copiez la valeur dans APP_SECRET de votre .env et ne la partagez jamais. Si elle fuit, régénérez-la depuis le même écran — toutes les signatures précédemment calculées deviendront alors invalides, ce qui forcera la rotation de tous vos environnements.

Mon webhook reçoit le même message dix fois, est-ce normal ?

C’est le symptôme classique d’un endpoint qui ne répond pas 200 dans le délai requis : Meta rejoue selon une politique de retry s’étendant sur sept jours. Vérifiez les logs du tunnel et la réponse réelle envoyée par votre serveur. Une fois la cause corrigée, l’idempotence vous protège des doublons résiduels déjà en file chez Meta.

Sponsoriser ce contenu

Cet emplacement est à vous

Position premium en fin d'article — c'est l'instant où les lecteurs sont le plus engagés. Réservez cet espace pour votre marque, votre formation ou votre offre.

Recevoir nos tarifs
Publicité