@vibedout/whatsapp
v0.1.0
Published
A small WhatsApp Business Cloud API (Meta Graph API) kit for Cloudflare Workers over plain fetch: a client to send text and templates, list templates, mark messages read and look up media, signed-webhook verification (handshake and X-Hub-Signature-256) wi
Maintainers
Readme
@vibedout/whatsapp
The WhatsApp Business Cloud API (Meta's Graph API) for a Cloudflare Worker, over plain fetch and WebCrypto. A small client to send
text and approved templates, list templates, mark messages read and look up media; webhook verification (the handshake and
X-Hub-Signature-256) with normalized message, status and template events, including business-scoped user ids; Mexican number
handling (52 and 521, the wa_id); the 24-hour window; opt-out keywords in Mexican Spanish and English; Meta's error codes as a
typed error family; and a test double.
- Your own Meta app. The token, the app secret and the number are the project's own (a system-user token with
whatsapp_business_messagingandwhatsapp_business_management); nothing here goes through anyone else's account. - Workers-first, no runtime dependencies.
fetchand WebCrypto only; Meta's Node SDK is archived. The shared kits this package is built on are bundled by the build. - ESM only, with types. Every module is its own subpath.
| Import | What |
|---|---|
| @vibedout/whatsapp | everything below except the test helpers |
| @vibedout/whatsapp/client | createWhatsAppClient, whatsappClientFromEnv, bodyParameters, metaLanguageCode |
| @vibedout/whatsapp/webhook | handleWebhookHandshake, verifyWebhookSignature, parseWebhook, handleWhatsAppWebhook |
| @vibedout/whatsapp/numbers | toE164, whatsappMatchKey, sameWhatsAppNumber, recipientFor, isBsuid |
| @vibedout/whatsapp/window | windowOpenUntil, isWindowOpen, assertWindowOpen |
| @vibedout/whatsapp/keywords | detectKeyword, keywordIntentOf |
| @vibedout/whatsapp/errors, /types | WhatsAppError, classifyMetaError; the types |
| @vibedout/whatsapp/testing | createFakeWhatsAppClient, signWebhookBody, signedWebhookRequest, delivery builders |
Sending
import { whatsappClientFromEnv } from "@vibedout/whatsapp";
// Secrets: WHATSAPP_ACCESS_TOKEN. Variables: WHATSAPP_PHONE_NUMBER_ID, WHATSAPP_WABA_ID (for listTemplates), WHATSAPP_GRAPH_VERSION (default v25.0).
const whatsapp = whatsappClientFromEnv(env);
// A template: the only message that can start a conversation or reach someone outside the 24-hour window.
const sent = await whatsapp.sendTemplate({ to: "5215512345678", name: "recordatorio_cita", language: "es-MX", body: ["Ana", "10:00"] });
sent.messageId; // wamid..., delivery is reported later by webhook
sent.waId; // the id WhatsApp resolved the number to: store it and send to it next time
// Free text, inside the window only.
await whatsapp.sendText({ to: sent.waId, body: "Gracias, te esperamos." });
await whatsapp.markRead("wamid.HBgL...");
const { templates, nextCursor } = await whatsapp.listTemplates({ status: "APPROVED", limit: 50 });
const media = await whatsapp.getMedia("1003383421387256"); // metadata and a five-minute url; the bytes are not fetched heretois a phone number with its country code in any spelling (+52 55 1234 5678) or awa_id, sent as digits exactly as given: no country is guessed and521is not rewritten. Normalize before you store a contact (below).recipienttakes a business-scoped user id for a person who has a username and no number; if both are given, Meta usesto.- Template variables:
body: ["Ana", "10:00"]fills{{1}},{{2}};body: { nombre: "Ana" }fills named ones. Header media and buttons go incomponents, as Meta writes them.language: "es-MX"is sent ases_MX. - A new app's test number can send the pre-approved
hello_worldtemplate (en_US) to up to five numbers you have verified in the app dashboard; any other number is refused with131030(recipient_not_allowed). - Every call takes
{ signal, timeoutMs }; the client has a 15-second timeout. Sends are never retried: Meta documents no idempotency key, so a send that fails ambiguously (a timeout, a dropped connection) is reportedretryable: falsewith a note to check before repeating it.retry: { attempts: 3 }repeats reads only. A crash between Meta's 200 and your own write can, rarely, send twice: claim the send in your database first. - The token never appears in an error message, a stack or a
cause.
Errors
Everything thrown is a WhatsAppError with code, retryable, status (HTTP), providerCode (Meta's code), retryAfterMs,
issues and cause. Meta tells apps to build on its error code, so that is what the table is built on.
| Code | Meta's codes | retryable |
|---|---|---|
| window_closed | 131047 (more than 24 hours since the person wrote: send a template) | no |
| recipient_not_allowed | 131030 (not on the test number's allowed list) | no |
| undeliverable | 131026 (not a WhatsApp number, an old client) | no |
| recipient_blocked | 130403 | no |
| delivery_declined | 131049 (Meta chose not to deliver; wait at least a day) | no |
| opted_out | 131050 (the person stopped marketing messages) | no |
| spam_limited | 131048 | no |
| template_invalid | 132000, 132005, 132007, 132012, 132018 | no |
| template_unavailable | 132001, 132015, 132016, 134101, 134102 | no |
| media_error / unsupported_message | 131052, 131053 / 131051 | no |
| account_restricted | 368, 130497, 131031, 131037, 131042, 131045, 131064, 133010, 134011 | no |
| auth_failed | 0, 3, 10, 190, 200-299, 131005 | no |
| rate_limited | 4, 80007, 130429, 131056 | yes (honour retryAfterMs) |
| unavailable | 1, 2, 131000, 131016, 131057, 133004 (Meta's own outage or maintenance) | yes for a read, and for a send Meta answered with one of these codes (it refused the request); no for a send nobody answered |
| invalid_request / not_found | 100, 131008, 131009, 131021, 131062, 135000 / 33 | no |
| timeout, invalid_response, configuration_invalid, validation_failed, webhook_signature_invalid | shared | per the shared rules |
A rate limit and an outage code are repeatable even for a send because Meta refused the request before anything was done. What is not
repeatable is a send that got no answer (a timeout, a dropped connection, a bare 5xx or a gateway page): the message may already be on
its way, so it is retryable: false with a note to check before repeating it. A product that would rather risk a duplicate than lose
a reminder can rethrow such an error as repeatable. A code the table does not know falls back to the HTTP status. The message of an error
ends with Meta's fbtrace_id, which its support asks for.
The webhook
import { D1ReceiptStore, handleWebhookHandshake, handleWhatsAppWebhook, webhookInputFromRequest, WhatsAppError } from "@vibedout/whatsapp";
app.get("/webhooks/whatsapp", (c) => handleWebhookHandshake(c.req.raw, c.env.WHATSAPP_VERIFY_TOKEN)); // 200 + the challenge, or 403
app.post("/webhooks/whatsapp", async (c) => {
try {
await handleWhatsAppWebhook({
input: await webhookInputFromRequest(c.req.raw), // the raw bytes: the signature covers exactly those
appSecret: c.env.WHATSAPP_APP_SECRET,
store: new D1ReceiptStore(c.env.DB), // run receiptsMigrationSql() once
handle: async (event) => {
if (event.kind === "message") await saveInbound(event);
if (event.kind === "status") await moveStatus(event);
},
});
return c.body(null, 200); // processed, duplicate and ignored all stop Meta's retries
} catch (error) {
if (error instanceof WhatsAppError && error.code === "webhook_signature_invalid") return c.body(null, 401);
throw error; // a 5xx makes Meta redeliver for up to seven days
}
});- Verify first.
X-Hub-Signature-256issha256=and the hex HMAC-SHA256 of the exact bytes under your app secret. A re-serialised body fails. The comparison is constant time, and an unsigned request never reaches the receipts or your handler. - Expect repeats and disorder. A delivery carries up to a thousand updates; a failed one is retried for seven days; there is no API to
fetch a missed one.
handleWhatsAppWebhookclaims each event once by itsdedupeKey(a message's id; a status's id and status) and releases the claim if your handler throws. Statuses can arrive out of order: move a message's status by rank, never backwards. - Events.
message(text, button and list replies, media as metadata, location, reaction, unsupported, system),status(sent,delivered,read,failedwith Meta's error; a played voice note isread),template(approved, rejected, paused, disabled) anderror. Webhook fields it does not normalize are named inignoredFields. Media is metadata only: fetch the file with the media id within a week (getMedia, then download with your token). Nothing from a shared contact card is copied. - Receipts. Meta retries a failed delivery for up to seven days, so keep a receipt for at least that long and purge older ones
(
D1ReceiptStore.purgeClaimedBefore(Date.now() - 8 * 86_400_000), from a daily cron); nothing else removes them. - Identity. A person has a
waId(send to it), thephonethe message came from, and since 2026 a business-scopeduserId; a person with a username may have no phone at all. KeepuserIdwhen you have it.
Numbers
A Mexican mobile is +52 and ten digits, but WhatsApp often reports it as 521 and ten digits (Meta's send-messages reference says the
extra prefix for Brazil and Mexico "may be modified by the Cloud API", and that this is standard behaviour). Match contacts on the canonical
form and send to the id WhatsApp gave you:
import { whatsappMatchKey, recipientFor, toE164 } from "@vibedout/whatsapp";
whatsappMatchKey("5215512345678"); // "+525512345678"
whatsappMatchKey("+52 55 1234 5678"); // "+525512345678", the same contact
toE164("044 55 1234 5678"); // "+525512345678" (the old dialling prefixes 01, 044, 045 are read too)
toE164("+1 650 555 1234"); // "+16505551234": other countries pass through
toE164("Order 12345678"); // undefined: text with letters in it is not a number
recipientFor({ waId: "5215512345678", phone: "+525512345678" }); // "5215512345678"The window, and STOP
import { assertWindowOpen, detectKeyword, isWindowOpen, keywordIntentOf, windowOpenUntil } from "@vibedout/whatsapp";
windowOpenUntil(lastInboundAt); // when the 24-hour window closes (null: the person never wrote)
isWindowOpen(lastInboundAt, Date.now()); // free text allowed
assertWindowOpen(lastInboundAt, Date.now()); // throws window_closed before any call
detectKeyword("BAJA"); // "opt_out"; also STOP, CANCELAR, "dar de baja", UNSUBSCRIBE, PARAR ...
detectKeyword("la baja de precios"); // null: the whole message must be the word
keywordIntentOf(event); // the text of a message, or the payload/id of a button or list row; never a button's labelThe match is the whole message after trimming and folding case, accents and punctuation. CANCELAR is on the default list; an app that
takes bookings by WhatsApp may mean "cancel my booking" by it, and can pass its own list. A button counts by its payload, never its
label (a booking message's own "Cancelar" button is not an opt-out): to make a template button an opt-out, send it with the payload
STOP. Revoking consent and sending one confirmation inside the window is the app's job; this package only recognises the word. Opt-in is a person's express act, recorded with its source
and text (Mexico's privacy law asks for consent that is free, specific and informed); a message from a person is not consent to be marketed to.
Testing
@vibedout/whatsapp/testing signs a body the way Meta does (signWebhookBody, signedWebhookRequest), builds deliveries
(inboundTextDelivery, statusDelivery) and has createFakeWhatsAppClient, a client that sends nothing, records every call and refuses
free text outside the window like Meta does. For the Graph API itself use the provider kit's recorded fetch
(@vibedout/provider-kit/testing) and pass it as fetch.
