# Recevoir messages et statuts WhatsApp par webhook

URL: https://wa.genuka.com/docs/guides/receive-messages-webhooks
Language: French

> Recevez réponses WhatsApp et statuts de livraison sur votre serveur : déclarer un webhook, vérifier la signature HMAC, dédupliquer, gérer les reprises.

Pour recevoir les réponses WhatsApp et les statuts de livraison, déclarez une URL HTTPS dans
Genuka WA : chaque événement y arrive en `POST` signé. Votre endpoint vérifie la signature
HMAC-SHA256 de l'en-tête `X-Genuka-Signature` sur le corps brut, enregistre l'événement une seule
fois grâce à son `id`, répond 2xx en moins de 10 secondes, puis traite en arrière-plan.

*Mis à jour le 8 octobre 2026*

## Comment déclarer un endpoint de webhook ?

1. ### Exposez une URL HTTPS publique

   Genuka WA refuse les URL en `http://`, celles qui contiennent un identifiant et un mot de passe,
   et celles qui pointent vers une adresse privée ou locale. Pour développer sur votre machine,
   passez par un tunnel HTTPS. Les redirections ne sont pas suivies : une réponse `3xx` compte comme
   un échec, donc déclarez l'URL finale exacte.

2. ### Déclarez-la

   Dans le tableau de bord, section **Webhooks**, ou par l'API :

   ```bash title="POST /api/v1/webhooks"
   curl -X POST https://wa.genuka.com/api/v1/webhooks \
     -H "Authorization: Bearer $GENUKA_WA_API_KEY" \
     -H "Content-Type: application/json" \
     -d '{
       "url": "https://api.example.com/webhooks/genuka",
       "connectionId": "con_1",
       "events": ["message.received", "message.sent", "message.delivered", "message.read",
                  "message.failed", "template.status_changed"]
     }'
   ```

   Sans `connectionId` ni `companyId`, l'endpoint couvre tous les numéros que votre clé atteint.
   Un `events` vide ou absent veut dire « tous les événements ». `template.status_changed` apporte
   le verdict de Meta sur vos templates (événement `template_status`) : sans lui, un endpoint
   abonné aux seuls messages ne le reçoit pas.

3. ### Copiez le secret

   La réponse `201` contient `secret` (`whsec_…`). Il n'est renvoyé qu'à la création et lors d'une
   rotation (`PATCH /api/v1/webhooks/{id}` avec `"rotateSecret": true`) : stockez-le dans vos
   variables d'environnement, par exemple `GENUKA_WEBHOOK_SECRET`. Une rotation prend effet
   immédiatement.

4. ### Testez

   `POST /api/v1/webhooks/{id}/test` envoie à votre URL un exemple de chaque famille d'événements
   et renvoie, pour chacun, le statut HTTP obtenu. Les exemples sont signés avec votre secret, comme
   une vraie livraison : c'est le moyen de tester votre vérification de signature avant le premier
   vrai message.

## À quoi ressemble un événement ?

Chaque livraison est un `POST` JSON qui porte **un** événement. Les champs autour de `data`
disent à quelle entreprise et à quel numéro il se rapporte ; `connection_id` est celui à reprendre
pour répondre.

```json title="POST https://api.example.com/webhooks/genuka"
{
  "id": "cmg7v2k0x0001…",
  "type": "inbound_message",
  "field": "messages",
  "created_at": "2026-10-08T09:31:07.412Z",
  "partner_id": "cl9x…",
  "company_id": "cm31…",
  "connection_id": "cn77…",
  "waba_id": "102290129340398",
  "phone_number_id": "106540352242922",
  "data": {
    "from": "237699001122",
    "id": "wamid.HBgLMjM3…",
    "timestamp": "1791451865",
    "type": "text",
    "text": { "body": "Je ne serai pas là demain" }
  }
}
```

Pour les cinq types historiques, `data` est l'objet **brut** envoyé par Meta, sans retouche :

| `type`            | Ce que c'est                                                | `data`                       |
| ----------------- | ----------------------------------------------------------- | ---------------------------- |
| `inbound_message` | Un message reçu du client                                   | L'objet message de Meta      |
| `message_status`  | `sent`, `delivered`, `read` ou `failed` d'un message envoyé | L'objet statut de Meta       |
| `template_status` | Un template approuvé, rejeté ou mis en pause                | La valeur Meta du changement |
| `account_update`  | Un changement sur le compte WhatsApp Business               | La valeur Meta du changement |
| `phone_quality`   | La qualité ou le palier d'un numéro a changé                | La valeur Meta du changement |

Les événements plus récents portent un nom Genuka (`user_preference.stopped`,
`template.quality_changed`, `unknown.received`…) et un `data` normalisé. Aiguillez toujours sur
`type`, et ignorez sans erreur ce que vous ne traitez pas : de nouveaux types peuvent apparaître.
Voir aussi la [référence Webhooks](https://wa.genuka.com/docs/webhooks).

### Comment lire la réponse d'un client ?

Le contenu d'un `inbound_message` dépend de `data.type`
([doc Meta, messages entrants](https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/reference/messages)) :

```json title="data — le client a touché un bouton de réponse (message interactif)"
{
  "context": { "from": "237690000000", "id": "wamid.du_message_envoyé…" },
  "from": "237699001122",
  "id": "wamid.HBgL…",
  "timestamp": "1791451865",
  "type": "interactive",
  "interactive": { "type": "button_reply", "button_reply": { "id": "yes", "title": "Confirmer" } }
}
```

* `type: "text"` → `data.text.body`.
* `type: "interactive"` → `data.interactive.button_reply.id` ou `data.interactive.list_reply.id`,
  l'`id` que vous aviez donné au bouton ou à la ligne
  ([doc Meta](https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/reference/messages/interactive)).
* `type: "button"` → `data.button.payload` : un bouton de réponse rapide d'un template
  ([doc Meta](https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/reference/messages/button)).
* `data.context.id`, quand il est présent, est le `wamid` du message auquel le client répond.

### Comment lire un statut de livraison ?

```json title="data — message_status en échec"
{
  "id": "wamid.HBgLMjM3…",
  "status": "failed",
  "timestamp": "1791451901",
  "recipient_id": "237699001122",
  "errors": [{ "code": 131026, "title": "…" }]
}
```

`data.id` est le `messageId` renvoyé par `POST /api/v1/messages`. `errors` n'apparaît que sur un
échec ([doc Meta, statuts](https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/reference/messages/status)).

### Quels en-têtes accompagnent chaque livraison ?

| En-tête                | Contenu                                                                        |
| ---------------------- | ------------------------------------------------------------------------------ |
| `X-Genuka-Signature`   | `t=<horodatage>,v1=<HMAC hexadécimal>`                                         |
| `X-Genuka-Event`       | Le `type` de l'événement                                                       |
| `X-Genuka-Event-Field` | Le champ webhook Meta d'origine                                                |
| `X-Genuka-Delivery`    | L'id de livraison, égal à l'`id` du corps, identique d'une tentative à l'autre |
| `X-Genuka-Webhook-Id`  | L'endpoint visé                                                                |
| `X-Genuka-Attempt`     | Le numéro de tentative, à partir de 1                                          |
| `X-Genuka-Test`        | `true` sur un événement de test, absent sinon                                  |

## Comment vérifier la signature HMAC ?

L'algorithme, exactement tel que Genuka WA signe :

1. Lisez le **corps brut**, avant tout `JSON.parse`.
2. Dans `X-Genuka-Signature`, lisez `t` (secondes Unix) et chaque `v1` (il peut y en avoir
   plusieurs).
3. Rejetez si `t` s'écarte de plus de 300 secondes de votre horloge.
4. Calculez le HMAC-SHA256 en hexadécimal de la chaîne `t` + `.` + corps brut, avec pour clé le
   secret **entier**, préfixe `whsec_` compris.
5. Acceptez si l'un des `v1` est égal à ce calcul, comparé en temps constant.

> [!NOTE]
> **Une relivraison tardive passe quand même la fenêtre**
>
> Chaque tentative est signée au moment où elle part, avec un `t` neuf. Une reprise six heures
> plus tard, ou un rejeu manuel, est donc acceptée par la tolérance de 300 secondes ; une requête
> capturée puis rejouée par un tiers, elle, ne l'est pas, car `t` ne peut pas être modifié sans
> invalider `v1`.

La fonction de vérification, sans dépendance :

```ts title="verify-signature.ts"
import crypto from "node:crypto";

const TOLERANCE_SECONDS = 300;

export function verifySignature(rawBody: string, header: string | null, secret: string): boolean {
  if (!header) return false;

  let timestamp = Number.NaN;
  const signatures: string[] = [];
  for (const part of header.split(",")) {
    const [key, value] = part.split("=", 2).map((s) => s.trim());
    if (key === "t") timestamp = Number(value);
    if (key === "v1" && value) signatures.push(value);
  }
  if (!Number.isFinite(timestamp) || signatures.length === 0) return false;

  // Le timestamp fait partie du HMAC : hors de la fenêtre, une requête capturée ne vaut plus rien.
  if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;

  const expected = crypto
    .createHmac("sha256", secret) // le secret entier, préfixe whsec_ compris
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");

  // timingSafeEqual lève une exception sur deux buffers de tailles différentes, et l'en-tête est
  // écrit par qui envoie la requête : on compare les tailles en octets, pas en caractères (« é »
  // fait un caractère mais deux octets), sinon un en-tête forgé transforme un 401 en 500.
  const expectedBytes = Buffer.from(expected);
  return signatures.some((candidate) => {
    const candidateBytes = Buffer.from(candidate);
    return (
      candidateBytes.length === expectedBytes.length &&
      crypto.timingSafeEqual(candidateBytes, expectedBytes)
    );
  });
}
```

Puis l'endpoint, selon votre framework :

**Next.js**

```ts title="app/api/webhooks/genuka/route.ts"
import { after } from "next/server";
import { verifySignature } from "@/lib/verify-signature";

export async function POST(request: Request) {
  const raw = await request.text(); // le corps tel qu'il a été signé
  const signature = request.headers.get("x-genuka-signature");
  if (!verifySignature(raw, signature, process.env.GENUKA_WEBHOOK_SECRET!)) {
    return new Response("invalid signature", { status: 401 });
  }

  // Un événement de test est signé comme un vrai : il ne doit rien écrire en base.
  if (request.headers.get("x-genuka-test") === "true") return new Response(null, { status: 204 });

  const event = JSON.parse(raw);

  // Enregistrer d'abord (rapide, dédupliqué sur event.id), traiter après la réponse.
  const fresh = await saveEventOnce(event);
  if (fresh) after(() => processEvent(event));

  return new Response(null, { status: 204 });
}
```

**Express**

```ts title="server.ts"
// Lancé avec tsx (`npx tsx server.ts`), qui résout ./verify-signature.js vers le fichier .ts.
import express from "express";
import { verifySignature } from "./verify-signature.js";

const app = express();

// Déclarée AVANT app.use(express.json()) : un parseur global consommerait le corps,
// et la signature ne correspondrait plus jamais.
app.post("/webhooks/genuka", express.raw({ type: "application/json" }), async (req, res) => {
  const raw = req.body.toString("utf8");
  const signature = req.get("X-Genuka-Signature") ?? null;
  if (!verifySignature(raw, signature, process.env.GENUKA_WEBHOOK_SECRET!)) {
    return res.status(401).send("invalid signature");
  }
  // Un événement de test est signé comme un vrai : il ne doit rien écrire en base.
  if (req.get("X-Genuka-Test") === "true") return res.sendStatus(204);

  const event = JSON.parse(raw);
  const fresh = await saveEventOnce(event); // INSERT … ON CONFLICT (id) DO NOTHING
  res.sendStatus(204); // répondre d'abord
  if (fresh) await queue.add("genuka-event", event); // traiter ensuite, hors de la requête
});

app.use(express.json()); // le reste de votre API
```

**Python (Flask)**

```python title="app.py"
import hashlib
import hmac
import json
import os
import time

from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["GENUKA_WEBHOOK_SECRET"].encode()  # le secret entier, préfixe whsec_ compris
TOLERANCE_SECONDS = 300


def verify_signature(raw, header):
    if not header:
        return False
    timestamp, signatures = None, []
    for part in header.split(","):
        key, _, value = part.partition("=")
        key, value = key.strip(), value.strip()
        # isascii() : isdigit() seul accepte « ² », sur lequel int() lève une exception.
        if key == "t" and value.isascii() and value.isdigit():
            timestamp = int(value)
        elif key == "v1" and value:
            signatures.append(value)
    if timestamp is None or not signatures:
        return False
    # Le timestamp fait partie du HMAC : hors de la fenêtre, une requête capturée ne vaut plus rien.
    if abs(time.time() - timestamp) > TOLERANCE_SECONDS:
        return False
    expected = hmac.new(SECRET, f"{timestamp}.".encode() + raw, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(s.encode(), expected.encode()) for s in signatures)


@app.post("/webhooks/genuka")
def genuka_webhook():
    raw = request.get_data()  # les octets reçus, avant tout parsing
    if not verify_signature(raw, request.headers.get("X-Genuka-Signature")):
        abort(401)
    # Un événement de test est signé comme un vrai : il ne doit rien écrire en base.
    if request.headers.get("X-Genuka-Test") == "true":
        return "", 204

    event = json.loads(raw)
    if save_event_once(event):  # INSERT … ON CONFLICT (id) DO NOTHING
        enqueue(event)  # Celery, RQ… : le traitement ne retient pas la réponse
    return "", 204
```

**@genuka/whatsapp**

```ts title="app/api/webhooks/genuka/route.ts"
import { verifySignature } from "@genuka/whatsapp/webhooks";

export async function POST(request: Request) {
  const raw = await request.text();
  const ok = await verifySignature(
    process.env.GENUKA_WEBHOOK_SECRET!,
    raw,
    request.headers.get("x-genuka-signature"),
  );
  if (!ok) return new Response("invalid signature", { status: 401 });

  // … même suite que l'onglet Next.js
  return new Response(null, { status: 204 });
}
```

`verifySignature` de la [librairie](https://wa.genuka.com/docs/library) est asynchrone (WebCrypto, donc utilisable
aussi sur un runtime edge) et exige `@genuka/whatsapp` 0.1.1 ou plus récent.

## Comment répondre assez vite pour éviter les relivraisons ?

Une livraison réussit sur n'importe quel `2xx`. Tout le reste échoue et sera retenté : un statut
`4xx` ou `5xx`, une redirection, ou une réponse qui dépasse **10 secondes**.

La séquence qui tient la charge :

1. vérifier la signature ;
2. enregistrer l'événement, dédupliqué sur son `id` — une insertion, quelques millisecondes ;
3. répondre `204` ;
4. traiter ensuite, dans `after()`, une file de tâches ou un worker.

Ne répondez `5xx` que si l'enregistrement lui-même a échoué : c'est le seul cas où vous avez
besoin que Genuka WA réessaie. Un traitement qui échoue après la réponse se rejoue depuis votre
table, pas depuis le webhook.

## Comment dédupliquer les événements ?

L'`id` du corps — égal à l'en-tête `X-Genuka-Delivery` — est identique d'une tentative à l'autre et
lors d'un rejeu manuel. C'est votre clé d'idempotence.

```sql title="schema.sql"
create table genuka_events (
  id           text primary key,    -- l'id du corps, stable entre les tentatives
  type         text not null,
  payload      jsonb not null,
  received_at  timestamptz not null default now(),
  processed_at timestamptz          -- null tant que le traitement n'a pas abouti
);
```

```ts title="save-event-once.ts"
export async function saveEventOnce(event: { id: string; type: string }): Promise<boolean> {
  const result = await db.query(
    "insert into genuka_events (id, type, payload) values ($1, $2, $3) on conflict (id) do nothing",
    [event.id, event.type, JSON.stringify(event)],
  );
  return result.rowCount === 1; // false : déjà reçu, rien à faire
}
```

Deux compléments :

* **Plusieurs endpoints, plusieurs `id`.** Si deux de vos endpoints couvrent le même numéro, chacun
  reçoit sa propre livraison, avec son propre `id`. Dédupliquez alors aussi sur la clé métier :
  `data.id` (le `wamid`) pour un message entrant, le couple `data.id` + `data.status` pour un
  statut.
* **Les événements de test** ont un `id` qui commence par `test_`, l'en-tête `X-Genuka-Test: true`
  et pas d'en-tête `X-Genuka-Delivery`. Filtrez-les avant d'écrire quoi que ce soit en base, comme
  le font les exemples plus haut.

Enfin, l'ordre n'est pas garanti : un `read` peut arriver avant le `delivered` du même message, et
`delivered` peut même ne jamais arriver. Quand le client a la conversation ouverte au moment où le
message arrive, Meta le considère livré et lu d'un coup et n'envoie que `read`
([doc Meta, statuts](https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/reference/messages/status)).
Traitez donc `read` comme impliquant `delivered`, et ne faites jamais reculer un statut —
`sent` → `delivered` → `read`, et `failed` est définitif.

## Que se passe-t-il si mon serveur est en panne ?

Genuka WA fait jusqu'à six tentatives au total :

| Tentative | Quand                                                        |
| --------- | ------------------------------------------------------------ |
| 1         | Dès que l'événement arrive                                   |
| 2         | Au moins 1 minute après l'échec de la précédente             |
| 3         | Au moins 5 minutes après                                     |
| 4         | Au moins 30 minutes après                                    |
| 5         | Au moins 2 heures après                                      |
| 6         | Au moins 6 heures après, puis la livraison passe en `failed` |

Les reprises partent d'une file vidée toutes les 10 minutes : une tentative peut donc arriver
jusqu'à une dizaine de minutes après son délai minimal, davantage en cas d'afflux.

Une fois votre endpoint rétabli, retrouvez ce qui a échoué et rejouez-le :

```bash title="Lister les livraisons en échec, puis en rejouer une"
curl "https://wa.genuka.com/api/v1/webhooks/wh_1/deliveries?status=failed" \
  -H "Authorization: Bearer $GENUKA_WA_API_KEY"

curl -X POST https://wa.genuka.com/api/v1/webhooks/wh_1/deliveries/cmg7v2k0x0001/replay \
  -H "Authorization: Bearer $GENUKA_WA_API_KEY"
```

Le rejeu répond `202` et repart avec un budget de tentatives neuf. Une livraison encore en
`pending` est refusée (`409 delivery_pending`) : elle est déjà dans la file. Le journal conserve le
corps exact de chaque livraison ; sa durée de conservation dépend de votre formule et figure dans
`meta.retentionDays`. Le tableau de bord, section **Webhooks**, offre le même journal et le même
bouton de renvoi.

## Comment répondre à un message reçu ?

Tant que le client vous a écrit dans les dernières 24 heures, vous pouvez lui répondre par un texte
libre ([doc Meta](https://developers.facebook.com/documentation/business-messaging/whatsapp/messages/send-messages)).
Reprenez `connection_id` de l'événement, ajoutez le `+` devant `data.from`, et citez le message
avec `replyTo` :

```json title="POST /api/v1/messages"
{
  "connectionId": "cn77…",
  "to": "+237699001122",
  "replyTo": "wamid.HBgLMjM3…",
  "text": "Merci, c'est noté !"
}
```

Pour afficher les coches bleues, `POST /api/v1/messages/{wamid}/read` avec `{ "connectionId": … }`
dans le corps — le `wamid` encodé pour l'URL. Hors fenêtre, il faut un template : voir
[Notifications de commande](https://wa.genuka.com/docs/guides/order-notifications).

## FAQ

### Pourquoi ma signature ne correspond-elle jamais ?

Presque toujours l'une de ces quatre causes : le corps a été parsé puis resérialisé avant la
vérification (un `express.json()` global, par exemple) ; le secret a été copié sans son préfixe
`whsec_` ; l'horloge du serveur dérive de plus de cinq minutes ; ou le secret a été régénéré depuis.

### Puis-je recevoir directement les webhooks de Meta ?

Non. Les webhooks de la Cloud API arrivent chez Genuka WA, qui vous les relaie signés avec votre
secret. Pour les cinq types historiques, `data` reste l'objet Meta d'origine : un code qui lit déjà
le format Meta s'y retrouve.

### Faut-il une clé API pour recevoir les webhooks ?

Non. La seule chose à vérifier à la réception est la signature. Ne traitez jamais un événement non
signé.

### Les statuts arrivent-ils dans l'ordre ?

Non : `read` peut précéder `delivered`, voire arriver seul. Classez les statuts, traitez `read`
comme impliquant `delivered`, et ne les faites jamais reculer.

## Sources

* [Meta — Messages webhook reference](https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/reference/messages)
* [Meta — Interactive message replies](https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/reference/messages/interactive)
* [Meta — Button message replies](https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/reference/messages/button)
* [Meta — Status messages webhook](https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/reference/messages/status)
* [Meta — Send messages (customer service window)](https://developers.facebook.com/documentation/business-messaging/whatsapp/messages/send-messages)
