Send WhatsApp Messages from Node.js and Python: A Complete Guide

Aug 18, 2026  •  5 min read
Send WhatsApp Messages from Node.js and Python: A Complete Guide

Most WhatsApp API tutorials show you one fetch call and declare victory. Then you deploy, and discover that phone numbers arrive in six different formats, media needs different handling, the third message in a loop fails silently, and nobody thought about what happens when someone replies.

This covers the whole path. If you only need the one call, it is in the first section and you can stop there.

The shape of it

A modern WhatsApp API is a plain REST API. Authenticate with a bearer token, POST JSON, get JSON back. No SDK required in any language, which is worth saying because vendor SDKs are the main source of lock-in in this space. A fetch call ports to any provider in ten minutes. A proprietary client class does not.

Every example below hits the same endpoint with the same payload. Only the syntax changes.

curl

Start here to verify your credentials before writing any code.


bash

curl -X POST https://app.wahttp.com/api/sessions/$SESSION_ID/messages \
-H "Authorization: Bearer $WAHTTP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "+14155551234",
"type": "text",
"body": "Hello from curl"
}'

A successful response:


json

{ "status": "success", "messageId": "msg_abc123" }

Hold on to messageId. It is how you correlate this send with the delivery events that arrive later.

If this fails, no amount of application code will fix it. A 401 means the key is wrong or revoked. A 404 usually means the session ID does not exist. An error about the session not being connected means the QR link has dropped and needs re-scanning.

Node.js

No dependencies needed. fetch has been built into Node since v18.


js

const API_KEY = process.env.WAHTTP_API_KEY;
const SESSION_ID = process.env.WAHTTP_SESSION_ID;
const BASE = 'https://app.wahttp.com/api';

async function sendMessage(to, body) {
const res = await fetch(`${BASE}/sessions/${SESSION_ID}/messages`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ to, type: 'text', body }),
});

if (!res.ok) {
const detail = await res.text();
throw new Error(`WhatsApp send failed (${res.status}): ${detail}`);
}

return res.json();
}

await sendMessage('+14155551234', 'Your order has shipped.');

The res.ok check is the line people leave out. fetch does not throw on a 4xx or 5xx, it resolves normally with a failure status. Without that check you get silent failures that look like successes for weeks.

Python


python

import os
import requests

API_KEY = os.environ["WAHTTP_API_KEY"]
SESSION_ID = os.environ["WAHTTP_SESSION_ID"]
BASE = "https://app.wahttp.com/api"

session = requests.Session()
session.headers.update({
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
})

def send_message(to: str, body: str) -> dict:
response = session.post(
f"{BASE}/sessions/{SESSION_ID}/messages",
json={"to": to, "type": "text", "body": body},
timeout=15,
)
response.raise_for_status()
return response.json()

send_message("+14155551234", "Your order has shipped.")

Two details worth keeping. requests.Session() reuses the TCP connection, which meaningfully speeds up any loop. And always pass a timeout, because requests waits indefinitely by default and a hung request will eventually take a worker with it.

For async workloads, httpx is a drop-in with the same ergonomics:


python

import httpx

async def send_message(to: str, body: str) -> dict:
async with httpx.AsyncClient(timeout=15) as client:
r = await client.post(
f"{BASE}/sessions/{SESSION_ID}/messages",
headers={"Authorization": f"Bearer {API_KEY}"},
json={"to": to, "type": "text", "body": body},
)
r.raise_for_status()
return r.json()

Phone numbers will break your integration

Not maybe. The formats that arrive from real users, spreadsheets and CRM exports include (415) 555-1234, 415-555-1234, 00 1 415 555 1234 and +1 415 555 1234. Some of those will be silently rejected and some will go to the wrong person.

Normalise to E.164 before sending: a leading +, country code, no spaces, no punctuation. Do not write the regex yourself.

Node:


js

import { parsePhoneNumberFromString } from 'libphonenumber-js';

function toE164(input, defaultCountry = 'US') {
const parsed = parsePhoneNumberFromString(input, defaultCountry);
if (!parsed?.isValid()) throw new Error(`Invalid number: ${input}`);
return parsed.number;
}

Python:


python

import phonenumbers

def to_e164(raw: str, region: str = "US") -> str:
parsed = phonenumbers.parse(raw, region)
if not phonenumbers.is_valid_number(parsed):
raise ValueError(f"Invalid number: {raw}")
return phonenumbers.format_number(
parsed, phonenumbers.PhoneNumberFormat.E164
)

The defaultCountry argument only applies to numbers without a country code. For any list spanning multiple countries, store the region alongside the number rather than guessing.

Sending media

Same endpoint, different type, plus a URL or file reference:


python

send = lambda payload: session.post(
f"{BASE}/sessions/{SESSION_ID}/messages", json=payload, timeout=30
).json()

send({
"to": "+14155551234",
"type": "image",
"url": "https://example.com/receipt.png",
"caption": "Your receipt",
})

send({
"to": "+14155551234",
"type": "document",
"url": "https://example.com/invoice-4521.pdf",
"filename": "invoice-4521.pdf",
})

Supported types are text, image, video, audio and document. Two things to watch: the URL must be publicly reachable, since the API fetches it rather than receiving your bytes, and media sends take longer, so raise your timeout above the 15 seconds that works fine for text.

Error handling that survives contact with production

The difference between a script and a service is what happens on the unhappy path.


python

import time
import requests

RETRYABLE = {429, 500, 502, 503, 504}

def send_with_retry(to: str, body: str, attempts: int = 4) -> dict:
for attempt in range(attempts):
try:
response = session.post(
f"{BASE}/sessions/{SESSION_ID}/messages",
json={"to": to, "type": "text", "body": body},
timeout=15,
)
if response.status_code in RETRYABLE:
wait = min(2 ** attempt, 30)
if response.status_code == 429:
wait = int(response.headers.get("Retry-After", wait))
time.sleep(wait)
continue
response.raise_for_status()
return response.json()
except requests.Timeout:
time.sleep(min(2 ** attempt, 30))
raise RuntimeError(f"Giving up on {to} after {attempts} attempts")

The principle: retry transient failures, never retry a 4xx. A 400 is a malformed payload and a 401 is a bad key, and retrying either just makes the same mistake more often. Honour Retry-After when a 429 includes it, since guessing your own backoff against a documented one is how you get rate limited harder.

For anything user-facing, add a circuit breaker. If ten consecutive sends fail, stop and alert rather than grinding through a queue of 4,000 doomed requests.

Rate limiting, and why your loop is dangerous

This is the part that separates a working integration from a banned number.


python

for customer in customers: # don't do this
send_message(customer.phone, message)

That loop sends as fast as your network allows. Machine-paced bursts are one of the strongest automation signals WhatsApp's detection looks for, and volume is not what triggers it. Rhythm is.

At minimum, pace yourself and randomise:


python

import random, time

for customer in customers:
send_message(customer.phone, personalise(message, customer))
time.sleep(random.uniform(4, 12)) # not a fixed interval

The randomisation matters as much as the delay. A perfectly regular gap is itself a fingerprint, which is why a fixed sleep(5) is worse than a variable one.

Better still, let the API queue it for you. If your provider paces sends server-side, you can fire requests normally and let the queue handle rhythm, which is more reliable than client-side sleeps that reset every time your process restarts. The safety and queuing approach explains what that looks like in practice.

Whatever you do, warm new numbers up gradually and only message people who opted in. No amount of client-side cleverness compensates for a cold list.

Sending to groups

Groups use the same endpoint with a group identifier instead of a phone number:


js

await sendMessage('[email protected]', 'Standup in 5 minutes.');

The exact identifier format comes from your provider, usually via a groups endpoint or from the from field on an inbound group message. Two cautions. Group sends are far more visible than direct messages, so a mistake reaches everyone at once. And if you run any kind of autoresponder, filter isGroup before replying, or your bot will answer every message in every group it belongs to.

A bulk send that will not get you banned

Putting the pieces together, this is roughly the shape of a safe broadcast to an opted-in list:


python

import random, time, logging

def broadcast(recipients, template, max_per_run=150):
sent, failed = [], []

for i, person in enumerate(recipients[:max_per_run]):
try:
phone = to_e164(person["phone"], person.get("region", "US"))
except ValueError as exc:
logging.warning("Skipping: %s", exc)
failed.append((person, str(exc)))
continue

if already_sent(person["id"], template["id"]):
continue # idempotency guard

try:
result = send_with_retry(phone, template["body"].format(**person))
record_sent(person["id"], template["id"], result["messageId"])
sent.append(result["messageId"])
except Exception as exc:
logging.error("Failed for %s: %s", phone, exc)
failed.append((person, str(exc)))
if len(failed) > 10:
raise RuntimeError("Too many failures, stopping")

time.sleep(random.uniform(20, 45))

return sent, failed

Five things are doing work there. Numbers are validated before sending rather than failing mid-run. A daily ceiling caps exposure. An idempotency check means re-running after a crash does not double-message anyone. Failures are collected rather than aborting the whole job, but a run of them stops it. And the delay is randomised and generous, because a broadcast is exactly the traffic shape enforcement looks for.

If your list is longer than the ceiling, spread it across days rather than raising the limit. If it is much longer, spread it across numbers by function too.

Configuration and keys

Nothing above hardcodes a credential, and that is deliberate.

Keep the key in an environment variable or a Secrets Manager, never in the repository. Use a test key in development and a live key in production, which is what the wahttp_test_ and wahttp_live_ prefixes exist for. Scope each key to the minimum role it needs, so a reporting script running with a read-only key cannot send anything even if it is compromised. And generate a separate key per integration, because revoking one should not take down the other four.

If a key does leak, revoke it in the dashboard first and rotate afterwards. The order matters.

Receiving replies

Sending is half a conversation. To receive, expose an endpoint and register it as a webhook.

Node with Express:


js

app.post('/webhooks/whatsapp', express.json(), async (req, res) => {
res.sendStatus(200); // acknowledge first

const { event, data } = req.body;
if (event !== 'message.received') return;
if (data.isGroup) return; // don't autoreply in groups

await sendMessage(data.from, `You said: ${data.body}`);
});

Python with FastAPI:


python

from fastapi import FastAPI, BackgroundTasks, Request

app = FastAPI()

@app.post("/webhooks/whatsapp")
async def webhook(request: Request, background: BackgroundTasks):
payload = await request.json()
if payload.get("event") == "message.received":
background.add_task(handle_message, payload["data"])
return {"ok": True}

Acknowledge fast and process in the background. If your handler calls a language model before responding, you will exceed the provider's timeout and receive the same event again while the first is still running. Verify the signature too, since an unverified endpoint lets anyone forge inbound messages into your system.

Which API you are coding against

Everything above assumes a session-based REST API, where you send arbitrary text to anyone at any time. On Meta's official Cloud API the same task requires a pre-approved template outside the 24-hour customer service window, which changes your code structure substantially: you send template names and variable arrays rather than message bodies. If you have not settled on a route yet, the comparison covers what each costs you in flexibility.

Endpoint paths and field names do shift between versions, so check the API reference rather than trusting any tutorial, including this one, six months from now.

Common Questions Asked by Users

How do I send a WhatsApp message using Python? POST JSON to your provider's message endpoint with an Authorization: Bearer header, using requests or httpx. No WhatsApp-specific library is required.

Can I send WhatsApp messages from Node.js without a library? Yes. Node 18 and later include fetch, so a standard POST with a JSON body is all you need. Remember to check res.ok, because fetch does not throw on error statuses.

What is the best library for WhatsApp automation? For a hosted REST API, none: use your language's standard HTTP client. If you are self-hosting the connection, Baileys (Node) is the most widely used, at the cost of maintaining sessions and reconnection logic yourself.

How do I send an image or PDF? Use the same endpoint with type set to image or document and a publicly reachable url. Raise your request timeout, because media takes longer than text.

Why do my messages fail intermittently? Usually a dropped session, malformed phone numbers, or rate limiting. Check session status first, normalise numbers to E.164, and add exponential backoff for 429 and 5xx responses.

How many messages can I send per second? Technically more than you should. Pace sends to roughly one a minute during bulk runs with randomised gaps, or let a server-side queue handle it. Bursts are what get numbers restricted.

How do I receive replies in my code? Register a webhook endpoint, subscribe to the incoming message event, return 2xx immediately, and process the payload in a background job.

WhatsApp