# 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.

Source: https://whasp.ai/fr/blog/webhook-whatsapp-signature-doublons · 2026-10-20

## 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](https://whasp.ai/fr/blog/numero-whatsapp-banni-migrer-utilisateurs). |
| `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`](https://github.com/standard-webhooks/standard-webhooks) 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](https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md) écrit avec `node:crypto`, pour les environnements sans bibliothèque (dans Hono, passez `c.req.raw.headers` et le corps en texte) :

```ts
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`](https://nodejs.org/api/crypto.html#cryptotimingsafeequala-b), 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.

```ts
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](https://whasp.ai/fr/blog/bot-whatsapp-claude-api-anthropic).

## 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](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/trycloudflare/).
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 :

```ts
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](https://whasp.ai/fr/blog/integration-openai-whatsapp-agent-gpt) et [le bot WhatsApp Claude](https://whasp.ai/fr/blog/bot-whatsapp-claude-api-anthropic) partent tous deux de ce schéma. Faites-en un [agent IA WhatsApp avec mémoire](https://whasp.ai/fr/blog/agent-ia-whatsapp-memoire-conversation), ou construisez le même flux sans code dans [n8n](https://whasp.ai/fr/blog/agent-ia-whatsapp-n8n-workflow). Quand un numéro ne suffit plus, [planifiez la capacité de plusieurs numéros](https://whasp.ai/fr/blog/combien-numeros-whatsapp-agent-ia).

## 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.
