Aller au contenu

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.

É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

POST /webhooks demande le scope webhooks:manage. L’URL doit être en HTTPS (le HTTP simple n’est accepté que pour localhost).

Terminal window
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"}'
{
"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.

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.

Terminal window
curl -X POST https://app.whatsetter.com/api/v1/webhooks/66f1c0ffee00000000000abc/test \
-H "Authorization: Bearer $WHATSETTER_API_KEY"
{
"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.

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"
}
}
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.
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.
{
"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.

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/json
User-Agent: Whatsetter-Webhooks/1.0
X-Whatsetter-Event: contact.qualified
X-Whatsetter-Delivery: evt_9f2c1a7e4b8d3056a1cf7e2b9d4a6013
X-Whatsetter-Timestamp: 2026-09-15T09:10:12.517Z
X-Whatsetter-Api-Version: 2026-06-01
X-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
// });
  • 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 /leads si tu soupçonnes un trou.
  • La requête expire après 10 secondes. Réponds 2xx tout 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_at et last_delivery_status sur la souscription te disent comment s’est passée la dernière livraison.
  • 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.