Genuka WA docs

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 :

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.

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.

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 :

typeCe que c'estdata
inbound_messageUn message reçu du clientL'objet message de Meta
message_statussent, delivered, read ou failed d'un message envoyéL'objet statut de Meta
template_statusUn template approuvé, rejeté ou mis en pauseLa valeur Meta du changement
account_updateUn changement sur le compte WhatsApp BusinessLa valeur Meta du changement
phone_qualityLa 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) :

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).
  • 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 le wamid du message auquel le client répond.

Comment lire un statut de livraison ?

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

Quels en-têtes accompagnent chaque livraison ?

En-têteContenu
X-Genuka-Signaturet=<horodatage>,v1=<HMAC hexadécimal>
X-Genuka-EventLe type de l'événement
X-Genuka-Event-FieldLe champ webhook Meta d'origine
X-Genuka-DeliveryL'id de livraison, égal à l'id du corps, identique d'une tentative à l'autre
X-Genuka-Webhook-IdL'endpoint visé
X-Genuka-AttemptLe numéro de tentative, à partir de 1
X-Genuka-Testtrue 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.

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 :

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 :

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 });
}

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.

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
);
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). 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 :

TentativeQuand
1Dès que l'événement arrive
2Au moins 1 minute après l'échec de la précédente
3Au moins 5 minutes après
4Au moins 30 minutes après
5Au moins 2 heures après
6Au 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 :

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). Reprenez connection_id de l'événement, ajoutez le + devant data.from, et citez le message avec replyTo :

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.

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

Sur cette page