Terminal sombre en écran partagé montrant un gestionnaire de webhook Node.js avec vérification HMAC-SHA256 à gauche et une charge utile JSON WhatsApp entrante à droite
Dans ce guide : Fonctionnement du pipeline webhook · Construire le point de terminaison (Node.js + Python) · Gérer le défi de vérification · Vérification de signature HMAC-SHA256 · Analyser chaque type de message · Gérer les téléchargements de médias · Modèle de file d'attente pour la production · SocialHook comme couche managée · FAQ

Fonctionnement du pipeline de messages WhatsApp entrants

La plupart des tutoriels commencent par du code avant d'expliquer l'architecture. C'est pourquoi la plupart des implémentations contiennent des bugs. Voici le pipeline complet — chaque saut effectué par un message avant d'atteindre votre logique applicative :

Quatre conditions doivent être remplies pour que ce pipeline fonctionne de manière fiable :

  • Votre point de terminaison est accessible publiquement via HTTPS. Pas de localhost, pas de HTTP. Meta rejette les URL non-HTTPS et ne peut pas atteindre les IP privées.
  • Votre point de terminaison répond avec 200 en moins de 20 secondes. Toute réponse plus lente entraîne une nouvelle tentative de livraison de la part de Meta. Si les tentatives se répètent suffisamment, Meta cesse entièrement d'envoyer vers votre point de terminaison.
  • Vous vérifiez la signature HMAC-SHA256 sur chaque requête. Sans cela, tout attaquant qui découvre l'URL de votre webhook peut envoyer de faux messages à votre système.
  • Vous acquittez d'abord, vous traitez ensuite. Renvoyez 200 immédiatement, poussez la charge utile vers une file d'attente, traitez de façon asynchrone. Ne faites jamais de travail lourd de manière synchrone dans le gestionnaire de webhook.

Étape 1 : Construire le point de terminaison du webhook

Votre point de terminaison doit gérer deux méthodes HTTP sur la même URL : GET (défi de vérification unique de Meta) et POST (événements de messages en direct). Voici l'implémentation minimale fonctionnelle en Node.js et Python.

Node.js + Express
webhook.js
const express = require('express'); const crypto = require('crypto'); const app = express(); // CRITICAL: parse raw body BEFORE express.json() // You need the raw buffer for HMAC verification app.use('/webhook', express.raw({ type: '*/*' })); const VERIFY_TOKEN = process.env.WEBHOOK_VERIFY_TOKEN; // your own string const APP_SECRET = process.env.WHATSAPP_APP_SECRET; // from Meta Developer Dashboard // GET — Meta verification challenge 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 ✓'); res.status(200).send(challenge); // return challenge as plain text } else { res.sendStatus(403); } }); // POST — inbound message events app.post('/webhook', (req, res) => { // 1. Verify signature BEFORE anything else if (!verifySignature(req)) { return res.sendStatus(403); } // 2. Acknowledge immediately — NEVER process synchronously res.sendStatus(200); // 3. Parse and enqueue for async processing const body = JSON.parse(req.body.toString()); enqueue(body); // your queue function — see Step 6 }); app.listen(3000, () => console.log('Webhook server running :3000'));
Python + FastAPI
webhook.py
import hmac, hashlib, os, json from fastapi import FastAPI, Request, Response, HTTPException from fastapi.responses import PlainTextResponse app = FastAPI() VERIFY_TOKEN = os.environ["WEBHOOK_VERIFY_TOKEN"] APP_SECRET = os.environ["WHATSAPP_APP_SECRET"] # GET — Meta verification challenge @app.get("/webhook", response_class=PlainTextResponse) async def verify(request: Request): params = request.query_params mode = params.get("hub.mode") token = params.get("hub.verify_token") challenge = params.get("hub.challenge") if mode == "subscribe" and token == VERIFY_TOKEN: return challenge # plain text, 200 OK raise HTTPException(status_code=403) # POST — inbound events @app.post("/webhook") async def receive(request: Request): raw_body = await request.body() # 1. Verify HMAC before anything else if not verify_signature(request, raw_body): raise HTTPException(status_code=403) # 2. Acknowledge immediately payload = json.loads(raw_body) enqueue(payload) # push to async worker return Response(status_code=200)

Étape 2 : Gérer correctement le défi de vérification

Lorsque vous enregistrez l'URL de votre webhook dans le tableau de bord des développeurs Meta, Meta envoie une requête GET unique pour vérifier que vous contrôlez le point de terminaison. C'est l'étape qui fait échouer presque chaque première implémentation — généralement parce que les développeurs renvoient du JSON au lieu de texte brut, ou renvoient l'objet params complet au lieu de la seule valeur du défi.

La requête GET de Meta inclut trois paramètres de requête :

  • hub.mode — toujours la chaîne subscribe
  • hub.verify_token — la chaîne que vous définissez dans le tableau de bord Meta. Vous choisissez cette valeur — faites-en un secret aléatoire, pas un mot prévisible
  • hub.challenge — un nombre aléatoire que Meta veut recevoir en retour sous forme de texte brut

Votre point de terminaison doit : (1) vérifier que hub.mode === 'subscribe', (2) vérifier que hub.verify_token correspond à votre valeur attendue, (3) renvoyer hub.challenge comme corps de réponse avec Content-Type: text/plain et un statut 200. Renvoyez du JSON, renvoyez la mauvaise valeur, ou renvoyez un statut non-200 — la vérification échoue et Meta marque votre webhook comme non vérifié.

Erreur courante : Utiliser res.json({ challenge }) dans Express au lieu de res.send(challenge). Meta attend du texte brut. Envelopper le défi dans du JSON fait échouer la vérification même si votre serveur renvoie 200.

Étape 3 : Vérification de signature HMAC-SHA256 — l'étape que personne ne saute en production

L'URL de votre webhook est publique. Quiconque la trouve peut envoyer de fausses charges utiles via POST à votre serveur. Sans vérification de signature, votre agent IA pourrait recevoir des conversations fabriquées, votre CRM pourrait être empoisonné avec des contacts inventés, et votre logique pourrait être déclenchée par un attaquant à volonté.

Meta signe chaque requête POST en utilisant votre App Secret (trouvé dans Meta Developer Dashboard → App Settings → Basic). La signature se trouve dans l'en-tête X-Hub-Signature-256, formatée comme sha256=<hex_digest>. Le digest est un HMAC-SHA256 des octets bruts du corps de la requête, en utilisant votre App Secret comme clé.

Node.js
verifySignature.js
function verifySignature(req) { const sigHeader = req.headers['x-hub-signature-256']; if (!sigHeader) return false; // Remove 'sha256=' prefix const receivedSig = sigHeader.slice(7); // everything after 'sha256=' // req.body MUST be the raw buffer — see express.raw() setup above const expectedSig = crypto .createHmac('sha256', APP_SECRET) .update(req.body) // raw Buffer, NOT parsed JSON .digest('hex'); // timingSafeEqual prevents timing attacks try { return crypto.timingSafeEqual( Buffer.from(receivedSig, 'hex'), Buffer.from(expectedSig, 'hex') ); } catch { return false; // mismatched lengths throw — treat as invalid } } module.exports = { verifySignature };
Python
verify.py
import hmac, hashlib def verify_signature(request: Request, raw_body: bytes) -> bool: sig_header = request.headers.get("X-Hub-Signature-256", "") if not sig_header.startswith("sha256="): return False received_sig = sig_header[7:] # strip 'sha256=' prefix expected_sig = hmac.new( APP_SECRET.encode(), raw_body, # raw bytes, NOT decoded string hashlib.sha256 ).hexdigest() # hmac.compare_digest prevents timing attacks return hmac.compare_digest(received_sig, expected_sig)
Critique : utilisez les octets bruts, pas du JSON analysé. Si vous appelez JSON.parse() avant de calculer le HMAC, le digest ne correspondra jamais — la sérialisation JSON peut modifier les espaces et l'ordre des clés. Vous devez calculer le HMAC sur les octets exacts qui sont arrivés dans le corps de la requête. Dans Express, cela signifie configurer express.raw() sur votre route webhook avant tout autre analyseur de corps. Dans FastAPI, appelez await request.body() avant toute désérialisation JSON.

Étape 4 : Développement local — exposer localhost à Meta

Meta ne peut pas atteindre http://localhost:3000. Pendant le développement, vous avez besoin d'une URL HTTPS publique qui tunnelise le trafic vers votre serveur local. Deux options fiables :

Terminal
options de tunnel
# Option A : ngrok (le plus courant, nécessite un compte gratuit) ngrok http 3000 # → https://a1b2c3d4.ngrok-free.app ← collez ceci dans le tableau de bord Meta # Option B : Cloudflare Tunnel (gratuit, sans limite de temps, nécessite cloudflared) cloudflared tunnel --url http://localhost:3000 # → https://random-words.trycloudflare.com ← collez ceci # URL complète du webhook à enregistrer dans le tableau de bord Meta : # https://your-tunnel-url.ngrok-free.app/webhook

Enregistrez l'URL du tunnel dans le tableau de bord des développeurs Meta (WhatsApp → Configuration → Webhooks). Lorsque vous redémarrez ngrok, il génère une nouvelle URL — mettez à jour le tableau de bord à chaque fois. Cloudflare Tunnel persiste entre les redémarrages pour les tunnels nommés. Basculez vers l'URL de votre domaine de production avant la mise en ligne.

Étape 5 : Enregistrer le webhook dans le tableau de bord des développeurs Meta

Avec votre point de terminaison en cours d'exécution et accessible publiquement :

  1. Ouvrez developers.facebook.com → votre application → WhatsApp → Configuration
  2. Sous Webhooks, cliquez sur Edit
  3. Collez l'URL HTTPS de votre webhook (par ex. https://yourdomain.com/webhook)
  4. Entrez votre Verify Token — la chaîne exacte que vous avez définie dans VERIFY_TOKEN
  5. Cliquez sur Verify and Save — Meta lance immédiatement le défi GET. Votre serveur doit répondre en ~5 secondes.
  6. Après l'enregistrement, cliquez sur Manage à côté du webhook et activez l'abonnement au champ messages. Sans cela, Meta ne poussera pas les événements de messages entrants vers votre point de terminaison.

Étape 6 : Analyser la charge utile du webhook Cloud API

Une fois la vérification terminée, chaque message client entrant déclenche un POST vers votre point de terminaison. Voici la structure complète d'un événement de message texte Cloud API — et comment le parcourir :

JSON
meta-cloud-api-webhook-payload.json
{ "object": "whatsapp_business_account", "entry": [{ "id": "WHATSAPP_BUSINESS_ACCOUNT_ID", "changes": [{ "value": { "messaging_product": "whatsapp", "metadata": { "display_phone_number": "+44 7700 900 123", // votre numéro "phone_number_id": "PHONE_NUMBER_ID" }, "contacts": [{ "profile": { "name": "Alice" }, "wa_id": "447700900456" // expéditeur — sans préfixe + }], "messages": [{ "from": "447700900456", // téléphone de l'expéditeur — sans préfixe + "id": "wamid.HBgL...", // ID de message unique "timestamp": "1747231892", // chaîne Unix, pas un entier "type": "text", "text": { "body": "Hello, is this working?" } }] }, "field": "messages" }] }] }

Le message qui vous intéresse est enfoui à body.entry[0].changes[0].value.messages[0]. Cette imbrication prend tout le monde au dépourvu. Un événement de mise à jour de statut (accusé de réception) arrive dans la même enveloppe mais contient un tableau statuses au lieu de messages. Voici le motif d'extraction :

Node.js
parseWebhook.js
function parseWebhookEvent(body) { const value = body?.entry?.[0]?.changes?.[0]?.value; if (!value) return null; const phoneNumberId = value.metadata?.phone_number_id; // Messages entrants if (value.messages?.length) { const msg = value.messages[0]; return { kind: 'message', phoneNumberId, // lequel de vos numéros l'a reçu from: msg.from, // expéditeur — sans préfixe + messageId: msg.id, timestamp: parseInt(msg.timestamp, 10), // convertir chaîne→entier type: msg.type, // 'text'|'image'|'audio'|etc raw: msg, // objet message complet }; } // Mises à jour de statut de livraison/lecture if (value.statuses?.length) { const s = value.statuses[0]; return { kind: 'status', messageId: s.id, status: s.status, // 'sent'|'delivered'|'read'|'failed' recipient: s.recipient_id, }; } return null; }

Étape 7 : Gérer tous les types de messages WhatsApp

Le champ type vous indique quelle propriété lire. Chaque type a une structure différente. Voici la carte complète :

text
Message texte standard du client
Lire : msg.text.body
image
Photo envoyée par le client. Peut inclure une légende optionnelle.
Lire : msg.image.id → télécharger
audio
Note vocale ou fichier audio. voice: true si enregistré dans l'application.
Lire : msg.audio.id → télécharger
document
PDF, Word, Excel ou autre fichier. Inclut le nom de fichier.
Lire : msg.document.id, .filename
video
Fichier vidéo. Peut inclure une légende.
Lire : msg.video.id → télécharger
location
Épingle déposée par le client. Inclut latitude/longitude et nom optionnel.
Lire : msg.location.latitude, .longitude
contacts
Contact(s) vCard partagé(s) par le client.
Lire : msg.contacts[0].name
reaction
Réaction emoji à l'un de vos messages.
Lire : msg.reaction.emoji, .message_id
interactive
Clic sur un bouton ou sélection de liste depuis un message WhatsApp Flow.
Lire : msg.interactive.button_reply.id
Node.js
handleMessage.js
async function handleMessage(msg) { switch (msg.type) { case 'text': await processText(msg.from, msg.text.body); break; case 'image': case 'audio': case 'video': case 'document': { const mediaId = msg[msg.type].id; const filename = msg[msg.type].filename; // uniquement pour les documents const url = await getMediaUrl(mediaId); await downloadAndStore(url, mediaId, filename); break; } case 'location': await processLocation({ lat: msg.location.latitude, lng: msg.location.longitude, name: msg.location.name, address: msg.location.address, }); break; case 'reaction': await processReaction(msg.reaction.emoji, msg.reaction.message_id); break; case 'interactive': { const type = msg.interactive.type; // 'button_reply' | 'list_reply' const reply = msg.interactive[type]; await processInteractive(type, reply.id, reply.title); break; } default: console.warn(`Unhandled message type: ${msg.type}`); } }

Étape 8 : Télécharger les médias entrants — la récupération en deux étapes

Lorsqu'un client envoie une image, une note vocale ou un document, la charge utile du webhook ne contient pas le fichier — elle contient un ID de média. Vous devez effectuer un appel API séparé pour résoudre l'ID en une URL de téléchargement temporaire, puis récupérer le fichier réel. L'URL de téléchargement expire après 5 minutes — téléchargez immédiatement et stockez sur votre propre infrastructure.

Node.js
downloadMedia.js
const GRAPH_URL = 'https://graph.facebook.com/v21.0'; const ACCESS_TOKEN = process.env.WHATSAPP_ACCESS_TOKEN; // Étape 1 : résoudre l'ID de média → URL de téléchargement temporaire async function getMediaUrl(mediaId) { const res = await fetch( `${GRAPH_URL}/${mediaId}`, { headers: { Authorization: `Bearer ${ACCESS_TOKEN}` } } ); const data = await res.json(); return data.url; // expire dans 5 minutes — téléchargez MAINTENANT } // Étape 2 : récupérer les octets réels du fichier async function downloadMedia(mediaUrl) { const res = await fetch(mediaUrl, { headers: { Authorization: `Bearer ${ACCESS_TOKEN}` } }); return Buffer.from(await res.arrayBuffer()); } // Composer : résoudre l'ID → télécharger → stocker sur S3/GCS/votre serveur async function downloadAndStore(mediaId, filename) { const mediaUrl = await getMediaUrl(mediaId); const buffer = await downloadMedia(mediaUrl); await uploadToStorage(buffer, filename ?? mediaId); // stocker le mediaId ou votre URL de stockage dans votre BD pour l'enregistrement de la conversation }

Étape 9 : Architecture de file d'attente — ne jamais traiter dans le gestionnaire de webhook

C'est le modèle de production qui sépare une implémentation jouet d'une implémentation qui survit au trafic réel. La règle est absolue : votre gestionnaire de webhook doit renvoyer 200 en moins de 20 secondes. Si vous appelez un LLM, interrogez un CRM ou téléchargez des médias de manière synchrone dans le gestionnaire, vous finirez par atteindre le délai d'attente sous charge, Meta réessaiera, et vous traiterez des événements en double.

Le modèle correct : acquitter immédiatement, pousser la charge utile brute vers une file d'attente, traiter de façon asynchrone dans un worker. Voici un modèle BullMQ minimal (supporté par Redis) :

Node.js + BullMQ
queue.js
const { Queue, Worker } = require('bullmq'); const redis = { host: '127.0.0.1', port: 6379 }; const whatsappQueue = new Queue('whatsapp', { connection: redis }); // appelé dans votre gestionnaire POST — immédiat, léger async function enqueue(payload) { await whatsappQueue.add('process-event', payload, { attempts: 3, // réessayer jusqu'à 3 fois en cas d'échec du worker backoff: { type: 'exponential', delay: 2000 }, removeOnComplete: 100, // conserver les 100 dernières tâches terminées pour le débogage removeOnFail: 200, }); } // Le worker s'exécute dans un processus séparé const worker = new Worker('whatsapp', async (job) => { const event = parseWebhookEvent(job.data); if (!event) return; if (event.kind === 'message') { await handleMessage(event.raw); // votre logique IA / CRM ici } if (event.kind === 'status') { await updateMessageStatus(event.messageId, event.status); } }, { connection: redis, concurrency: 10 });
Déduplication : Meta peut livrer le même événement plusieurs fois si votre serveur est lent à acquitter. Stockez les valeurs msg.id traitées dans Redis avec un TTL court (par ex. 24h). Avant de traiter une tâche, vérifiez si msg.id est déjà dans votre ensemble de déduplication. Si oui, ignorez. Si non, ajoutez-le et traitez. Cela évite les réponses doubles de votre agent IA et les enregistrements en double dans le CRM.

Alternative : évitez tout ce qui précède avec SocialHook

Tout ce qui précède — gestion du défi de vérification, signature HMAC-SHA256, extraction de la charge utile depuis entry[0].changes[0].value imbriqué, résolution d'ID de média, logique de réessai, déduplication — est un travail d'infrastructure. Ce n'est pas votre produit. C'est l'échafaudage sur lequel votre produit fonctionne.

SocialHook remplace cette couche entière. Vous connectez votre numéro WhatsApp Business à SocialHook, collez l'URL du webhook de votre serveur dans le tableau de bord SocialHook, et SocialHook :

  • Gère le défi de vérification de Meta automatiquement
  • Vérifie la signature HMAC-SHA256 sur chaque événement entrant
  • Extrait le message de l'enveloppe Cloud API imbriquée
  • Normalise la charge utile vers un format cohérent sur WhatsApp, Facebook Messenger et Messages directs Instagram
  • Réessaie la livraison vers votre point de terminaison jusqu'à 3 fois avec backoff exponentiel si votre serveur renvoie un statut non-200
  • Journalise chaque tentative de livraison avec horodatage, code de statut et latence
  • Livre à votre point de terminaison en moins de 50 ms

Votre point de terminaison reçoit ceci au lieu de la charge utile brute imbriquée de Meta :

JSON
socialhook-normalized-payload.json
{ "platform": "whatsapp", "event": "message.received", "timestamp": 1747231892, // entier — déjà analysé "from": "+44 7700 900 456", // E.164 — préfixe + ajouté "conversation_id": "conv_8j3k...", "message": { "type": "text", "body": "Hello, is this working?", "id": "wamid.HBgL..." }, "signature_verified": true, "delivery": { "attempt": 1, "latency_ms": 41 } }

Pas de parcours imbriqué. Pas de parseInt(timestamp). Pas de préfixe + manquant sur le numéro de l'expéditeur. Pas d'instructions switch spécifiques à une plateforme — la même forme de charge utile arrive que le client vous ait envoyé un message sur WhatsApp, Facebook ou Instagram. Un seul gestionnaire de webhook, trois canaux. Le coût total est un forfait de 50 $/mois — moins d'une heure de temps d'ingénierie pour construire et maintenir l'équivalent from scratch.

Questions fréquentes

Comment recevoir des messages WhatsApp sur mon serveur ?
Vous avez besoin d'un numéro WhatsApp Business sur Cloud API, d'un point de terminaison HTTPS accessible publiquement, et que ce point de terminaison soit enregistré comme votre webhook dans le tableau de bord des développeurs Meta. Meta envoie ensuite un HTTP POST à votre point de terminaison pour chaque message entrant. Votre serveur doit répondre avec 200 en moins de 20 secondes. Consultez la configuration complète en 9 étapes ci-dessus.
Pourquoi ma vérification HMAC échoue-t-elle continuellement ?
Presque toujours parce que vous calculez le HMAC sur le JSON analysé au lieu des octets bruts du corps de la requête. Dans Express, vous devez configurer express.raw() sur votre route webhook avant tout autre analyseur de corps. Dans FastAPI, appelez await request.body() avant la désérialisation. Vérifiez également que vous utilisez votre App Secret (depuis App Settings → Basic) et non votre jeton d'accès comme clé HMAC — ce sont des valeurs différentes.
Comment tester mon webhook localement avant le déploiement ?
Utilisez un outil de tunneling. ngrok (ngrok http 3000) est le plus courant — il génère une URL HTTPS publique qui redirige vers votre port local. Cloudflare Tunnel (cloudflared tunnel --url http://localhost:3000) est une alternative gratuite sans limite de temps de session. Collez l'URL générée dans le champ webhook du tableau de bord des développeurs Meta. N'oubliez pas de la mettre à jour lorsque vous redémarrez ngrok car l'URL change.
Meta continue de réessayer mon webhook — pourquoi ?
Trois causes : (1) votre serveur a renvoyé un statut non-200 — les erreurs de vérification de signature renvoient 403, les exceptions de traitement renvoient 500. (2) votre serveur a mis plus de 20 secondes à répondre — déplacez le traitement vers une file d'attente asynchrone et renvoyez 200 immédiatement. (3) votre point de terminaison était hors ligne lorsque Meta a envoyé l'événement — assurez-vous que votre serveur fonctionne toujours ou utilisez la livraison gérée de SocialHook avec réessai automatique.
Quelle est la différence entre la charge utile brute de Meta et la charge utile normalisée de SocialHook ?
Cloud API de Meta enveloppe chaque événement dans une structure imbriquée : body.entry[0].changes[0].value.messages[0]. L'horodatage est une chaîne, l'expéditeur n'a pas de préfixe +, et le format diffère des webhooks Facebook Messenger et Instagram. SocialHook extrait le message, normalise l'expéditeur au format E.164, convertit l'horodatage en entier, et livre la même structure JSON plate sur les trois canaux Meta. Vous écrivez un seul analyseur et cela fonctionne pour WhatsApp, Facebook et Instagram.
Comment gérer les médias entrants (images, notes vocales, documents) ?
La charge utile du webhook contient un ID de média, pas le fichier lui-même. Vous devez : (1) appeler GET https://graph.facebook.com/v21.0/{media_id} avec votre jeton d'accès pour obtenir une URL de téléchargement temporaire, (2) récupérer le fichier depuis cette URL (également avec votre jeton d'accès dans l'en-tête Authorization), (3) stocker le fichier sur votre propre serveur ou stockage cloud. L'URL temporaire expire en environ 5 minutes — téléchargez immédiatement. Consultez le code Node.js complet dans la section Médias ci-dessus.

Connectez votre numéro.
Premier webhook en 5 minutes.

Vous avez lu l'implémentation complète. Si vous préférez ne pas maintenir vous-même la vérification HMAC, la logique de réessai et la normalisation de charge utile — SocialHook gère tout cela. Collez l'URL de votre point de terminaison. Recevez du JSON propre. 50 $/mois forfait.

Aucune carte de crédit requise · 50 $/mois après l'essai · Annuler à tout moment