Webhook WhatsApp : signature, nouvelles tentatives, doublons

Gérer un webhook WhatsApp pour un agent IA : vérifier la signature Standard Webhooks, répondre en moins de 10 secondes, survivre aux retries et dédoublonner. TypeScript.

Un tuyau livre des enveloppes scellées de cire verte dans un bloc serveur sombre, l'une d'elles repartant en boucle.

L’essentiel

  • Un webhook whasp est signé avec Standard Webhooks, livré au moins une fois et sans garantie d'ordre : un récepteur doit donc vérifier, dédoublonner et tolérer le désordre.
  • Vérifiez la signature sur le corps brut de la requête, jamais sur du JSON resérialisé, et rejetez les horodatages décalés de plus de cinq minutes.
  • Écrivez l'événement dans un stockage durable, puis répondez 2xx en moins de 10 secondes et faites le travail lent ensuite : whasp ne garde le corps d'un événement que jusqu'à sa livraison ou son abandon.
  • Si votre endpoint échoue en continu, whasp retente 12 fois sur environ huit heures puis abandonne, et le contenu d'un message.received en échec ne peut pas être renvoyé.
  • Dédoublonnez sur l'identifiant d'événement et envoyez chaque réponse avec une Idempotency-Key construite à partir de l'identifiant du message entrant.

Un webhook whasp est un POST HTTPS par événement, signé avec Standard Webhooks, livré au moins une fois et sans ordre garanti. Un récepteur fiable fait quatre choses : il vérifie la signature sur le corps brut, écrit l’événement dans un stockage durable, répond 2xx en moins de 10 secondes, puis fait le travail lent ensuite en dédoublonnant sur l’identifiant d’événement. Ce guide construit ce récepteur en TypeScript.

Que contient une livraison de webhook ?

Une livraison whasp est un POST avec une enveloppe JSON et trois en-têtes de signature. L’enveloppe contient l’id de l’événement, son type, une heure created_at et les data de l’événement. Les en-têtes sont webhook-id (l’identifiant de l’événement, identique à chaque nouvelle tentative), webhook-timestamp (en secondes unix) et webhook-signature (v1, suivi d’un HMAC-SHA256 en base64).

Il existe huit types d’événements :

Type Quand il se déclenche Ce que fait un récepteur
message.received Une personne a écrit à un numéro. Contient text, media et plus. Stockez-le et lancez l’agent. C’est la seule copie du contenu.
message.sent Un message sortant a quitté le téléphone. Mettez à jour votre propre enregistrement.
message.failed Un message sortant a échoué ; la raison est dans message.error. Consignez-le. Ne retentez que si la raison est temporaire.
reaction.received Quelqu’un a réagi à un message ; un emoji vide signifie que la réaction a été retirée. En général, ignorez-le.
number.ready Un numéro en provisioning est maintenant actif. Commencez à y diriger les utilisateurs.
number.status_changed health ou online a changé. Alertez sur critical.
number.replaced Un numéro banni a été remplacé. Les deux numéros figurent dans l’événement. Migrez vos utilisateurs vers un nouveau numéro.
webhook.test Vous avez appelé POST /v1/webhook/test. Répondez 2xx.

Comment vérifier la signature ?

La vérification de la signature prouve qu’une requête vient de whasp et que personne n’a modifié son corps. La bibliothèque standardwebhooks le fait en un appel, et le projet publie des bibliothèques pour JavaScript, Python, Go, Java, Ruby, Rust et .NET. Voici l’algorithme de la spécification Standard Webhooks écrit avec node:crypto, pour les environnements sans bibliothèque (dans Hono, passez c.req.raw.headers et le corps en texte) :

import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(secret: string, headers: Headers, rawBody: string): boolean {
  const id = headers.get("webhook-id") ?? "";
  const timestamp = headers.get("webhook-timestamp") ?? "";
  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!(age <= 300)) return false; // plus ancien ou plus récent que 5 minutes ; attrape aussi NaN

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest();

  // L'en-tête peut contenir plusieurs signatures séparées par des espaces, par ex. "v1,abc= v1,def=".
  return (headers.get("webhook-signature") ?? "").split(" ").some((part) => {
    const [version, signature] = part.split(",");
    if (version !== "v1" || !signature) return false;
    const got = Buffer.from(signature, "base64");
    return got.length === expected.length && timingSafeEqual(got, expected);
  });
}

Cinq erreurs courantes cassent la vérification :

  • Du JSON analysé. La signature couvre les octets exacts que whasp a envoyés. Lisez le corps en texte d’abord, vérifiez, et seulement ensuite analysez-le. Un objet resérialisé peut différer par les espaces ou l’ordre des clés.
  • La mauvaise clé. Un secret ressemble à whsec_ suivi de base64. La clé, ce sont les octets décodés, pas la chaîne. Les bibliothèques standardwebhooks retirent le préfixe et décodent pour vous.
  • Une dérive d’horloge. Les horodatages décalés de plus de cinq minutes par rapport à votre horloge sont rejetés. Gardez le serveur synchronisé en NTP.
  • Une comparaison === simple. Utilisez timingSafeEqual, qui lève aussi une exception quand les longueurs diffèrent, d’où le contrôle de longueur.
  • Ne lire que la première signature. L’en-tête peut contenir plusieurs signatures séparées par des espaces. Acceptez la requête si l’une des signatures v1 correspond.

Répondez 401 quand la vérification échoue. whasp le traite comme n’importe quel échec et retente : un mauvais secret ne fait donc pas perdre les événements tout de suite, mais il le fait une fois les nouvelles tentatives épuisées. POST /v1/webhook/rotate-secret émet un nouveau secret. L’API ne dit pas si whasp signe avec les deux secrets pendant une rotation : mettez donc votre configuration à jour immédiatement ; tout ce qui est rejeté entre-temps est retenté 30 secondes plus tard.

Pourquoi répondre d’abord et travailler ensuite ?

Un récepteur doit stocker l’événement et répondre d’abord, car whasp attend 10 secondes un 2xx et que le travail d’un agent, un appel au modèle ou à un outil, dure souvent plus longtemps. Répondre tard crée une nouvelle tentative, et une nouvelle tentative crée un doublon.

Le stockage passe en premier parce que whasp ne garde le corps d’un événement que jusqu’à sa livraison ou son abandon, et que les serveurs de whasp ne conservent aucun message. Dès que votre endpoint renvoie 2xx, vous détenez la seule copie d’un message.received. Le schéma est une boîte de réception durable : insérez l’événement brut, répondez 204 et laissez un worker traiter les lignes de la table. Le récepteur complet suit, avec Hono et node:sqlite. Installez hono, @hono/node-server et standardwebhooks dans un projet avec "type": "module", définissez WHASP_WEBHOOK_SECRET et lancez-le avec node --env-file=.env webhook.ts sur Node 22.18 ou plus récent.

import { DatabaseSync } from "node:sqlite";
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { Webhook } from "standardwebhooks";

type WhaspEvent = { id: string; type: string; created_at: string; data: any };

const wh = new Webhook(process.env.WHASP_WEBHOOK_SECRET!); // whsec_...
const MAX_ATTEMPTS = 6;

const db = new DatabaseSync("events.db");
db.exec(`
  CREATE TABLE IF NOT EXISTS inbox (
    id TEXT PRIMARY KEY, type TEXT NOT NULL, chat TEXT, body TEXT NOT NULL,
    status TEXT NOT NULL DEFAULT 'pending',  -- pending | done | dead
    attempts INTEGER NOT NULL DEFAULT 0, next_at INTEGER NOT NULL DEFAULT 0, error TEXT,
    received_at INTEGER NOT NULL DEFAULT (unixepoch()));
`);

const app = new Hono();

app.post("/whasp/webhook", async (c) => {
  const body = await c.req.text(); // les octets bruts qui ont été signés
  let event: WhaspEvent;
  try {
    event = wh.verify(body, c.req.header()) as WhaspEvent;
  } catch {
    return c.text("Invalid signature", 401);
  }
  const chat = event.data.message?.chat ?? event.data.chat ?? null;
  // Durable avant le 2xx. Un identifiant déjà vu n'insère rien et reçoit quand même un 2xx.
  db.prepare("INSERT OR IGNORE INTO inbox (id, type, chat, body) VALUES (?, ?, ?, ?)").run(
    event.id, event.type, chat, body,
  );
  void drain(); // on démarre tout de suite ; le minuteur plus bas ne reprend que les nouvelles tentatives
  return c.body(null, 204);
});

// Le worker : un événement à la fois par conversation, plusieurs conversations en parallèle.
const busy = new Set<string>();

async function drain() {
  const due = db
    .prepare("SELECT id, chat, body, attempts FROM inbox WHERE status = 'pending' AND next_at <= ? ORDER BY rowid")
    .all(Date.now()) as { id: string; chat: string | null; body: string; attempts: number }[];
  for (const row of due) {
    const lane = row.chat ?? row.id;
    if (busy.has(lane)) continue;
    busy.add(lane);
    void work(row).finally(() => busy.delete(lane));
  }
}

async function work(row: { id: string; body: string; attempts: number }) {
  try {
    await handle(JSON.parse(row.body));
    db.prepare("UPDATE inbox SET status = 'done' WHERE id = ?").run(row.id);
  } catch (err) {
    const attempts = row.attempts + 1;
    const status = attempts >= MAX_ATTEMPTS ? "dead" : "pending";
    const next = Date.now() + Math.min(2 ** attempts * 5_000, 300_000); // 10 s, 20 s, 40 s...
    db.prepare("UPDATE inbox SET status = ?, attempts = ?, next_at = ?, error = ? WHERE id = ?").run(
      status, attempts, next, String(err), row.id,
    );
  }
}

async function handle(event: WhaspEvent) {
  switch (event.type) {
    case "message.received":
      console.log("inbound from", event.data.message.chat, event.data.message.text);
      return; // appelez votre agent ici, puis POST /v1/messages
    case "message.failed":
      console.warn("send failed", event.data.message.id, event.data.message.error);
      return;
    case "number.status_changed":
      if (event.data.number.health === "critical") console.error("number critical", event.data.number.number);
      return;
    case "number.replaced":
      console.warn("replaced", event.data.old_number, "->", event.data.new_number.number);
      return;
    default:
      return; // message.sent, reaction.received, number.ready, webhook.test
  }
}

setInterval(drain, 1000);
serve({ fetch: app.fetch, port: 3000 });

Trois éléments portent l’essentiel. L’INSERT OR IGNORE sur l’identifiant d’événement assure le dédoublonnage et la durabilité en une seule instruction. La boucle drain traite un événement à la fois par conversation et plusieurs conversations en parallèle : une conversation lente ne bloque donc jamais les autres. Et work consigne chaque échec avec un nombre de tentatives et un délai croissant, puis range la ligne en dead après six essais : un événement toxique cesse ainsi de tourner en boucle et reste dans la table pour que vous le lisiez.

Purgez les lignes terminées chaque jour avec DELETE FROM inbox WHERE status = 'done' AND received_at < unixepoch() - 86400. Gardez les identifiants plus longtemps que la fenêtre de nouvelles tentatives de whasp, sinon une relance tardive passe pour un nouvel événement. Après une panne chez votre fournisseur de modèle, remettez en file les lignes mises de côté avec UPDATE inbox SET status = 'pending', attempts = 0 WHERE status = 'dead'.

Que se passe-t-il quand votre endpoint échoue ?

Quand votre endpoint échoue, whasp retente le même événement avec des écarts croissants. Un échec est toute réponse non 2xx, une erreur de connexion ou l’absence de réponse en 10 secondes. Un 401 dû à une mauvaise signature compte aussi.

Nouvelle tentative Attente après l’échec précédent
1 30 secondes
2 2 minutes
3 10 minutes
4 30 minutes
5 à 12 1 heure chacune

Les douze nouvelles tentatives s’étalent sur environ huit heures. Après la dernière, la livraison est marquée failed et, comme whasp supprime le corps d’un événement dès qu’il est livré ou abandonné, le texte d’un message.received qui a échoué à chaque fois ne peut pas être renvoyé. Sans URL de webhook, les événements sont seulement listés comme no_endpoint et jamais envoyés.

Deux habitudes en découlent. Gardez l’endpoint indépendant de votre fournisseur de modèle : avec la boîte de réception, une panne de ce côté se voit comme des lignes dans votre table, pas comme des livraisons en échec. Et surveillez GET /v1/webhook/deliveries, qui liste les 7 derniers jours avec le status, les attempts, le last_status_code et le last_error de chaque livraison.

Comment gérer les doublons et l’ordre ?

Les événements en double sont normaux, car la livraison se fait au moins une fois. Le même événement arrive deux fois quand votre 2xx se perd sur le réseau ou arrive après 10 secondes. Dédoublonnez sur l’identifiant d’événement (la même valeur que webhook-id), continuez à répondre 2xx aux répétitions, et votre gestionnaire s’exécute une fois par événement.

L’ordre n’est pas garanti non plus. Un message qui a nécessité une nouvelle tentative peut arriver après celui que la personne a envoyé ensuite. Le récepteur ci-dessus traite déjà une conversation à la fois. Triez l’historique que vous construisez pour le modèle par message.created_at plutôt que par ordre d’arrivée, et attendez-vous à des rafales : trois messages en dix secondes produisent trois événements, pensez donc à attendre deux ou trois secondes de plus avant de répondre une seule fois.

Les doublons touchent aussi ce que vous renvoyez. Envoyez chaque réponse avec une Idempotency-Key construite à partir de l’identifiant du message entrant, par exemple reply-msg_..., et non avec une valeur aléatoire à chaque tentative. whasp conserve une clé pendant 7 jours par compte : la même clé renvoie le premier message sans jamais envoyer deux fois. Un envoi refusé (4xx) ne consomme pas la clé, et un 409 idempotency_conflict signifie que la première requête avec cette clé est encore en cours : réessayez après un instant. La même précaution vaut pour les outils à effet de bord, comme les réservations ou les remboursements : un événement rejoué relance votre agent, protégez-les donc avec l’identifiant du message entrant, comme l’explique le tutoriel du bot WhatsApp Claude.

Comment tester et déboguer les webhooks ?

Vous testez un webhook whasp en quatre étapes, et la dernière ne demande aucun compte whasp.

  1. Lancez le récepteur et exposez-le. cloudflared tunnel --url http://localhost:3000 donne une adresse HTTPS temporaire et n’exige aucun compte Cloudflare.
  2. Enregistrez-le avec PUT /v1/webhook et un corps { "url": "https://.../whasp/webhook" }. La réponse contient le secret.
  3. Appelez POST /v1/webhook/test. Il envoie tout de suite un webhook.test et répond { "delivered", "status_code", "error" } : vous voyez ainsi ce que votre serveur a renvoyé.
  4. Envoyez un faux événement signé à localhost. Webhook.sign produit la valeur de l’en-tête : ce script ne demande donc ni compte whasp ni tunnel :
import { Webhook } from "standardwebhooks";

const wh = new Webhook(process.env.WHASP_WEBHOOK_SECRET!);
const event = {
  id: `evt_local_${Date.now()}`,
  type: "message.received",
  created_at: new Date().toISOString(),
  data: { message: { id: "msg_local_1", chat: "+15555550111", text: "Bonjour, ceci est un test", media: null } },
};
const body = JSON.stringify(event);

const res = await fetch("http://localhost:3000/whasp/webhook", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "webhook-id": event.id,
    "webhook-timestamp": String(Math.floor(Date.now() / 1000)),
    "webhook-signature": wh.sign(event.id, new Date(), body),
  },
  body,
});
console.log(res.status); // 204

Lancez-le avec node --env-file=.env send-test-event.ts pendant que le récepteur tourne. Changez type et data pour répéter message.failed ou number.replaced de la même façon.

Que construire ensuite ?

Branchez le récepteur à un agent : l’intégration OpenAI WhatsApp et le bot WhatsApp Claude partent tous deux de ce schéma. Faites-en un agent IA WhatsApp avec mémoire, ou construisez le même flux sans code dans n8n. Quand un numéro ne suffit plus, planifiez la capacité de plusieurs numéros.

Questions

Comment vérifier la signature d'un webhook WhatsApp de whasp ?

Recalculez un HMAC-SHA256 sur la chaîne webhook-id, un point, webhook-timestamp, un point et le corps brut, avec pour clé la partie décodée en base64 de votre secret whsec_, puis comparez-le en temps constant à la valeur v1 de webhook-signature. La bibliothèque standardwebhooks de votre langage le fait en un appel.

Combien de fois whasp retente-t-il un webhook en échec ?

Jusqu'à 12 fois, après 30 secondes, 2 minutes, 10 minutes, 30 minutes, puis toutes les heures. Après le dernier échec, la livraison est marquée failed et le corps de l'événement est supprimé.

Le même événement de webhook peut-il arriver deux fois ?

Oui. La livraison se fait au moins une fois : un 2xx perdu ou une réponse lente produit un doublon avec le même identifiant d'événement. Stockez l'identifiant avec une contrainte d'unicité et répondez 2xx aux répétitions.

Que doit renvoyer un endpoint de webhook ?

Tout statut 2xx en moins de 10 secondes compte comme livré, et un 204 sans corps suffit. Un 4xx, un 5xx, un délai dépassé ou une erreur de connexion est un échec et est retenté.

Comment tester un webhook whasp en local ?

Exposez votre port avec un tunnel, enregistrez l'URL avec PUT /v1/webhook et appelez POST /v1/webhook/test. Vous pouvez aussi signer vous-même un faux événement avec la bibliothèque standardwebhooks et l'envoyer en POST à localhost.

Tous les articles