Canaux web et WhatsApp
Un client WhatsApp écrit « où est mon colis ». Meta envoie le webhook, l'agent met 2,5 secondes à répondre, le serveur rend son HTTP 200 après la réponse. Pendant un pic, un tour dépasse le délai de Meta : la plateforme considère la livraison comme échouée et renvoie le même message. Le client reçoit deux réponses, et si son message demandait un retour, deux retours sont créés. Le canal n'est pas un détail d'intégration : c'est lui qui fixe l'identité du client, l'ordre des messages et le nombre de fois où l'agent travaille.
Le mécanisme
Les deux canaux aboutissent au même appel, SupportService.message_client(canal, client_ref, texte, canal_msg_id). Le service ne sait rien de HTTP ni de Meta ; les canaux ne savent rien de l'agent.
sequenceDiagram
participant Meta as Meta (ou simulateur)
participant W as POST /webhook/whatsapp
participant T as Tâche de fond
participant S as SupportService
participant G as API Graph /messages
Meta->>W: corps JSON + X-Hub-Signature-256
W->>W: HMAC-SHA256(corps brut, secret de l'application)
W-->>Meta: 200 {"recu": true} immédiatement
W->>T: un message texte = une tâche
T->>S: message_client("whatsapp", wa_id, texte, wamid)
S->>S: wamid déjà vu ? alors on s'arrête
S-->>T: réponse de l'agent
T->>G: POST /v25.0/{phone_number_id}/messages (Bearer)
| Widget web | ||
|---|---|---|
| Entrée | POST /api/chat (JSON validé par Pydantic) | webhook signé au format Meta |
client_ref | identifiant de visiteur aléatoire, gardé dans localStorage | numéro wa_id, authentifié par Meta |
| Preuve d'identité pour une commande | e-mail de la commande | le numéro suffit |
| Réponse | dans la réponse HTTP | envoi séparé vers l'API Graph |
| Messages d'un conseiller | le widget interroge /api/chat/{visiteur}/messages toutes les 2 s | poussés par le même client d'envoi |
L'identifiant de visiteur web n'est pas une identité : n'importe qui peut en fabriquer un. Il sert seulement à retrouver la conversation. C'est pourquoi l'e-mail reste exigé par les outils sur ce canal (leçon 3).
Mise en œuvre
1. Vérification et signature
Meta vérifie le point de terminaison par un GET avec hub.mode=subscribe, hub.verify_token et hub.challenge ; il faut renvoyer le challenge tel quel. Chaque POST porte ensuite l'en-tête X-Hub-Signature-256, un HMAC-SHA256 du corps calculé avec le secret de l'application.
def signature_valide(corps: bytes, entete: str | None, secret: str) -> bool:
"""X-Hub-Signature-256 = HMAC-SHA256(corps brut, secret de l'application). Comparaison à temps constant."""
return bool(entete) and hmac.compare_digest(signature(corps, secret), entete)La signature porte sur les octets reçus. D'où corps = await request.body() avant tout décodage : un json.dumps(payload) recalculé n'a pas les mêmes espaces ni le même échappement des accents, et la comparaison échoue une fois sur deux selon le contenu.
2. Acquitter d'abord, traiter ensuite
@app.post("/webhook/whatsapp")
async def webhook(request: Request, taches: BackgroundTasks) -> dict:
corps = await request.body()
if not wa.signature_valide(corps, request.headers.get("x-hub-signature-256"), cfg.wa_app_secret):
raise HTTPException(401, "signature invalide")
# Répondre 200 tout de suite : le modèle prend plusieurs secondes et Meta relance les webhooks non acquittés.
for m in wa.extraire_messages(json.loads(corps)):
taches.add_task(traiter_whatsapp, m)
return {"recu": True}extraire_messages ne garde que les messages : les notifications de statut (sent, delivered, read) arrivent sur le même webhook et sont ignorées. Une image ou un vocal devient un texte « [message de type image non pris en charge] », que l'agent traite comme une question hors périmètre.
3. L'idempotence par le wamid
Chaque message WhatsApp a un identifiant unique (wamid.…). La colonne messages.canal_msg_id est UNIQUE : la seconde insertion échoue et le service s'arrête avant l'agent.
async with self._verrous.setdefault(cid, asyncio.Lock()):
if self.store.ajouter_message(cid, "client", texte, canal_msg_id=canal_msg_id) is None:
log.info("doublon ignoré %s", canal_msg_id)
return Reponse(cid, conv["statut"], doublon=True)Le verrou par conversation règle un second problème : un client qui envoie « bonjour » puis « LL-10587 » à une seconde d'écart. Sans verrou, deux tours d'agent tournent en parallèle sur la même session et le second ne voit pas le premier.
4. Le simulateur
Sans compte Meta, l'application joue les deux rôles. La page /whatsapp construit un webhook au format exact (payload_entrant), le signe avec le même secret et le poste sur /webhook/whatsapp. Les envois sortants partent vers /simulateur/{version}/{phone_number_id}/messages, qui imite l'API Graph (jeton Bearer, réponse {"messages": [{"id": "wamid…"}]}). Le transport est un httpx.ASGITransport : l'application s'appelle elle-même sans réseau.
Passer en production revient à changer trois variables de .env : WHATSAPP_API_BASE=https://graph.facebook.com, WHATSAPP_ACCESS_TOKEN, WHATSAPP_PHONE_NUMBER_ID, plus WHATSAPP_APP_SECRET et WHATSAPP_VERIFY_TOKEN de votre application Meta.
make test # test_webhook_whatsapp_bout_en_bout : vérification, signature, doublon
make run # puis http://localhost:8080/whatsappRésultat obtenu
Le test sans clé couvre les quatre comportements du webhook :
assert client.get("/webhook/whatsapp", params={"hub.mode": "subscribe", "hub.verify_token": "faux"}).status_code == 403
assert client.post("/webhook/whatsapp", content=corps, headers={"x-hub-signature-256": "sha256=00"}).status_code == 401
assert client.post("/webhook/whatsapp", content=corps, headers=h).status_code == 200
assert client.post("/webhook/whatsapp", content=corps, headers=h).status_code == 200 # relance Meta
assert len(sortants) == 1, "le doublon (même wamid) ne produit pas de seconde réponse"Puis le canal réel, avec le modèle (make e2e, Vertex AI, gemini-3.5-flash-lite, 2026-10-16) :
[whatsapp 33600000101] Bonjour, je souhaite retourner la housse de couette de ma commande LL-10421, la couleur ne me plaît pas.
-> webhook HTTP 200, 2.9 s
-> Votre demande de retour pour la housse de couette a bien été enregistrée. Vous pouvez télécharger et imprimer
votre étiquette prépayée à l'adresse suivante : https://exemple.fr/etiquettes/RET-CE81C3.pdf. [...]
OK retour créé avec étiquetteAucun e-mail n'a été demandé : le numéro WhatsApp a suffi, parce que le service l'a écrit dans l'état de session.
Ce qui casse en production
| Symptôme | Cause | Parade |
|---|---|---|
| Toutes les signatures sont refusées, ou certaines seulement | HMAC calculé sur un JSON re-sérialisé, ou mauvais secret (jeton d'accès au lieu du secret de l'application) | signer request.body() ; le secret se trouve dans les paramètres de base de l'application Meta |
| Le client reçoit deux ou trois réponses identiques | webhook acquitté après le tour d'agent, Meta relance | 200 immédiat, traitement en tâche de fond, wamid unique en base |
| Les réponses partent dans le désordre | deux tours parallèles sur la même conversation | verrou par conversation |
| Une conseillère répond le lundi à un message du samedi, l'envoi est refusé | fenêtre de service de 24 h dépassée : hors fenêtre, Meta n'accepte qu'un modèle de message approuvé | prévoir un modèle « nous avons traité votre demande », journaliser l'erreur de l'API Graph |
| Un envoi échoue et rien ne le signale | livrer_whatsapp journalise et abandonne | marqué ponytail: dans le code ; en production, une file de réémission (Cloud Tasks) |
La règle de décision
Sur un canal à webhook (WhatsApp, Messenger, Slack), acquittez avant de réfléchir et dédoublonnez sur l'identifiant fourni par la plateforme, jamais sur le texte : deux « oui » successifs sont deux messages. Sur le web, gardez le HTTP synchrone tant qu'un tour reste sous 10 secondes ; au-delà, passez au même modèle asynchrone que WhatsApp.
Pratique
Vos journaux montrent le même wamid reçu trois fois en 40 secondes, et trois réponses envoyées au client. Le code insère bien canal_msg_id. Que vérifiez-vous en premier ?
Enregistrer l'identifiant ne suffit pas : il faut que la base refuse le doublon (UNIQUE) et que ce refus arrête le traitement avant l'agent. Une signature invalide renverrait 401 et aucune réponse ne partirait. La température n'a aucun rapport avec le nombre de tours exécutés.
Un développeur propose de reconnaître un client web à son adresse IP pour lui épargner l'e-mail. Que répondez-vous ?
Une IP regroupe souvent des dizaines de personnes, et l'identifiant de visiteur est fabriqué par le navigateur, donc par n'importe qui. Combiner deux éléments non authentifiés ne donne pas une authentification. La vraie amélioration est d'intégrer le widget à l'espace client connecté de la boutique et de transmettre une identité signée par le serveur.
Exercice. Un client envoie une photo du colis abîmé. Aujourd'hui, l'agent reçoit « [message de type image non pris en charge] ». Modifiez extraire_messages pour que toute image déclenche directement une escalade de priorité haute, sans passer par le modèle.
Voir la solution
Le webhook ne doit pas appeler l'agent pour une image : le modèle n'a pas l'image, il ne peut que deviner. Ajoutez un champ type à MessageEntrant, puis dans traiter_whatsapp :
if m.type == "image":
conv = svc.store.conversation_ouverte("whatsapp", m.telephone)
if svc.store.ajouter_message(conv["id"], "client", "[photo envoyée]", canal_msg_id=m.wamid) is None:
return # doublon
pos = svc.store.escalader(conv["id"], "Photo envoyée (colis abîmé ?)", "haute")
await livrer_whatsapp(conv, f"Photo reçue. Un conseiller la regarde (position {pos}).")
returnLe test : un payload_entrant modifié avec "type": "image" doit placer la conversation en tête de store.file_attente() sans qu'aucune requête n'atteigne le faux modèle (len(faux_modele.requetes) inchangé). Pour aller plus loin, le média se récupère par son identifiant via l'API Graph et peut être passé à Gemini, qui lit les images.
- Je sais vérifier une signature
X-Hub-Signature-256sur le corps brut - Je sais pourquoi on acquitte un webhook avant le tour d'agent, et ce que cela impose sur Cloud Run
- Je sais rendre un webhook idempotent avec l'identifiant de la plateforme
- J'ai envoyé un message depuis le simulateur et vu la réponse arriver par l'API Graph simulée