Genuka WA docs

Webhooks

Normalized event union, idempotency, signature verification and the public Genuka event vocabulary.

Webhooks & ingestion. Ce document décrit ce que le client reçoit (le contrat d'événements Genuka, sa signature, son journal de livraison) et ce que la plateforme garantit (idempotence, ordre, opt-out marketing). Code : src/webhooks/, lib/webhooks/, app/api/v1/webhooks/.


1. Ce que le client voit

Une ressource webhook classique. Jamais le dispatcher, jamais subscribed_apps, jamais un PHONE_NUMBER_ID Meta.

POST   /api/v1/webhooks                                    créer (renvoie le secret UNE fois)
GET    /api/v1/webhooks                                    lister
GET    /api/v1/webhooks/{id}                               lire
PATCH  /api/v1/webhooks/{id}                               modifier ({ rotateSecret: true } → nouveau secret)
DELETE /api/v1/webhooks/{id}                               supprimer
POST   /api/v1/webhooks/{id}/test                          un événement factice signé par famille
GET    /api/v1/webhooks/{id}/deliveries                    journal de livraison (paginé)
POST   /api/v1/webhooks/{id}/deliveries/{deliveryId}/replay  rejouer

Champs d'un endpoint : url (https obligatoire), events[] (vide = tous), enabled, name, et une portée facultative companyId / connectionId. Ce qui n'apparaît jamais dans une réponse : secret (sauf à la création et à la rotation), dispatcherTargetId, lastSyncedAt, syncError.

La portée est celle de la clé API (lib/api/scope.ts) : une clé client ne voit que les endpoints créés par elle, jamais l'intégration que son revendeur a posée sur le même compte.


2. Le vocabulaire Genuka

Les noms de champs Meta sont l'affaire de Meta : ils sont renommés, scindés et dépréciés au rythme de Meta. Le contrat public est donc une liste de noms Genuka (src/webhooks/vocabulary.ts), et GENUKA_EVENT_BY_FIELD est le seul endroit où les deux vocabulaires se rencontrent.

FamilleÉvénements
Conversationmessage.received · message.sent · message.delivered · message.read · message.failed · message.deleted
Templatestemplate.status_changed · template.quality_changed · template.category_changed · template.components_changed
Consentementuser_preference.stopped · user_preference.resumed
Compte & numéroaccount.alert · account.updated · account.review_completed · account.capability_changed · account.security_changed · number.quality_changed · number.name_changed
Flowsflow.response_received · flow.status_changed
Coexistencecoexistence.history_received · coexistence.contacts_synced · coexistence.message_echoed · coexistence.offboarded · coexistence.reconnected
Fourre-toutunknown.received

Le champ Meta ne suffit pas à nommer un événement : messages transporte les messages entrants, les statuts sortants et les réponses de Flow. C'est genukaEventFor(event) qui tranche, à partir de l'union normalisée — pas le field.

import { parseWebhook } from "@genuka/whatsapp";
import { genukaEventFor } from "@genuka/whatsapp/webhooks/vocabulary";

for (const event of parseWebhook(body)) {
  console.log(genukaEventFor(event)); // "message.delivered", "user_preference.stopped", …
}

Politique de versionnage

  • Ajouter un nom est une version mineure. Un abonnement « tous les événements » (events: []) commence à le recevoir : c'est ce que « tous » veut dire.
  • Renommer ou supprimer un nom est une version majeure, et l'ancien nom continue d'être livré pendant un cycle majeur complet, en parallèle du nouveau.
  • Le payload d'un événement peut gagner des champs en mineure ; il n'en perd jamais, et un champ ne change jamais de sens. Un consommateur doit ignorer ce qu'il ne connaît pas.
  • Le mapping champ Meta → nom Genuka peut changer en mineure : c'est exactement à cela que sert l'indirection. Tant que le nom auquel le client s'est abonné continue d'arriver, ce n'est pas une rupture.
  • Un champ Meta non mappé est livré en unknown.received avec son payload brut. Un événement que nous n'avons pas encore modélisé reste un événement que le client a payé.

Compatibilité ascendante

Un abonnement est stocké sous les deux vocabulaires : les noms Genuka choisis par le client, plus les champs Meta auxquels ils se résolvent (normalizeEventSelection). Les champs Meta sont ce sur quoi lib/webhooks/deliver.ts filtre un événement entrant ; ils sont retirés à la sortie (toGenukaEvents), donc aucune réponse d'API n'expose jamais un nom Meta. Les endpoints créés avant le vocabulaire, qui ne contiennent que des champs Meta, continuent de fonctionner et sont traduits à la lecture : messages signifie bien « les six événements de conversation et les réponses de Flow », et c'est ce qui est affiché.


3. L'enveloppe livrée

POST sur l'URL du client, corps JSON, signé.

{
  "id": "cl…",                        // id de la livraison ; clé d'idempotence côté client
  "type": "message.delivered",
  "field": "messages",                // champ Meta d'origine, pour le débogage
  "created_at": "2026-08-13T10:12:00.000Z",
  "partner_id": "…", "company_id": "…", "connection_id": "…",
  "waba_id": "…", "phone_number_id": "…",
  "data": { }
}

En-têtes : X-Genuka-Signature, X-Genuka-Event, X-Genuka-Event-Field, X-Genuka-Delivery, X-Genuka-Webhook-Id, X-Genuka-Attempt. Un événement de test porte en plus X-Genuka-Test: true — une intégration en construction doit pouvoir distinguer l'exercice du réel avant d'écrire quoi que ce soit en base.

⚠️ Deux formes de data coexistent

typefielddata
inbound_message, message_status, template_status, account_update, phone_qualitymessages, message_template_status_update, account_update, phone_number_quality_updatel'objet Meta brut
tout le reste (user_preference.*, template.quality_changed, flow.*, unknown.received…)le champ Meta correspondantle payload normalisé Genuka

Ce n'est pas une élégance, c'est une dette assumée : les cinq premiers types étaient livrés avant que le vocabulaire existe, et des intégrations en production font un switch dessus. Les aligner sur les noms Genuka est une rupture majeure, planifiée pour une version majeure ultérieure, pas un effet de bord de ce module. Tout ce qui est nouveau naît déjà dans le bon vocabulaire.

Signature

X-Genuka-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256> où le message signé est ${t}.${corps brut} (lib/webhooks/signature.ts). Lier l'horodatage au MAC est ce qui empêche un rejeu : rejetez tout t hors d'une fenêtre de 5 minutes, et t ne peut pas être modifié sans invalider v1.

Le corps doit être vérifié tel qu'il est reçu. JSON.parse puis JSON.stringify change l'ordre des clés et les espaces : la signature ne correspondra jamais.


4. Journal de livraison et rejeu

GET /api/v1/webhooks/{id}/deliveries renvoie payload, status (pending | success | failed), attempts / maxAttempts, responseStatus, error, nextAttemptAt, deliveredAt. Pagination par curseur (?limit=&cursor=, 50 par défaut, 200 au maximum), filtre ?status=.

Backoff : 1 min → 5 min → 30 min → 2 h → 6 h, soit 6 tentatives avant abandon. Une livraison est unique sur (webhookId, eventKey) : un événement rejoué pour quelque raison que ce soit ne peut pas être livré deux fois.

Rétention : meta.retentionDays dans la réponse. La fenêtre est un droit du plan (Plan.logRetentionDays), avec WEBHOOK_LOG_RETENTION_DAYS comme repli pour un partenaire dont l'abonnement n'est pas résoluble. La purge tourne dans /api/cron/webhooks et ne touche jamais une livraison pending : c'est la file de réessai, pas de l'historique.

POST …/deliveries/{deliveryId}/replay remet la livraison en file avec un budget de tentatives neuf, et répond 202 — le rejeu part après la réponse, parce qu'un récepteur qui vient de revenir peut encore être lent. Une livraison déjà pending est refusée : elle est dans la file, et la remettre ne ferait que réinitialiser le backoff qui protège l'endpoint.


5. Ingestion : idempotence et ordre

Meta redélivre, et n'ordonne pas ce qu'il délivre. Deux règles, toutes deux dans src/webhooks/dedupe.ts, pures et testables sans base de données.

Déduplication

import { dedupeEvents, stableEventKey } from "@genuka/whatsapp/webhooks/dedupe";

const events = dedupeEvents(parseWebhook(body)); // avant toute écriture

stableEventKey est eventKey() — msg:<wamid>, status:<wamid>:<status> — auquel s'ajoute une empreinte du payload pour les genres dont la clé est de faible cardinalité (account, coexistence, unknown sont identifiés par (kind, field, wabaId) seulement). Sans cela, la deuxième alerte de compte d'une WABA serait avalée comme un doublon de la première.

Progression des statuts

shouldAdvanceStatus(current, incoming); // sent < delivered < read ; failed/deleted terminaux
  • un read arrivé avant son delivered ne régresse pas le statut stocké ;
  • rien n'écrase un statut terminal ;
  • un statut terminal s'applique même après un read (un message peut être supprimé après lecture) ;
  • un statut que nous ne savons pas classer n'est jamais écrit — mieux vaut une ligne périmée qu'une ligne corrompue ;
  • nos propres états d'attente (pending, queued) valent zéro : le premier vrai statut passe.

Les colonnes d'horodatage (deliveredAt, readAt…) sont écrites dans tous les cas, même quand le statut ne bouge pas : un delivered arrivé après son read a quand même eu lieu, et perdre l'instant où il a eu lieu serait perdre une donnée irrécupérable.

Réponse immédiate

/api/{partner}/events/ingest accuse réception avant tout traitement (after()). Meta réessaie agressivement et coupe la souscription d'une app qui répond lentement ou mal ; et le dispatcher n'a rien à faire de notre résultat de traitement, puisque chaque événement est déjà durable dans WebhookEvent.


6. Opt-out marketing (conformité)

Non négociable : un message marketing envoyé après un STOP brûle la note qualité du numéro, est facturé, et est illégal dans la plupart des juridictions où opèrent nos partenaires.

Deux sources alimentent la même table ContactPreference :

  1. l'événement user_preferences (stop / resume) — le signal explicite de Meta ;
  2. l'erreur 131050 sur un envoi — Meta nous apprend après coup que le destinataire s'était déjà désabonné.

changedAt porte l'horodatage de Meta, pas le nôtre : ces événements arrivent dans le désordre, et un stop retardataire ne doit pas défaire un resume postérieur. Le filtre changedAt: { lt: … } de l'updateMany fait trancher la base, pas le processus.

À consulter avant tout envoi marketing (lib/webhooks/optout.ts) :

const sendable = await filterOptedOut(companyId, recipients); // liste moins les désabonnés
await assertMarketingAllowed(companyId, waId);                // 403 recipient_opted_out

7. Vérifier une signature côté client

import crypto from "node:crypto";

function verify(secret: string, header: string, rawBody: string): boolean {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2) as [string, string]));
  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(parts.t));
  if (!Number.isFinite(Number(parts.t)) || age > 300) return false;

  const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
  return parts.v1.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
}

Répondez 2xx immédiatement et traitez en asynchrone : nous réessayons six fois, puis nous abandonnons — et une livraison abandonnée n'est rattrapable que par un rejeu manuel.

On this page