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 rejouerChamps 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 |
|---|---|
| Conversation | message.received · message.sent · message.delivered · message.read · message.failed · message.deleted |
| Templates | template.status_changed · template.quality_changed · template.category_changed · template.components_changed |
| Consentement | user_preference.stopped · user_preference.resumed |
| Compte & numéro | account.alert · account.updated · account.review_completed · account.capability_changed · account.security_changed · number.quality_changed · number.name_changed |
| Flows | flow.response_received · flow.status_changed |
| Coexistence | coexistence.history_received · coexistence.contacts_synced · coexistence.message_echoed · coexistence.offboarded · coexistence.reconnected |
| Fourre-tout | unknown.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.receivedavec 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
type | field | data |
|---|---|---|
inbound_message, message_status, template_status, account_update, phone_quality | messages, message_template_status_update, account_update, phone_number_quality_update | l'objet Meta brut |
tout le reste (user_preference.*, template.quality_changed, flow.*, unknown.received…) | le champ Meta correspondant | le 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 écriturestableEventKey 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
readarrivé avant sondeliveredne 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 :
- l'événement
user_preferences(stop/resume) — le signal explicite de Meta ; - 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_out7. 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.