TypeScript library
@genuka/whatsapp — typed builders, validation and event normalization.
@genuka/whatsapp is not another HTTP client. It is the layer that prevents avoidable 400s, makes
webhook handling exhaustive at compile time, and turns Meta's ~200 error codes into seven
actionable decisions.
The library works standalone against Meta's Cloud API, with no Genuka account. It ships under the MIT license.
Installation
npm install @genuka/whatsappBuilding a message
Lengths, cardinalities and phone number format are checked before the network call.
import { messages } from "@genuka/whatsapp";
const choice = messages.buttons("+237 6 99 00 11 22", {
body: "How can we help?",
buttons: [
{ id: "order", title: "My order" },
{ id: "support", title: "A problem" },
],
});A construction error is thrown immediately, with the offending field path:
ValidationError: interactive.buttons: must contain at most 3 items (got 4)The 24-hour window
import { sendStrategy } from "@genuka/whatsapp";
sendStrategy(contact.lastInboundAt); // "free_form" | "template"A function call, rather than a 131047 discovered after the attempt was already billed.
Handling a webhook
Meta nests everything in entry[].changes[].value, where the real discriminator is not a field
but the presence of messages or statuses. The library flattens that once and for all.
import { parseWebhook, eventKey } from "@genuka/whatsapp/webhooks";
for (const event of parseWebhook(await request.json())) {
if (await alreadyProcessed(eventKey(event))) continue;
switch (event.kind) {
case "message": // inbound
case "status": // sent / delivered / read / failed / deleted
case "template": // approval, quality, recategorization
case "account": // number quality, limits, suspension
case "user_preference": // marketing opt-out
case "coexistence": // history, echoes, contacts
case "unknown": // never lost, always forwarded
}
}The parser never throws: a malformed payload yields an empty list. An exception here would become a 500, and Meta would replay the whole batch.
Deciding what to do with an error
errorClass carries the decision, not the numeric 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 markUnreachable(); // 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;
}
}Two transports, one core
The same built payload can travel two routes, with the same builders, validation and error taxonomy:
// With a Genuka WA key — no Meta token involved
import { GenukaTransport } from "@genuka/whatsapp";
const transport = new GenukaTransport({ apiKey: process.env.GENUKA_WA_API_KEY! });
// Straight to Meta, if you hold your own credentials
import { MetaTransport } from "@genuka/whatsapp";
const transport = new MetaTransport({ accessToken: process.env.META_TOKEN! });Full reference
Every module has its own guide, sourced from the package itself — they live next to the code they describe, so they do not drift:
Sending messages
Templates
Media
Webhooks
Flows
Management
Coexistence
Graph API version
The library pins a Graph API version explicitly rather than following "latest". A silent version
bump is the best way to discover a breaking change in production: changing
DEFAULT_GRAPH_VERSION is a deliberate release.