What's new, 21 October 2026: A customer who sends several messages before the bot's first reply no longer gets the opening sequence of a Chat Flow twice
WASync · Developers

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

PUT /webhook
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"}'
200 response
{
  "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

EventFires whenDirection
message.receivedUn 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.statusL'état de livraison de l'un de vos messages sortants change.Sortant uniquement.
connection.disconnectedUne 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.connectedUne 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

POST your webhook URL
{
  "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 que GET /messages renvoie dans id). Dédoublonnez et faites vos jointures dessus : la livraison est « au moins une fois ».
  • message.wa_id : l'identifiant propre à WhatsApp, null s'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 +.
  • text vaut null pour les messages composés uniquement de médias ; media_url / media_type valent null pour 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 :

POST your webhook URL
{
  "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.

verify.js
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));
}
receiver (express)
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

PropertyValue
Critère de réussiteTout code 2xx. Tout le reste, y compris les redirections, est un échec.
Tentatives3 par événement : immédiate, puis après 500 ms, puis après 1 s.
Délai d'expiration par tentative10 secondes. Accusez réception rapidement et traitez en asynchrone.
Après le dernier échecL'événement est abandonné. Il n'existe aucune file de relivraison longue.
DoublonsPossibles. Dédoublonnez sur message.id.
OrdreNon garanti. Triez par timestamp si cela compte.
Signature lors des nouvelles tentativesLe 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

POST your webhook URL
{
  "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é avec GET /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 2xx en moins de 10 secondes ? Les gestionnaires lents passent pour des échecs et épuisent les trois tentatives.
  • S'agit-il d'une connexion qr ? Les connexions cloud_api n'émettent aucun webhook.
  • Seuls les messages entrants déclenchent message.received : vos propres envois ne le font jamais.
Cette page vous a-t-elle été utile ?

On this page