Three code panels side by side showing identical WhatsApp Cloud API send message code in Node.js, Python, and PHP on dark terminal background
Sections: API fundamentals · Text message · Template with variables · Image & document · Interactive buttons · Location · Bulk sending with rate limiting · Error handling & retry · Full class wrappers (Node / Python / PHP)

API fundamentals: the one endpoint everything goes through

Every outbound WhatsApp message — regardless of type — goes through one HTTP POST endpoint. The message type and content are in the JSON body. That's it. No WebSockets, no streaming, no long-polling.

Three values you need before any code runs:

  • Phone Number ID — a numeric ID for your WhatsApp Business number (looks like 123456789012345). Different from your actual phone number.
  • Access Token — a permanent System User token with whatsapp_business_messaging permission. Never use a temporary user token in production.
  • Recipient phone number — in E.164 format: country code + number, no spaces, no dashes, no plus sign in the JSON value (e.g. 15550001234 for a US number, or with + is also accepted: +15550001234).

A successful send returns HTTP 200 with a body like:

Message types: session vs template

Before picking a message type, you need to know whether you're inside a service window. The session window (24h after a customer messages you, or 72h after a Click-to-WhatsApp ad click) determines which message types are free and unrestricted.

text
Plain text. Markdown-like WhatsApp formatting supported. Up to 4,096 chars.
Free in window
template
Pre-approved format with variable slots. Required to initiate contact outside window.
Approval required
image
JPEG or PNG. Max 5MB. URL or media ID. Optional caption.
Free in window
document
PDF, DOCX, XLSX. Max 100MB. URL or media ID. Filename shown.
Free in window
video
MP4 or 3GPP. Max 16MB. URL or media ID. Optional caption.
Free in window
interactive
Button replies (max 3) or list messages (max 10 items). Customer taps to reply.
Free in window
location
Send lat/lng with optional name and address. Renders on map in WhatsApp.
Free in window
audio
MP3 or AAC. Max 16MB. URL or media ID. No caption supported.
Free in window

Send a text message

The simplest send. Works inside the 24-hour service window at no Meta charge (within the 1,000 free service conversations/month quota). Outside the window, use a template instead.

Node.js (native fetch)
sendText.js
async function sendText(to, message) { const url = `https://graph.facebook.com/v21.0/${process.env.WA_PHONE_ID}/messages`; const res = await fetch(url, { method: 'POST', headers: { 'Authorization': `Bearer ${process.env.WA_TOKEN}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ messaging_product: 'whatsapp', recipient_type: 'individual', to, type: 'text', text: { body: message, preview_url: false }, }), }); if (!res.ok) throw new Error(`WhatsApp error: ${await res.text()}`); return (await res.json()).messages[0].id; // wamid — store for status tracking } // Usage const messageId = await sendText('+15550001234', 'Hello from WhatsApp Cloud API! 👋');
Python (requests)
send_text.py
import os, requests def send_text(to: str, message: str) -> str: url = f"https://graph.facebook.com/v21.0/{os.environ['WA_PHONE_ID']}/messages" res = requests.post(url, headers={ "Authorization": f"Bearer {os.environ['WA_TOKEN']}", "Content-Type": "application/json", }, json={ "messaging_product": "whatsapp", "recipient_type": "individual", "to": to, "type": "text", "text": { "body": message, "preview_url": False }, } ) res.raise_for_status() return res.json()["messages"][0]["id"] # Usage message_id = send_text("+15550001234", "Hello from WhatsApp Cloud API! 👋")
PHP (cURL)
send_text.php
function sendText(string $to, string $message): string { $url = "https://graph.facebook.com/v21.0/" . getenv('WA_PHONE_ID') . "/messages"; $body = [ 'messaging_product' => 'whatsapp', 'recipient_type' => 'individual', 'to' => $to, 'type' => 'text', 'text' => ['body' => $message, 'preview_url' => false], ]; $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode($body), CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv('WA_TOKEN'), 'Content-Type: application/json', ], ]); $response = json_decode(curl_exec($ch), true); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($status !== 200) { throw new RuntimeException('WhatsApp error: ' . json_encode($response)); } return $response['messages'][0]['id']; // store for status tracking } // Usage $messageId = sendText('+15550001234', 'Hello from WhatsApp Cloud API! 👋');

Send a template message with variables

Templates are required for the first message in a new conversation, or any message sent outside the 24-hour service window. Templates are defined in WhatsApp Manager, approved by Meta, and can include {{1}} {{2}} variable placeholders. Your API call fills those variables at send time.

Template approval timing: Simple utility templates (order confirmations, shipping updates) typically approve in 1–24 hours. Marketing templates take 1–5 business days. You can check approval status via the message_template_status_update webhook subscription or in WhatsApp Manager. Templates with rejected status cannot be sent — sending a non-APPROVED template returns a 400 error.
Node.js
sendTemplate.js
async function sendTemplate(to, templateName, languageCode, variables = []) { const url = `https://graph.facebook.com/v21.0/${process.env.WA_PHONE_ID}/messages`; const body = { messaging_product: 'whatsapp', to, type: 'template', template: { name: templateName, language: { code: languageCode }, // e.g. 'en', 'pt_BR', 'de' components: variables.length ? [{ type: 'body', parameters: variables.map(v => ({ type: 'text', text: v })), }] : [], }, }; const res = await fetch(url, { method: 'POST', headers: { 'Authorization': `Bearer ${process.env.WA_TOKEN}`, 'Content-Type': 'application/json', }, body: JSON.stringify(body), }); if (!res.ok) throw new Error(`Template send failed: ${await res.text()}`); return (await res.json()).messages[0].id; } // Example: template "order_confirmation" with body text {{1}}="order #1234" {{2}}="2h" await sendTemplate( '+15550001234', 'order_confirmation', 'en', ['order #1234', '2 hours'] );
Python
send_template.py
def send_template(to: str, template_name: str, language_code: str, variables: list = []) -> str: url = f"https://graph.facebook.com/v21.0/{os.environ['WA_PHONE_ID']}/messages" components = [] if variables: components.append({ "type": "body", "parameters": [{ "type": "text", "text": v } for v in variables], }) res = requests.post(url, headers={ "Authorization": f"Bearer {os.environ['WA_TOKEN']}", "Content-Type": "application/json", }, json={ "messaging_product": "whatsapp", "to": to, "type": "template", "template": { "name": template_name, "language": { "code": language_code }, "components": components, }, } ) res.raise_for_status() return res.json()["messages"][0]["id"] # Usage send_template("+15550001234", "order_confirmation", "en", ["order #1234", "2 hours"])
PHP
send_template.php
function sendTemplate(string $to, string $templateName, string $langCode, array $variables = []): string { $url = "https://graph.facebook.com/v21.0/" . getenv('WA_PHONE_ID') . "/messages"; $components = []; if (!empty($variables)) { $components[] = [ 'type' => 'body', 'parameters' => array_map( fn($v) => ['type' => 'text', 'text' => $v], $variables ), ]; } $body = [ 'messaging_product' => 'whatsapp', 'to' => $to, 'type' => 'template', 'template' => [ 'name' => $templateName, 'language' => ['code' => $langCode], 'components' => $components, ], ]; $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode($body), CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv('WA_TOKEN'), 'Content-Type: application/json', ], ]); $res = json_decode(curl_exec($ch), true); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($status !== 200) throw new RuntimeException(json_encode($res)); return $res['messages'][0]['id']; }

Send an image or document

Media messages reference a file either by URL (the file must be publicly accessible via HTTPS) or by a Media ID (obtained after uploading the file to Meta's media endpoint). URL is simpler for most use cases — the Cloud API fetches and caches it. Media IDs are better for frequently reused assets like product images or branded PDFs.

Node.js
sendMedia.js
// Send image by public URL async function sendImage(to, imageUrl, caption = '') { return sendMedia(to, 'image', { link: imageUrl, caption }); } // Send document by public URL async function sendDocument(to, docUrl, filename, caption = '') { return sendMedia(to, 'document', { link: docUrl, filename, caption }); } // Generic media sender — works for image, video, audio, document async function sendMedia(to, type, mediaObj) { const res = await fetch( `https://graph.facebook.com/v21.0/${process.env.WA_PHONE_ID}/messages`, { method: 'POST', headers: { 'Authorization': `Bearer ${process.env.WA_TOKEN}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ messaging_product: 'whatsapp', to, type, [type]: mediaObj, // { link, caption } OR { id, caption } }), } ); if (!res.ok) throw new Error(`Media send failed: ${await res.text()}`); return (await res.json()).messages[0].id; } // Usage await sendImage('+15550001234', 'https://yourdomain.com/promo.jpg', 'Check out our new product! 🚀'); await sendDocument('+15550001234', 'https://yourdomain.com/invoice.pdf', 'invoice-2026.pdf');
Python
send_media.py
def send_media(to: str, media_type: str, media_obj: dict) -> str: url = f"https://graph.facebook.com/v21.0/{os.environ['WA_PHONE_ID']}/messages" res = requests.post(url, headers={ "Authorization": f"Bearer {os.environ['WA_TOKEN']}", "Content-Type": "application/json", }, json={ "messaging_product": "whatsapp", "to": to, "type": media_type, media_type: media_obj, # e.g. {"link": url, "caption": "..."} } ) res.raise_for_status() return res.json()["messages"][0]["id"] def send_image(to: str, url: str, caption: str = "") -> str: return send_media(to, "image", { "link": url, "caption": caption }) def send_document(to: str, url: str, filename: str, caption: str = "") -> str: return send_media(to, "document", { "link": url, "filename": filename, "caption": caption })
PHP
send_media.php
function sendMedia(string $to, string $type, array $mediaObj): string { $url = "https://graph.facebook.com/v21.0/" . getenv('WA_PHONE_ID') . "/messages"; $body = [ 'messaging_product' => 'whatsapp', 'to' => $to, 'type' => $type, $type => $mediaObj, // ['link' => url, 'caption' => '...'] ]; $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode($body), CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv('WA_TOKEN'), 'Content-Type: application/json', ], ]); $res = json_decode(curl_exec($ch), true); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($status !== 200) throw new RuntimeException(json_encode($res)); return $res['messages'][0]['id']; } // Usage sendMedia('+15550001234', 'image', ['link' => 'https://yourdomain.com/img.jpg', 'caption' => 'New product!']); sendMedia('+15550001234', 'document', ['link' => 'https://yourdomain.com/invoice.pdf', 'filename' => 'invoice.pdf']);

Send an interactive message with buttons

Interactive button messages let customers reply with a tap instead of typing. Maximum 3 buttons per message. Button titles are limited to 20 characters. When a customer taps a button, your webhook receives an interactive type message with the button's id value in msg.interactive.button_reply.id.

Node.js
sendButtons.js
async function sendButtons(to, bodyText, buttons) { // buttons: [{ id: 'btn_yes', label: 'Yes' }, { id: 'btn_no', label: 'No' }] const res = await fetch( `https://graph.facebook.com/v21.0/${process.env.WA_PHONE_ID}/messages`, { method: 'POST', headers: { 'Authorization': `Bearer ${process.env.WA_TOKEN}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ messaging_product: 'whatsapp', to, type: 'interactive', interactive: { type: 'button', body: { text: bodyText }, action: { buttons: buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.label.slice(0, 20) }, // 20 char limit })), }, }, }), } ); if (!res.ok) throw new Error(`Button send failed: ${await res.text()}`); return (await res.json()).messages[0].id; } // Usage await sendButtons( '+15550001234', 'Would you like a callback from our team?', [ { id: 'yes_callback', label: 'Yes, call me!' }, { id: 'no_thanks', label: 'No thanks' }, ] );
Python
send_buttons.py
def send_buttons(to: str, body_text: str, buttons: list) -> str: # buttons: [{"id": "btn_yes", "label": "Yes"}, ...] max 3 buttons url = f"https://graph.facebook.com/v21.0/{os.environ['WA_PHONE_ID']}/messages" res = requests.post(url, headers={ "Authorization": f"Bearer {os.environ['WA_TOKEN']}", "Content-Type": "application/json", }, json={ "messaging_product": "whatsapp", "to": to, "type": "interactive", "interactive": { "type": "button", "body": { "text": body_text }, "action": { "buttons": [ { "type": "reply", "reply": { "id": b["id"], "title": b["label"][:20] }} for b in buttons ] }, }, } ) res.raise_for_status() return res.json()["messages"][0]["id"]
PHP
send_buttons.php
function sendButtons(string $to, string $bodyText, array $buttons): string { $url = "https://graph.facebook.com/v21.0/" . getenv('WA_PHONE_ID') . "/messages"; $btns = array_map(fn($b) => [ 'type' => 'reply', 'reply' => ['id' => $b['id'], 'title' => mb_substr($b['label'], 0, 20)], ], $buttons); $body = [ 'messaging_product' => 'whatsapp', 'to' => $to, 'type' => 'interactive', 'interactive' => [ 'type' => 'button', 'body' => ['text' => $bodyText], 'action' => ['buttons' => $btns], ], ]; $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode($body), CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv('WA_TOKEN'), 'Content-Type: application/json', ], ]); $res = json_decode(curl_exec($ch), true); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($status !== 200) throw new RuntimeException(json_encode($res)); return $res['messages'][0]['id']; }

Send a location

Location messages render as a map pin in WhatsApp with an optional name and address below it. Customers can tap to open in their maps app. No URL or media ID needed — just coordinates.

Bulk sending: rate limits and the right pattern

The WhatsApp Cloud API allows 80 messages per second per phone number by default. At that rate, sending 10,000 messages takes about 2 minutes. Without rate limiting, a naive loop exhausts the limit almost immediately and you start getting 429 errors mid-campaign. The right pattern: add a fixed delay between sends and implement exponential backoff on 429s.

Node.js — Bulk sender with rate limiting
bulkSend.js
const DELAY_MS = 20; // 20ms between sends = 50 msg/s — safely under 80 msg/s limit const MAX_RETRIES = 4; async function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } async function sendWithRetry(to, message, attempt = 0) { try { return await sendText(to, message); } catch (err) { const is429 = err.message.includes('429'); if (!is429 || attempt >= MAX_RETRIES) throw err; const waitMs = Math.min(1000 * 2 ** attempt + Math.random() * 500, 60000); console.warn(`Rate limited — waiting ${Math.round(waitMs)}ms (attempt ${attempt + 1})`); await sleep(waitMs); return sendWithRetry(to, message, attempt + 1); } } async function bulkSend(contacts, messageBody) { const results = { sent: 0, failed: [] }; for (const { phone, name } of contacts) { try { const personalised = messageBody.replace('{{name}}', name); await sendWithRetry(phone, personalised); results.sent++; } catch (err) { results.failed.push({ phone, error: err.message }); } await sleep(DELAY_MS); // rate limit spacing } console.log(`Sent: ${results.sent}, Failed: ${results.failed.length}`); return results; } // Usage await bulkSend( [{ phone: '+15550001234', name: 'Alice' }, { phone: '+15550005678', name: 'Bob' }], 'Hi {{name}}, your order is ready! 🎉' );

Error handling: what every status code means

HTTP StatusCommon causeFix
200 OKMessage accepted by Meta — not yet deliveredStore the returned wamid and listen for delivery status webhooks
400 Bad RequestInvalid JSON, missing required field, template not approved, template variable count mismatchCheck the error response body — Meta returns a detailed error.message explaining the exact field or constraint that failed
401 UnauthorizedInvalid or expired access token; missing Authorization headerRegenerate a permanent System User token. Never use temporary user tokens in production — they expire after 60 days
404 Not FoundWrong Phone Number ID in the URL; number not registered on Cloud APIVerify the Phone Number ID in Meta Developer Dashboard → WhatsApp → Phone Numbers. Confirm the number is registered and verified.
429 Too Many RequestsExceeded 80 msg/s rate limit, or API call rate limitImplement exponential backoff with jitter. Add 15–20ms delay between sends in bulk operations. Do not retry immediately.
460 (Meta internal)Recipient's number not registered on WhatsAppThe phone number is not a WhatsApp user. Remove from your list. This is not a deliverable number.
131026Message undeliverable — recipient WhatsApp account restrictionsRecipient may have blocked your number or their account is restricted. Log and skip.
500 / 503Meta infrastructure outage or temporary errorRetry with exponential backoff. Check metastatus.com for active incidents. Do not retry more than 3 times.

Complete WhatsApp client class (Node.js)

The functions above packaged into a reusable class with built-in retry logic, consistent error handling, and environment-based configuration:

Node.js
WhatsAppClient.js
class WhatsAppClient { constructor({ phoneId = process.env.WA_PHONE_ID, token = process.env.WA_TOKEN, version = 'v21.0', } = {}) { this.baseUrl = `https://graph.facebook.com/${version}/${phoneId}/messages`; this.headers = { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', }; } async #send(payload) { const res = await fetch(this.baseUrl, { method: 'POST', headers: this.headers, body: JSON.stringify({ messaging_product: 'whatsapp', ...payload }), }); if (!res.ok) { const err = await res.json(); throw Object.assign(new Error(err?.error?.message ?? 'WhatsApp API error'), { code: err?.error?.code, status: res.status, }); } return (await res.json()).messages[0].id; } text(to, body) { return this.#send({ to, type: 'text', text: { body } }); } image(to, link, caption = '') { return this.#send({ to, type: 'image', image: { link, caption } }); } document(to, link, filename, caption = '') { return this.#send({ to, type: 'document', document: { link, filename, caption } }); } location(to, lat, lng, name = '', address = '') { return this.#send({ to, type: 'location', location: { latitude: lat, longitude: lng, name, address } }); } buttons(to, bodyText, buttons) { return this.#send({ to, type: 'interactive', interactive: { type: 'button', body: { text: bodyText }, action: { buttons: buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.label.slice(0, 20) } })) }, }}); } template(to, name, lang, variables = []) { return this.#send({ to, type: 'template', template: { name, language: { code: lang }, components: variables.length ? [{ type: 'body', parameters: variables.map(v => ({ type: 'text', text: v })) }] : [], }}); } } // Usage const wa = new WhatsAppClient(); await wa.text('+15550001234', 'Hello! 👋'); await wa.template('+15550001234', 'order_shipped', 'en', ['#1234']); await wa.buttons('+15550001234', 'Rate your experience:', [{ id: 'great', label: 'Great! 🌟' }, { id: 'poor', label: 'Could be better' }]); module.exports = WhatsAppClient;

The other half: receiving replies with SocialHook

Sending messages is only half the picture. When your customer replies, the WhatsApp Cloud API fires a webhook to your server — but you need a publicly reachable HTTPS endpoint to receive it, plus HMAC-SHA256 signature verification, nested payload extraction, and retry handling.

SocialHook handles the entire inbound layer. Connect your WhatsApp number to SocialHook, paste your server's URL as the destination, and every customer reply arrives as a normalized JSON event — verified, extracted from Meta's nested envelope, and forwarded to your endpoint in under 50ms. The same flat format works for Facebook Messenger and Instagram DMs too.

The complete setup: your server uses the WhatsAppClient above for outbound, SocialHook delivers inbound events to your webhook handler. Two directions, one flat $50/month. See the full inbound webhook guide or start with the 5-minute quickstart.

Common questions

How do I send a WhatsApp message using the Cloud API?
POST to https://graph.facebook.com/v21.0/{PHONE_NUMBER_ID}/messages with your Authorization: Bearer {ACCESS_TOKEN} header and a JSON body with messaging_product: "whatsapp", to: "+E.164_number", type: "text", and text: { body: "message" }. Full code for Node.js, Python, and PHP is in the text message section above.
What is the WhatsApp Phone Number ID and where do I find it?
The Phone Number ID is a numeric identifier for your specific WhatsApp Business number on the Cloud API — it's different from the actual phone number. Find it in the Meta Developer Dashboard → WhatsApp → Phone Numbers. It looks like a 15-16 digit number. Use it in the URL: graph.facebook.com/v21.0/{PHONE_NUMBER_ID}/messages. One phone number has one ID that never changes.
What is the difference between a session message and a template message?
A session message (text, image, buttons, location, document) can only be sent within the 24-hour service window that opens when a customer messages you first. These are free within the monthly quota. A template message is a Meta-approved format required for initiating new conversations or sending outside the service window. Templates must be approved (1–24 hours for utility, up to 5 days for marketing) before you can send them.
What happens when I get a 429 from the WhatsApp Cloud API?
You've hit the rate limit (80 msg/s default). Implement exponential backoff: wait 1000 * 2^attempt + random(500) ms before retrying, capped at 60 seconds. Add jitter (the random component) to prevent synchronized retries from multiple workers. For bulk campaigns, add a fixed 15–20ms delay between sends to stay safely below the limit rather than hitting it and retrying.
How do I send a WhatsApp template with variables in Python?
Set "type": "template" and include a components array with a body component containing parameters — one object per variable in your template. Each parameter is {"type": "text", "text": "variable_value"}. They map sequentially to {{1}}, {{2}}, {{3}} in your template. Full Python code is in the template message section above.
How do I receive WhatsApp message replies from customers?
Customer replies arrive via webhook — Meta fires an HTTP POST to your registered endpoint when any message event occurs. You need a publicly reachable HTTPS URL, HMAC-SHA256 signature verification, and nested payload parsing. SocialHook handles all of this automatically and delivers clean JSON to your endpoint in under 50ms. See the complete setup in our inbound webhook guide.

You can send. Now
receive replies too.

Your outbound code is ready. Connect SocialHook for inbound — every customer reply arrives as clean JSON at your webhook endpoint. No HMAC boilerplate, no nested payload parsing, no retry infrastructure to build.

No credit card required · $50/month after trial · Cancel anytime