How do I send WhatsApp API messages programmatically?
Send WhatsApp API messages programmatically via Cloud API — HTTP requests, templates, session messages, error handling, retry logic, and production patterns for Node, Python, and PHP. POST JSON to https://graph.facebook.com/v21.0/{PHONE_NUMBER_ID}/messages with Bearer token, E.164 recipient phone, and a template or text payload.
Last updated: 6 July 2026
Need to send WhatsApp API messages programmatically from your app, backend, or script? This developer guide covers the WhatsApp Cloud API endpoint, authentication, template payloads, session messages, interactive messages, error handling, and retry logic — the same patterns used by Twilio, 360dialog, and Ominiflow under the hood.
Written for backend engineers shipping production integrations — not hello-world tutorials. You'll see idempotent send patterns, rate-limit backoff, template variable pitfalls, and when to call Graph API directly vs route through a BSP.
Start here if you're in a hurry
- Endpoint:
POST https://graph.facebook.com/v21.0/{PHONE_NUMBER_ID}/messages - Auth:
Authorization: Bearer {ACCESS_TOKEN} - Business-initiated messages require approved templates
- Phone format: E.164 without + (e.g.
919876543210) - Return value includes
messages[0].id— store for deduplication and status webhooks - Implement exponential backoff on error 130429 (rate limit)
- Full architecture: integration guide
Prerequisites
- WhatsApp Business Account and Phone Number ID from Meta
- Permanent or long-lived access token (not short-lived dev token in production)
- Approved message template (utility, marketing, or authentication)
- Webhook URL configured for inbound messages and delivery statuses
- WABA subscribed to your app via
POST /{WABA_ID}/subscribed_apps
New to the platform? Start with WhatsApp Business API India onboarding on Ominiflow. Checklist: onboarding checklist.
Send a template message (HTTP POST)
curl -X POST \
"https://graph.facebook.com/v21.0/PHONE_NUMBER_ID/messages" \
-H "Authorization: Bearer ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"messaging_product": "whatsapp",
"recipient_type": "individual",
"to": "919876543210",
"type": "template",
"template": {
"name": "order_update",
"language": { "code": "en" },
"components": [{
"type": "body",
"parameters": [
{ "type": "text", "text": "ORD-12345" },
{ "type": "text", "text": "18 Jul 2026" }
]
}]
}
}'Successful response:
{
"messaging_product": "whatsapp",
"contacts": [{ "input": "919876543210", "wa_id": "919876543210" }],
"messages": [{ "id": "wamid.HBgNOTE..." }]
}Replace order_update with your exact approved template name. Parameter count must match Meta approval — error #132000 when mismatched. Template guide: template message API.
Template components — header, body, buttons
Templates with image header and URL button need additional components:
{
"template": {
"name": "order_shipped",
"language": { "code": "en" },
"components": [
{
"type": "header",
"parameters": [
{ "type": "image", "image": { "link": "https://cdn.example.com/product.jpg" } }
]
},
{
"type": "body",
"parameters": [
{ "type": "text", "text": "Priya" },
{ "type": "text", "text": "ORD-88421" }
]
},
{
"type": "button",
"sub_type": "url",
"index": "0",
"parameters": [
{ "type": "text", "text": "track/88421" }
]
}
]
}
}Send a session message (within 24 hours)
After a customer messages you, reply with free-form text inside the 24-hour customer service window:
{
"messaging_product": "whatsapp",
"to": "919876543210",
"type": "text",
"text": { "body": "Thanks for your order! Tracking: https://track.example/88421" }
}Outside the 24-hour window, error 131047 (re-engagement required) — you must send an approved template instead.
Interactive messages (buttons and lists)
{
"messaging_product": "whatsapp",
"to": "919876543210",
"type": "interactive",
"interactive": {
"type": "button",
"body": { "text": "Confirm your COD order #ORD-88421 for ₹1,299?" },
"action": {
"buttons": [
{ "type": "reply", "reply": { "id": "confirm_yes", "title": "YES" } },
{ "type": "reply", "reply": { "id": "confirm_no", "title": "CANCEL" } }
]
}
}
}COD flow: COD confirmation India
Production code — Node.js with retry
const PHONE_NUMBER_ID = process.env.WA_PHONE_ID;
const ACCESS_TOKEN = process.env.WA_ACCESS_TOKEN;
const API_URL = `https://graph.facebook.com/v21.0/${PHONE_NUMBER_ID}/messages`;
async function sendTemplate(to, templateName, bodyParams, retries = 3) {
const payload = {
messaging_product: 'whatsapp',
to,
type: 'template',
template: {
name: templateName,
language: { code: 'en' },
components: [{
type: 'body',
parameters: bodyParams.map(text => ({ type: 'text', text })),
}],
},
};
for (let attempt = 0; attempt < retries; attempt++) {
const res = await fetch(API_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${ACCESS_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
const data = await res.json();
if (res.ok) {
return { messageId: data.messages[0].id, raw: data };
}
const code = data?.error?.code;
if (code === 130429) {
const delay = Math.pow(2, attempt) * 1000 + Math.random() * 500;
await new Promise(r => setTimeout(r, delay));
continue;
}
throw new Error(`WA send failed: ${code} ${data?.error?.message}`);
}
throw new Error('WA send failed after retries (rate limit)');
}Python with error handling
import time
import requests
def send_template(to: str, template_name: str, params: list[str], max_retries: int = 3):
url = f"https://graph.facebook.com/v21.0/{PHONE_NUMBER_ID}/messages"
headers = {"Authorization": f"Bearer {ACCESS_TOKEN}"}
payload = {
"messaging_product": "whatsapp",
"to": to,
"type": "template",
"template": {
"name": template_name,
"language": {"code": "en"},
"components": [{
"type": "body",
"parameters": [{"type": "text", "text": p} for p in params],
}],
},
}
for attempt in range(max_retries):
r = requests.post(url, json=payload, headers=headers, timeout=10)
data = r.json()
if r.ok:
return data["messages"][0]["id"]
code = data.get("error", {}).get("code")
if code == 130429:
time.sleep(2 ** attempt + 0.5)
continue
raise RuntimeError(f"WA error {code}: {data.get('error', {}).get('message')}")
raise RuntimeError("Rate limit exceeded after retries")PHP with idempotency key
function sendWhatsAppTemplate(
string $to,
string $templateName,
array $bodyParams,
string $idempotencyKey
): array {
// Check DB: if idempotencyKey exists, return cached message_id
if ($cached = getCachedSend($idempotencyKey)) {
return $cached;
}
$payload = [
'messaging_product' => 'whatsapp',
'to' => $to,
'type' => 'template',
'template' => [
'name' => $templateName,
'language' => ['code' => 'en'],
'components' => [[
'type' => 'body',
'parameters' => array_map(
fn($t) => ['type' => 'text', 'text' => $t],
$bodyParams
),
]],
],
];
$ch = curl_init("https://graph.facebook.com/v21.0/" . PHONE_NUMBER_ID . "/messages");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . ACCESS_TOKEN,
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
]);
$response = json_decode(curl_exec($ch), true);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode >= 200 && $httpCode < 300) {
cacheSend($idempotencyKey, $response);
return $response;
}
throw new RuntimeException(json_encode($response['error'] ?? $response));
}Use order ID + event type as idempotency key — prevents duplicate sends when your webhook or queue retries.
Common API errors and fixes
| Code | Meaning | Fix |
|---|---|---|
| 131026 | Template not found or not approved | Check exact template name and language code |
| 132000 | Template param count mismatch | 3 placeholders in template, 2 in payload |
| 131047 | Outside 24h window | Send approved template to re-open |
| 130429 | Rate limit | Exponential backoff; throttle bulk sends |
| 131031 | Account locked | Quality review — pause marketing |
| 100 | Invalid parameter | Check E.164 format, remove + prefix |
More: common API mistakes
Retry logic — production rules
- Retry: 130429 (rate limit), 5xx from Graph API, network timeouts
- Do not retry: 131026, 132000, 131047, 100 — fix payload first
- Backoff: exponential with jitter —
2^attempt * 1000ms + random(500ms) - Idempotency: store
message.idkeyed by business event ID - Dead letter queue: after 3 failures, alert ops — don't infinite loop
Production tip: Log template name + recipient hash (not full phone) + error code on every failure. You'll debug Diwali incidents at 2 AM with this, not with "it didn't work."
Receive messages and statuses
- Configure webhook URL in Meta App Dashboard or Ominiflow
- Verify GET challenge with
hub.verify_token - Parse POST payload for
messagesandstatusesarrays - Verify
X-Hub-Signature-256on every POST - Return HTTP 200 in under 5 seconds; queue heavy work
Deep dive: webhook integration guide
Direct API vs BSP
| Approach | You own | Best for |
|---|---|---|
| Direct Graph API | Tokens, webhooks, inbox, templates, rate limits | Product companies with backend team |
| Ominiflow | Business logic; BSP handles infra + inbox + bots | Teams wanting API power without building everything |
| Twilio / 360dialog | SDK + API; less inbox/bot tooling | Developer-only messaging layer |
Send messages via Ominiflow
Get API credentials, approved templates, and webhook setup in one platform. REST API mirrors Graph patterns with inbox and bot tooling on top.
Related: integration guide · OTP templates · API pricing
FAQ
How do I send a WhatsApp API message programmatically? expand_more
POST JSON to graph.facebook.com/v21.0/{PHONE_NUMBER_ID}/messages with Bearer token, recipient in E.164 without +, and either a template (business-initiated) or text (within 24h session).
Can I send WhatsApp messages without a template? expand_more
Only when replying inside the 24-hour window after the user messaged you first. Otherwise you need an approved template.
What is the WhatsApp API send message endpoint? expand_more
POST /v21.0/{phone-number-id}/messages on graph.facebook.com with messaging_product set to whatsapp.
How do I handle rate limits? expand_more
Implement exponential backoff on error 130429. Throttle bulk sends. Don't retry validation errors (132000, 131026).
Should I use idempotency keys? expand_more
Yes — key on business event ID (order_id + event_type) to prevent duplicate messages when queues or webhooks retry.
Is Twilio WhatsApp API different from Meta? expand_more
Twilio wraps Meta's API with their SDK. Underlying rules (templates, opt-in, 24h window) are identical.
How do I know if a message was delivered? expand_more
Subscribe to statuses webhooks — sent, delivered, read, failed. Store message.id from send response to correlate. Last updated July 2026. Developer guide for sending WhatsApp API messages programmatically — verified against Meta Graph API v21.0 documentation.