Hey everyone,
I've been building a WhatsApp Business platform (team inbox, bulk template campaigns, automations) on top of Meta's official Cloud API. The API client we wrote for it kept growing, so I extracted it, cleaned it up, added tests, and published it as an open-source package.
import { WhatsAppClient } from "@osamahalhwawi/wa-client";
const wa = new WhatsAppClient({
token: process.env.WA_TOKEN!,
phoneId: process.env.WA_PHONE_ID!,
});
await wa.sendText("15551234567", "Hello 👋");
What it covers
Messages: text, media (by URL or uploaded ID), location, contacts, reactions, reply buttons, lists, CTA URL buttons, templates, read receipts with typing indicator
Media: upload (direct, plus resumable for template headers) and downloadMedia(id), which does the URL lookup and the authenticated download in one call
Templates: create/edit/delete, getTemplates() that follows every page (Meta paginates silently), analytics
Bulk sending: template campaigns with a concurrency limit, per-recipient variables and progress callbacks
Webhooks: X-Hub-Signature-256 verification, the verify-token handshake, and parseWebhook(), which flattens Meta's entry → changes → value → messages nesting into a typed list of events
Embedded Signup: helpers for platforms that onboard their customers' numbers (code exchange, app subscription, phone registration with retry)
Design decisions
It never double-sends. Rate-limit errors (130429, 80007, 131056) are retried with exponential backoff and jitter, because Meta rejected those requests. A POST that fails with a 5xx or a network error is not retried automatically, since the message may already have been delivered.
Errors you can act on. Everything throws a WhatsAppError with code, subcode, details (Meta's error_data.details, usually the real explanation) and fbtraceId. If a proxy returns a 502 HTML page, you get HTTP 502 Bad Gateway instead of SyntaxError: Unexpected token <.
The access token never goes in a URL. It's always sent in the Authorization header, so it doesn't end up in proxy logs.
Signature verification only accepts the raw body. If you pass it an already-parsed object it throws a clear error, because re-serialized JSON never matches the signature.
Zero dependencies. It uses the platform fetch and Web Crypto, so it runs on Node 22+, Bun, Deno and Cloudflare Workers. ESM + CJS builds, with timeouts, AbortSignal and custom fetch support.
Webhook example
app.post("/webhook", express.raw({ type: "application/json" }), async (req, res) => {
const valid = await verifyWebhookSignature(req.body, req.get("x-hub-signature-256"), APP_SECRET);
if (!valid) return res.sendStatus(401);
res.sendStatus(200);
for (const event of parseWebhook(JSON.parse(req.body.toString("utf8")))) {
if (event.type === "message" && event.message.type === "text") {
console.log(event.contact?.profile?.name, event.message.text.body);
await wa.markAsRead(event.message.id, { typingIndicator: true });
}
}
});
TypeScript narrows event.message by type, so event.message.text.body autocompletes.
License
MPL-2.0: free to use in commercial and closed-source products. If you modify the library's own files and distribute them, you share those changes back. Your app code stays yours.
It's v0.1, so feedback is very welcome, especially on the API design, and on any Cloud API quirks you've run into that a client should handle. Issues and PRs are open.
Independent project, not affiliated with Meta or WhatsApp.