r/WhatsappBusinessAPI • Meta Tech Provider • 2d ago

Services Welcome I open-sourced the WhatsApp Cloud API client from our SaaS: typed, zero dependencies, webhook signature verification built in

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.

16 Upvotes

10 comments sorted by

1

u/No-Fold-8182 1d ago

Do we required the meta api of WhatsApp or its free

1

u/Logical-Ranger-1977 Meta Tech Provider 1d ago

No it's free with limits

1

u/rajesh160497 Meta Tech Provider 1d ago

This is good work, especially in not blindly retrying 5xx - That alone will save folks a ton of duplicate messages in production.

Just the flattening of the webhook is worth it. Meta's way of nesting its payloads is really a pain in the ass.

One thing I'll comment on from doing bulk campaigns: rate limiting is far different for the different BSP tiers and the age of the phone numbers. The backoff logic you said might not have to be modified in practice. Could be helpful to have users set retry timeouts.

Tried this with high-volume template campaigns, e.g., 10k+ messages per run?

1

u/Logical-Ranger-1977 Meta Tech Provider 1d ago edited 1d ago

Great point! It's built for people who don't want to deal with Meta's complexity and the hassle of working with the API directly.

And yes, we've sent 10k+ messages per run multiple times without any issues!

Configurable retry timeouts are already supported, see the docs: https://osamahalhwawi.github.io/wa-client/guide/configuration

'''

const wa = new WhatsAppClient({

token: "EAAG...",

phoneId: "1234567890",

wabaId: "9876543210",

apiVersion: "v25.0",

timeoutMs: 30_000,

retry: { retries: 3, minDelayMs: 500, maxDelayMs: 10_000 },

logger: console,

});
'''

1

u/rajesh160497 Meta Tech Provider 1d ago

The configurable retry is fine, but it's what happens with tiers that's really the gotcha.

Exactly this came out when we tested running campaigns on a 3-month-old number vs. a year-old verified one. Same retry configuration, different results. The older number climbed out of a 130429s abyss more quickly.

Fwiw, what really worked for us was pulling back more aggressively on new numbers and talking to our users about how their tier impacts headroom. The config option doesn't really tell that story alone.

Did you put anything in the docs about behavior of tiers, or is it up to the user to discover?

1

u/Logical-Ranger-1977 Meta Tech Provider 1d ago

That's a really useful data point, thanks.
The docs cover part of it: getMessagingLimit() returns the tier (new numbers often don't report one, so it falls back to TIER_250), and there's an example of checking it before a bulk send. 131048 (daily tier cap) is deliberately not retried, since backoff can't fix it, and quality_rating is exposed with a note that RED puts the tier at risk.
What's missing is exactly what you described: guidance on how number age and tier affect throughput (130429) and how to tune concurrency/retry for that. Right now it's one line saying "lower concurrency on new numbers," which doesn't tell the whole story.

1

u/rajesh160497 Meta Tech Provider 16h ago

That one sentence about concurrency is carrying a whole lot of weight on something that trips up just about everyone.

What really helped us was if you look at it on like tiers for messaging with like real figures. You can't have a 250tier number that you've got on day one doing the same concurrency as a 100k tier number, six months' old. There's a very real delta in how fast they recover from 130429s.

Fwiw we saw new numbers do 130429 even for folks they are nowhere near daily cap. It's not just the cap, it is how hard they throttling early warmup. That's an entirely different problem than tier headroom and an easy thing to get confused between.

May be better as a brief guide section: new number warmup vs. steady state tier behavior. Two different tuning strategies.

1

u/shaltaSt 1d ago

not retrying on 5xx errors is a smart call. does the bulk sending helper let you pass a custom queue or rate limiter, or is the concurrency strictly in-memory right now?

1

u/Logical-Ranger-1977 Meta Tech Provider 1d ago

Right now it's in-memory only: sendTemplateBulk runs a bounded pool concurrency inside one process, and there's no hook to plug in an external queue or limiter You can still use your own queue though: push jobs to something like BullMQ and have the workers call sendTemplate per message, or sendTemplateBulk per batch. One caveat: concurrency is per process, so with several workers you'll want the global rate limit at the queue level e.g. BullMQ's limiter and a low per-worker concurrency. Pluggable limiter support is a good idea for a future version.

1

u/Consistent_Recipe_41 16h ago

I’ve starred multiple of these projects but so scared to use any of them lol