Node has shipped fetch since version 18, which means sending a WhatsApp message needs zero dependencies. Here's the whole thing, working, before anything else:
js
Run it with node send.mjs (or any filename ending in .mjs, or set "type": "module" in your package.json), swap in your own key, session ID and number, and that's a message sent. The res.ok check is the line people skip and regret: fetch doesn't throw on a 4xx or 5xx, it resolves normally with a failure status, so without that check a broken integration looks identical to a working one in your logs.
Before any code: connect a session
Nothing sends until a number is actually connected. Create a session in your WaHttp dashboard, scan the QR code with WhatsApp on the phone you're connecting, and copy the session ID and API key it issues. Those are the two environment variables the snippet above reads. Until the dashboard shows the session as connected, calls will fail with a clear error rather than hanging or doing nothing, so check connection status first if anything above doesn't work.
bash
Build against a wahttp_test_ key and switch to wahttp_live_ only once you're sending to real recipients.
A small client instead of repeating fetch calls
One call is fine for a script. For anything that sends more than once, wrap it:
js
fetch has no built-in timeout, so the AbortController pattern above isn't optional decoration, it's what stops a hung request from quietly stalling your process. Media gets a longer timeout than text for the same reason it does in every other language here: the API fetches the file from your URL before delivering it, and that round trip takes longer than a text send.
Receiving messages: the half most tutorials skip
Sending is one direction. Express is the fastest way to stand up the other half.
bash
js
Run it, expose port 8000 with a tunnel like ngrok while developing locally, and register that public URL as your session's webhook in the dashboard. The order of operations in the handler matters more than the logic inside it: acknowledge with res.sendStatus(200) first, then do the actual work. If you reply before acknowledging and your downstream logic is slow, often because it calls a language model, the webhook will time out and WhatsApp will redeliver the same event while your first attempt is still running. And always check isGroup before replying, or an echo bot will answer every message in every group it belongs to.
Putting it together: a minimal echo bot
The full loop, send and receive, in one file:
js
Message your connected number and it echoes back, no WhatsApp-specific package anywhere in the dependency tree.
The three things that break this in production
This is deliberately the fast version. A few things it skips on purpose, because each deserves more room than a first working example should take:
Phone number formatting. Real input arrives as (415) 555-1234, 415-555-1234 and other shapes the API won't accept as-is. Normalize to E.164 with libphonenumber-js before sending, not after a message silently fails to land.
Rate limiting. Firing a loop at your network's full speed against a customer list is one of the clearest automation signals WhatsApp's detection looks for. Pace sends with randomized delays between them, not a fixed interval.
Retries and backoff. Retry a 429 or 5xx with exponential backoff and respect any Retry-After header. Never retry a 400 or 401, since a malformed payload or a bad key will fail identically every time you resend it.
All three, with full working code, are covered in our combined Node.js and Python guide, which this post deliberately doesn't repeat. If what you need next is webhook signature verification rather than rate limiting specifically, our webhooks deep dive covers that.
What this costs once you're past the trial
Everything above runs on a free trial, capped at 100 messages a day for seven days, no card required. Past that, WaHttp's pricing runs $5 to $35 a month for one to ten sessions with unlimited messages on every tier and no per-message fee layered on top the way Meta's own official Cloud API charges. Our 2026 pricing breakdown has the full comparison against the official platform's rates.
Which endpoint shape you're actually coding against
Everything here assumes a session-based REST API, where a plain-text body can go to anyone at any time, which is why none of this deals with template names or approval states. On Meta's official Cloud API, the same send becomes a template call outside the 24-hour customer service window, with variable arrays instead of a free-text body, a different code path rather than a different vendor wearing the same shape. Settle which one you're actually building against before you write much more code around either.