Recevoir messages et statuts WhatsApp par webhook
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 ?
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.
Déclarez-la
Dans le tableau de bord, section Webhooks, ou par l'API :
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.
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.
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.
{
"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.
Comment lire la réponse d'un client ?
Le contenu d'un inbound_message dépend de data.type
(doc Meta, messages entrants) :
{
"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.idoudata.interactive.list_reply.id, l'idque vous aviez donné au bouton ou à la ligne (doc Meta).type: "button"→data.button.payload: un bouton de réponse rapide d'un template (doc Meta).data.context.id, quand il est présent, est lewamiddu message auquel le client répond.
Comment lire un statut de livraison ?
{
"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).
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 :
- Lisez le corps brut, avant tout
JSON.parse. - Dans
X-Genuka-Signature, lisezt(secondes Unix) et chaquev1(il peut y en avoir plusieurs). - Rejetez si
ts'écarte de plus de 300 secondes de votre horloge. - Calculez le HMAC-SHA256 en hexadécimal de la chaîne
t+.+ corps brut, avec pour clé le secret entier, préfixewhsec_compris. - Acceptez si l'un des
v1est égal à ce calcul, comparé en temps constant.
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 :
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 :
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 });
}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 :
- vérifier la signature ;
- enregistrer l'événement, dédupliqué sur son
id— une insertion, quelques millisecondes ; - répondre
204; - 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.
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
);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 propreid. Dédupliquez alors aussi sur la clé métier :data.id(lewamid) pour un message entrant, le coupledata.id+data.statuspour un statut. - Les événements de test ont un
idqui commence partest_, l'en-têteX-Genuka-Test: trueet pas d'en-têteX-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).
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 :
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).
Reprenez connection_id de l'événement, ajoutez le + devant data.from, et citez le message
avec replyTo :
{
"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.
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.