Webhooks signés
Plutôt que d’interroger l’API en boucle, laisse WhatSetter pousser les événements vers ton serveur dès qu’ils se produisent. Chaque livraison est signée, tu es donc sûr qu’elle vient de nous.
Les événements
Section intitulée « Les événements »| Événement | Déclenché quand | État |
|---|---|---|
contact.qualified |
L’agent qualifie un lead (selon les critères de ton prompt) | En service |
contact.not_qualified |
L’agent écarte un lead | En service |
agent.disconnected |
Un numéro WhatsApp se déconnecte | En service |
booking.created |
Un rendez-vous est pris dans la conversation | Pas encore émis |
message.received |
Un lead répond | Pas encore émis |
Créer une souscription
Section intitulée « Créer une souscription »POST /webhooks demande le scope webhooks:manage. L’URL doit être en HTTPS (le HTTP simple n’est accepté que pour localhost).
curl -X POST https://app.whatsetter.com/api/v1/webhooks \ -H "Authorization: Bearer $WHATSETTER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"url":"https://ton-serveur.com/webhooks/whatsetter","events":["contact.qualified","contact.not_qualified"],"description":"Sync CRM"}'const res = await fetch('https://app.whatsetter.com/api/v1/webhooks', { method: 'POST', headers: { Authorization: `Bearer ${process.env.WHATSETTER_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ url: 'https://ton-serveur.com/webhooks/whatsetter', events: ['contact.qualified', 'contact.not_qualified'], description: 'Sync CRM', }),});const { data } = await res.json();console.log(data.secret); // whsec_… affiché une seule fois, stocke-le maintenantimport os, requests
r = requests.post( "https://app.whatsetter.com/api/v1/webhooks", headers={"Authorization": f"Bearer {os.environ['WHATSETTER_API_KEY']}"}, json={ "url": "https://ton-serveur.com/webhooks/whatsetter", "events": ["contact.qualified", "contact.not_qualified"], "description": "Sync CRM", },)print(r.json()["data"]["secret"]) # whsec_… affiché une seule fois, stocke-le maintenant{ "data": { "id": "66f1c0ffee00000000000abc", "url": "https://ton-serveur.com/webhooks/whatsetter", "events": ["contact.qualified", "contact.not_qualified"], "active": true, "description": "Sync CRM", "secret_prefix": "whsec_3f9a", "secret": "whsec_3f9a…", "last_delivery_at": null, "last_delivery_status": null, "created_at": "2026-09-19T10:00:00.000Z" }}La réponse contient le secret de signature (whsec_…). Il n’est affiché qu’ici : stocke-le immédiatement. Les appels suivants à GET /webhooks ne renvoient que secret_prefix.
Les erreurs qui comptent :
| HTTP | code |
Pourquoi |
|---|---|---|
| 422 | validation_error |
url manquante ou pas en HTTPS, events vide ou inconnu, description de plus de 255 caractères. |
| 429 | quota_exceeded |
L’espace a déjà 10 souscriptions. Supprimes-en une d’abord. |
Tester la livraison
Section intitulée « Tester la livraison »POST /webhooks/{id}/test envoie immédiatement un événement d’exemple signé vers l’URL de la souscription et te dit comment ton endpoint a répondu. L’exemple utilise le premier événement de la souscription et porte "is_test": true.
curl -X POST https://app.whatsetter.com/api/v1/webhooks/66f1c0ffee00000000000abc/test \ -H "Authorization: Bearer $WHATSETTER_API_KEY"const res = await fetch('https://app.whatsetter.com/api/v1/webhooks/66f1c0ffee00000000000abc/test', { method: 'POST', headers: { Authorization: `Bearer ${process.env.WHATSETTER_API_KEY}` },});console.log(await res.json());import os, requests
r = requests.post( "https://app.whatsetter.com/api/v1/webhooks/66f1c0ffee00000000000abc/test", headers={"Authorization": f"Bearer {os.environ['WHATSETTER_API_KEY']}"},)print(r.json()){ "data": { "delivered": true, "endpoint_status": 200, "error": null, "event_id": "evt_9f2c1a7e4b8d3056a1cf7e2b9d4a6013" }}delivered vaut false quand ton endpoint a répondu autre chose qu’un statut 2xx (voir endpoint_status) ou n’a pas pu être joint en 10 secondes (voir error). Un 404 not_found signifie que l’identifiant de souscription est inconnu ou appartient à un autre espace.
Ce que tu reçois
Section intitulée « Ce que tu reçois »Chaque livraison partage la même enveloppe. Les champs propres à l’événement se trouvent à côté de ceux de l’enveloppe, et non imbriqués sous une clé payload.
{ "event_type": "contact.qualified", "event_id": "evt_9f2c1a7e4b8d3056a1cf7e2b9d4a6013", "api_version": "2026-06-01", "occurred_at": "2026-09-15T09:10:12.517Z", "team_id": "6b0c1d2e3f405162738a", "is_test": false,
"campaign": { "id": "6b0c1d2e3f4051627abc", "slug": "acme-morocco-1-ab12", "name": "Alex" }, "contact": { "id": "6b0c1d2e3f4051627def", "lead_id": "212600000000@c.us", "name": "Sam", "phone": "212600000000", "classification": "qualified", "ai_reasoning": "Cherche un SUV, budget confirmé, veut être rappelé.", "conversation_url": "https://app.whatsetter.com/dashboard/conversations?leadId=212600000000%40c.us", "external_reference": "your-crm-id-12345" }}Champs de l’enveloppe
Section intitulée « Champs de l’enveloppe »| Champ | Détail |
|---|---|
event_type |
L’un des événements ci-dessus. |
event_id |
Unique par livraison. Sert à dédupliquer. |
api_version |
Actuellement 2026-06-01. |
occurred_at |
ISO 8601, en UTC. |
team_id |
L’espace WhatSetter concerné. |
is_test |
true si envoyé depuis POST /webhooks/{id}/test. |
contact.qualified et contact.not_qualified
Section intitulée « contact.qualified et contact.not_qualified »| Champ | Détail |
|---|---|
campaign.id |
Identifiant interne de la campagne. |
campaign.slug |
Identifiant stable et lisible de la campagne. |
campaign.name |
Le nom affiché de l’agent. |
contact.id |
Identifiant interne du lead, le même id que dans GET /leads. |
contact.lead_id |
Identifiant WhatsApp brut, par exemple 212600000000@c.us. |
contact.name |
Prénom détecté par l’agent, parfois null. |
contact.phone |
Chiffres seuls, voir l’avertissement ci-dessous. |
contact.classification |
Le verdict de l’agent. |
contact.ai_reasoning |
Pourquoi l’agent a tranché ainsi, en clair. |
contact.conversation_url |
Lien direct vers la conversation dans WhatSetter. |
contact.external_reference |
Ton propre identifiant, voir l’astuce ci-dessous. |
agent.disconnected
Section intitulée « agent.disconnected »{ "event_type": "agent.disconnected", "event_id": "evt_…", "api_version": "2026-06-01", "occurred_at": "2026-09-14T13:00:09.250Z", "team_id": "6b0c1d2e3f405162738a", "is_test": false,
"agent": { "name": "Alex", "phone": "212611111111@c.us", "campaign_id": "acme-morocco-1-ab12", "campaign_name": "Acme Morocco 1", "session_status": "disconnected", "disconnected_at": "2026-09-14T12:54:00.000Z" }}Envoyé une seule fois, à la détection de l’incident. Les rappels de reconnexion ne le renvoient pas.
Vérifier la signature
Section intitulée « Vérifier la signature »Chaque livraison est signée en HMAC-SHA256 du corps brut de la requête, avec le secret de ta souscription. Les en-têtes que tu reçois :
Content-Type: application/jsonUser-Agent: Whatsetter-Webhooks/1.0X-Whatsetter-Event: contact.qualifiedX-Whatsetter-Delivery: evt_9f2c1a7e4b8d3056a1cf7e2b9d4a6013X-Whatsetter-Timestamp: 2026-09-15T09:10:12.517ZX-Whatsetter-Api-Version: 2026-06-01X-Whatsetter-Signature: sha256=<hex>X-Whatsetter-Delivery reprend l’event_id de l’enveloppe, et X-Whatsetter-Timestamp son occurred_at. Calcule sha256= + le HMAC hexadécimal du corps brut, puis compare-le à X-Whatsetter-Signature en temps constant.
import crypto from 'node:crypto';
function verify(rawBody, header, secret) { const expected = 'sha256=' + crypto .createHmac('sha256', secret) .update(rawBody) // le corps BRUT, avant tout JSON.parse .digest('hex'); const a = Buffer.from(expected); const b = Buffer.from(header || ''); return a.length === b.length && crypto.timingSafeEqual(a, b);}
// Express : garde le corps brut pour cette route// app.post('/webhooks/whatsetter', express.raw({ type: 'application/json' }), (req, res) => {// if (!verify(req.body, req.get('X-Whatsetter-Signature'), process.env.WHATSETTER_WEBHOOK_SECRET)) {// return res.status(401).end();// }// const event = JSON.parse(req.body);// res.status(200).end(); // réponds vite, traite en asynchrone// });import hmac, hashlib
def verify(raw_body: bytes, header: str, secret: str) -> bool: expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, header or "")
# Flask : utilise request.get_data(), pas request.json, pour garder le corps brut# @app.post("/webhooks/whatsetter")# def whatsetter_webhook():# if not verify(request.get_data(), request.headers.get("X-Whatsetter-Signature", ""), os.environ["WHATSETTER_WEBHOOK_SECRET"]):# return "", 401# event = request.get_json()# return "", 200<?phpfunction verify(string $rawBody, string $header, string $secret): bool{ $expected = 'sha256=' . hash_hmac('sha256', $rawBody, $secret); return hash_equals($expected, $header);}
$rawBody = file_get_contents('php://input');$header = $_SERVER['HTTP_X_WHATSETTER_SIGNATURE'] ?? '';
if (!verify($rawBody, $header, getenv('WHATSETTER_WEBHOOK_SECRET'))) { http_response_code(401); exit;}$event = json_decode($rawBody, true);http_response_code(200);Les règles de livraison
Section intitulée « Les règles de livraison »- Un seul essai par événement. Si ton endpoint est indisponible ou répond une erreur, l’événement n’est pas réessayé et il est perdu. Garde ton endpoint disponible, et réconcilie avec
GET /leadssi tu soupçonnes un trou. - La requête expire après 10 secondes. Réponds
2xxtout de suite et fais ton traitement en asynchrone ; un timeout compte comme un échec. - Déduplique quand même sur l’
event_id. Ça ne coûte rien et ça te protège si des nouvelles tentatives arrivent plus tard. - Un événement est diffusé à chaque souscription active qui le contient, jusqu’à 10 souscriptions par espace. Deux souscriptions vers la même URL reçoivent chacune leur copie.
last_delivery_atetlast_delivery_statussur la souscription te disent comment s’est passée la dernière livraison.
Gérer les souscriptions
Section intitulée « Gérer les souscriptions »- GET /webhooks liste tes souscriptions, sans le secret.
DELETE /webhooks/{id}arrête les livraisons immédiatement. Pour changer l’URL ou les événements, supprime et recrée : tu obtiens un nouveau secret.

