Webhooks
Un seul endpoint signé par compte. Vérification de la signature, catalogue des événements, politique de nouvelles tentatives et rotation du secret.
Indiquez votre endpoint à WASync et vous recevez en retour un secret de signature. Utilise
whatsapp.events. Aucune inscription d'application et aucune étape de consentement ne sont nécessaires.
Un seul emplacement de webhook par compte
Un compte a exactement une URL de webhook. PUT /webhook la définit : l'appeler
de nouveau avec une autre URL déplace l'endpoint au lieu d'en ajouter un second,
et il n'existe aucune suppression. Cela compte dès que plusieurs outils veulent
recevoir les événements : un déclencheur n8n et votre propre backend ne peuvent pas
posséder tous deux l'emplacement. Faites la répartition de votre côté.
Définir l'endpoint
curl -X PUT https://developers.wasync.app/api/v1/webhook \
-H "Authorization: Bearer $WASYNC_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/wasync/webhook"}'{
"url": "https://example.com/wasync/webhook",
"events": [
"message.received",
"message.status",
"connection.disconnected",
"connection.connected"
],
"secret": "whsec_3f9a…"
}Conservez le secret. Il est délivré ici parce que c'est le moment où vous en avez besoin
pour écrire votre code de vérification, et GET /webhook ne le renverra plus :
cet endpoint renvoie { url, events, secretSet }, volontairement sans le
secret, car une route de lecture finit dans les journaux et les tickets. Vous l'avez perdu ? Appelez
POST /webhook/rotate pour en obtenir un nouveau. L'URL doit être en HTTPS sur un hôte accessible
publiquement ; http, localhost et les plages privées sont rejetés avec
400 invalid_webhook.
La rotation n'a aucune période de chevauchement : l'ancien secret meurt à l'instant où le nouveau est émis. Déployez rapidement le nouveau secret : la politique de nouvelles tentatives ci-dessous couvre un déploiement qui se mesure en secondes, pas en heures.
Catalogue des événements
| Event | Fires when | Direction |
|---|---|---|
message.received | Un message WhatsApp arrive sur une connexion autorisée (texte ou média). | Entrant uniquement : les messages que vous envoyez ne déclenchent pas cet événement. |
message.status | L'état de livraison de l'un de vos messages sortants change. | Sortant uniquement. |
connection.disconnected | Une connexion a cessé de fonctionner et son propriétaire doit la rappairer. | Une fois par panne, pas de façon répétée tant qu'elle reste en panne. |
connection.connected | Une connexion qui était coupée fonctionne de nouveau. | Une seule fois, et uniquement pour une connexion dont on vous a signalé la coupure. |
Ignorez les valeurs d'event inconnues au lieu de les traiter comme des erreurs : les nouveaux types
sont annoncés dans le changelog avant leur mise en ligne.
Les événements ne sont émis que pour les connexions qr. Les connexions cloud_api n'émettent aucun
webhook pour le moment ; interrogez GET /messages et GET /connections/{id} pour celles-ci.
Payload
{
"event": "message.received",
"connection_id": "conn_8f3a21",
"message": {
"id": "cmqj3k2ab0001xyz",
"wa_id": "[email protected]_3EB0A1B2C3",
"from": "40700000000",
"text": "Hello!",
"media_url": null,
"media_type": null,
"timestamp": 1766138640000
}
}message.id: l'identifiant stable de WASync (la même valeur queGET /messagesrenvoie dansid). Dédoublonnez et faites vos jointures dessus : la livraison est « au moins une fois ».message.wa_id: l'identifiant propre à WhatsApp,nulls'il est inconnu. Pour le support et le débogage uniquement, jamais comme clé de dédoublonnage ou de jointure.from: chiffres au format international uniquement, sans+.textvautnullpour les messages composés uniquement de médias ;media_url/media_typevalentnullpour les messages texte ou lorsque le fichier n'est pas récupérable publiquement.timestamp: epoch en millisecondes, sous forme de nombre et non de chaîne ISO.
Un événement message.status met à jour l'un de vos messages sortants :
{
"event": "message.status",
"connection_id": "conn_8f3a21",
"message": {
"id": "cmqj3k2ab0001xyz",
"wa_id": "[email protected]_3EB0A1B2C3",
"status": "read",
"timestamp": 1766138641000
}
}Vérifier la signature
Chaque livraison comporte X-WASync-Timestamp (epoch en ms) et
X-WASync-Signature: sha256=<hex>, qui vaut
HMAC-SHA256(timestamp + "." + rawBody, secret). Calculez-la sur le corps brut
(avant tout parsing JSON) et comparez en temps constant. Rejetez les livraisons
dont l'horodatage date de plus de ~5 minutes, ce qui bloque les rejeux.
import crypto from "node:crypto";
// rawBody MUST be the exact bytes you received (verify BEFORE JSON.parse).
export function verifyWASync(headers, rawBody, secret) {
const ts = headers["x-wasync-timestamp"];
const sig = headers["x-wasync-signature"]; // "sha256=<hex>"
const expected =
"sha256=" +
crypto.createHmac("sha256", secret)
.update(`${ts}.${rawBody}`)
.digest("hex");
return Boolean(sig) &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}import express from "express";
import { verifyWASync } from "./verify.js";
const app = express();
// Capture the RAW body — signature verification needs the exact bytes.
app.post("/webhooks/wasync", express.raw({ type: "application/json" }), (req, res) => {
if (!verifyWASync(req.headers, req.body.toString("utf8"), process.env.WASYNC_WEBHOOK_SECRET)) {
return res.status(401).end();
}
// Reject stale deliveries (replay window) — 5 minutes is a sane tolerance.
if (Math.abs(Date.now() - Number(req.headers["x-wasync-timestamp"])) > 5 * 60_000) {
return res.status(401).end();
}
res.status(200).end(); // ACK fast — under 10s — then process async
const { message } = JSON.parse(req.body.toString("utf8"));
// ... your logic (dedupe on message.id — retries can deliver duplicates)
});Politique de livraison et de nouvelles tentatives
| Property | Value |
|---|---|
| Critère de réussite | Tout code 2xx. Tout le reste, y compris les redirections, est un échec. |
| Tentatives | 3 par événement : immédiate, puis après 500 ms, puis après 1 s. |
| Délai d'expiration par tentative | 10 secondes. Accusez réception rapidement et traitez en asynchrone. |
| Après le dernier échec | L'événement est abandonné. Il n'existe aucune file de relivraison longue. |
| Doublons | Possibles. Dédoublonnez sur message.id. |
| Ordre | Non garanti. Triez par timestamp si cela compte. |
| Signature lors des nouvelles tentatives | Le même corps et la même signature sont renvoyés : vérifiez chaque tentative de la même manière. |
Échelle des statuts
Les valeurs de message.status forment une échelle à sens unique : sent (1) → delivered (2) →
read (3), failed étant une issue terminale qui ne suit pas read.
Appliquez les mises à jour de façon monotone. N'appliquez un nouveau statut que si son rang est supérieur
à celui que vous avez enregistré. Un message peut arriver avec read avant
delivered (une livraison tardive), et les événements en double sont possibles. Ne faites jamais reculer
un message dans votre interface.
Réconciliation : l'interrogation comme filet de sécurité
Les livraisons sont tentées trois fois puis abandonnées, ce qui fait de votre gestionnaire de webhook
la voie rapide, pas la garantie. Exécutez une boucle en arrière-plan qui interroge
GET /messages?connectionId=… toutes les 30 à 60 secondes et insère ou met à jour tout ce que le
gestionnaire n'a pas vu, en utilisant le curseur pour des récupérations incrémentales. Cette même boucle
rattrape les mises à jour de statut arrivées pendant que votre endpoint était hors service.
Webhooks = mises à jour de l'interface en temps réel. Interrogation = exactitude. Utilisez les deux.
Cycle de vie de la connexion
{
"event": "connection.disconnected",
"connection_id": "conn_8f2a…",
"connection": {
"id": "conn_8f2a…",
"phone_number": "393331234567",
"label": "Studio Rossi",
"status": "disconnected",
"needs_reconnect": true,
"license_status": "active",
"license_expires": "2027-08-08T09:00:00.000Z",
"reason": "AUTH_LOST"
},
"timestamp": 1786000000000
}Une connexion qr peut cesser de fonctionner pour des raisons indépendantes de la volonté de chacun : le
téléphone est éteint pendant une journée, WhatsApp est ouvert sur un autre appareil, la
session expire. Le numéro devient alors silencieux dans les deux sens, et sans
événement, la première personne à s'en apercevoir est votre client.
Le code QR ne figure volontairement pas dans le payload. Un QR WhatsApp est un identifiant de
connexion par scan : toute personne qui le lit peut prendre le contrôle de la session. L'événement
vous indique qu'un nouvel appairage est nécessaire : récupérez le QR lui-même via votre
GET /connections/{id} authentifié et affichez-le au propriétaire du numéro, exactement
comme lors de la première configuration. Pilotez votre interface avec needs_reconnect ; considérez reason comme un
indice de journalisation à titre indicatif, car de nouvelles valeurs peuvent apparaître.
Délai de détection : une déconnexion est repérée par une sonde de statut toutes les 5 minutes (~5 min). Lorsque la session semble récupérable, une réparation automatique est tentée d'abord, de sorte que ce cas prend ~15 à 20 minutes.
Vous ne recevez pas d'événements ?
- L'URL de livraison est-elle en HTTPS sur un hôte public ? Localhost, les IP privées et
http://sont rejetés. Utilisez un tunnel pendant le développement et vérifiez ce qui est réellement enregistré avecGET /webhook. - L'identifiant porte-t-il
whatsapp.events, et couvre-t-il la connexion sur laquelle le message est arrivé ? - La licence de la connexion est-elle toujours active ? Une licence expirée signifie aucun événement
et
402à l'envoi. - Votre endpoint répond-il
2xxen moins de 10 secondes ? Les gestionnaires lents passent pour des échecs et épuisent les trois tentatives. - S'agit-il d'une connexion
qr? Les connexionscloud_apin'émettent aucun webhook. - Seuls les messages entrants déclenchent
message.received: vos propres envois ne le font jamais.