@genuka/whatsapp
v0.1.1
Published
Typed builders, validation and event normalization for the WhatsApp Business Platform (Cloud API v26.0) — usable against Meta directly or through the Genuka WA API.
Readme
@genuka/whatsapp
Builders typés, validation et normalisation d'événements pour la WhatsApp Business Platform. Calibré sur Graph API v26.0.
Ce n'est pas un client HTTP de plus : c'est la couche qui empêche les 400 évitables, qui rend le parsing des webhooks exhaustif à la compilation, et qui transforme les ~200 codes d'erreur Meta en sept décisions actionnables.
Deux transports, un seul cœur
Le même payload construit part vers Meta par deux routes différentes :
// Côté Genuka WA (serveur) — détient le token Meta
import { MetaTransport, messages } from "@genuka/whatsapp";
const transport = new MetaTransport({ accessToken: process.env.META_TOKEN! });
// Côté client Genuka WA — ne détient qu'une clé API Genuka
import { GenukaTransport, messages } from "@genuka/whatsapp";
const transport = new GenukaTransport({ apiKey: "gwa_..." });Les builders, la validation, la taxonomie d'erreurs et le parsing de webhooks sont identiques
des deux côtés. Un client ne voit jamais de token Meta, ni de PHONE_NUMBER_ID, ni le dispatcher.
Construire un message
import { messages } from "@genuka/whatsapp";
// Numéro normalisé, longueurs vérifiées, payload prêt à envoyer.
const hello = messages.text("+237 6 99 00 11 22", "Bonjour 👋", { callbackData: "msg_42" });
const choice = messages.buttons("237699001122", {
body: "Comment peut-on vous aider ?",
footer: "Genuka",
buttons: [
{ id: "order", title: "Ma commande" },
{ id: "support", title: "Un problème" },
],
});Les erreurs sortent avant le réseau :
messages.buttons(to, { body: "…", buttons: [a, b, c, d] });
// ValidationError: interactive.buttons: must contain at most 3 items (got 4)callbackData est renvoyé tel quel dans le webhook de statut (biz_opaque_callback_data) — c'est
la façon la moins chère de corréler un statut à votre id interne, sans table de mapping wamid.
La fenêtre de service
Envoyer un message libre hors des 24 h échoue en 131047, après facturation de la tentative.
Décider avant coûte un appel de fonction :
import { sendStrategy } from "@genuka/whatsapp";
sendStrategy(contact.lastInboundAt); // "free_form" | "template"Parser un webhook
Meta imbrique tout dans entry[].changes[].value, où le discriminant réel n'est pas un champ mais
la présence de messages ou statuses. Le parser aplatit ça une fois pour toutes :
import { parseWebhook, eventKey } from "@genuka/whatsapp/webhooks";
for (const event of parseWebhook(await request.json())) {
if (await alreadyProcessed(eventKey(event))) continue; // Meta redélivre, et dans le désordre
switch (event.kind) {
case "message": // entrant
case "status": // sent / delivered / read / failed / deleted
case "template": // approbation, qualité, recatégorisation
case "account": // qualité du numéro, limites, ban
case "user_preference": // opt-out marketing — juridiquement contraignant
case "coexistence": // history / echoes / contacts
case "unknown": // jamais perdu, toujours transmis
}
}Le parser ne lève jamais : un payload malformé produit une liste vide. Une exception ici deviendrait un 500, et Meta rejouerait tout le lot.
Vérifier une signature
import { verifySignature, verifyChallenge } from "@genuka/whatsapp/webhooks";
// Le corps BRUT, pas re-sérialisé — sinon la signature ne correspondra jamais.
const raw = await request.text();
if (!(await verifySignature(secret, raw, request.headers.get("x-hub-signature-256")))) {
return new Response("invalid signature", { status: 401 });
}WebCrypto uniquement : tourne sur Node, Bun, Vercel Edge et Workers.
Gérer les erreurs
errorClass porte la décision, pas le code :
import { WhatsAppError } from "@genuka/whatsapp";
try {
await send(payload);
} catch (error) {
if (!(error instanceof WhatsAppError)) throw error;
switch (error.errorClass) {
case "needs_template": return resendAsTemplate(); // 131047
case "recipient_permanent": return markContactUnreachable(); // 131026, 131050
case "recipient_throttled": return requeueLater(); // 131049
case "media": return reuploadAndRetryOnce(); // 131052
case "retryable": return backoff(); // 4, 130429, 5xx
case "config": return alertOperator(); // 190, 133010
case "template": return surfaceToCustomer(); // 132xxx
case "validation":
case "unknown": throw error;
}
}Modules
Le socle est exporté à plat ; chaque module métier est un namespace (les collisions sont réelles :
trois modules définissent légitimement un PlatformType) et un sous-chemin.
| Module | Import | Guide |
|---|---|---|
| Socle — config, limits, validate, errors, transports | @genuka/whatsapp | — |
| Construction de messages + fenêtre 24 h | messages.*, sendStrategy | docs/messages.md |
| Envoi — client, MM Lite, retry, débit | MessageClient, runWithRetry, RateLimiter | docs/messages.md |
| Webhooks — parsing, signature, idempotence, vocabulaire | @genuka/whatsapp/webhooks | docs/webhooks.md |
| Templates — builder, carousel, LTO, coupon, auth, CRUD | @genuka/whatsapp/templates | docs/templates.md |
| Médias — cache par numéro, cycle de vie, upload reprenable | @genuka/whatsapp/media | docs/media.md |
| Flows — Flow JSON, CRUD, endpoint chiffré, flow_token | @genuka/whatsapp/flows | docs/flows.md |
| Management — profil, automatisations, QR, blocklist, santé, analytics | @genuka/whatsapp/management | docs/management.md |
| Coexistence — détection, history, echoes, contacts, offboarding | @genuka/whatsapp/coexistence | docs/coexistence.md |
| Calling API | — | hors périmètre v1 |
Exemples exécutables : examples/. Plan détaillé : docs/WHATSAPP_LIB_EXECUTION_PLAN.md Référence des capacités : docs/WHATSAPP_API_CAPABILITIES.md
Changements cassants de Meta
CHANGELOG-META.md suit ce que Meta casse, pas ce que nous cassons. C'est la partie de ce package qu'on ne trouve nulle part ailleurs : reconstruire « qu'est-ce qui va casser, et quand » à partir du changelog de Meta est un travail manuel que personne ne fait.
Développement
npm run typecheck # tsc --noEmit
npm run test # compile puis node --test
npm run build # dist/ (ESM + .d.ts)Le package est branché en workspace npm sur l'app Next.js. L'app consomme dist/, pas les
sources : le package est en NodeNext, ses imports internes portent l'extension .js, et
Turbopack ne les réécrit pas vers .ts. Consommer dist/ a l'avantage de faire passer l'app par
exactement l'artefact publié. npm run dev et npm run build à la racine compilent la lib d'abord.
Principes
- Fail-fast à la construction. Un titre de bouton de 21 caractères lève une erreur avant le réseau.
- Union discriminée partout. Le
switchsurevent.kindest exhaustif à la compilation. - Zéro dépendance runtime. Isomorphe : Node, Bun, Edge, Workers.
- Aucune hypothèse d'ordre sur les webhooks.
eventKey()fournit l'idempotence. - Version Graph épinglée. Bumper
DEFAULT_GRAPH_VERSIONest une release délibérée, jamais un effet de bord.
