Install
openclaw skills install whatsapp-cloud-api-referenceUse when implementing WhatsApp messaging via Meta Cloud API, or diagnosing failures like message not delivered, template rejected, webhook issues, phone not registered, token errors, rate limiting, 24-hour window violations, quality rating drops, or setup mistakes on the WhatsApp Business API.
openclaw skills install whatsapp-cloud-api-referenceThe Meta WhatsApp Cloud API is the official, fully hosted path for programmatic WhatsApp messaging. No server management needed. First 1,000 service conversations per month are free.
Key rules:
Conversation flow:
App → user: MUST be a template (always, for first contact)
User → app: reply opens a 24-hour free-form window
App → user: free-form text allowed within that 24h window
[24h passes with no user reply]
App → user: MUST use a template again to re-engage
whatsapp_business_messaging + whatsapp_business_management// npm install axios
const axios = require('axios');
async function sendMessage(phoneNumber, text) {
// phoneNumber: E.164 without +, e.g. "14155551234"
const res = await axios.post(
`https://graph.facebook.com/v21.0/${process.env.WA_PHONE_NUMBER_ID}/messages`,
{
messaging_product: 'whatsapp',
recipient_type: 'individual',
to: phoneNumber,
type: 'text',
text: { preview_url: false, body: text }
},
{ headers: { Authorization: `Bearer ${process.env.WA_ACCESS_TOKEN}` } }
);
return res.data;
}
# pip install requests
import requests, os
def send_message(phone: str, text: str) -> dict:
r = requests.post(
f"https://graph.facebook.com/v21.0/{os.environ['WA_PHONE_NUMBER_ID']}/messages",
headers={"Authorization": f"Bearer {os.environ['WA_ACCESS_TOKEN']}"},
json={
"messaging_product": "whatsapp",
"recipient_type": "individual",
"to": phone, # E.164 without +
"type": "text",
"text": {"preview_url": False, "body": text}
}
)
r.raise_for_status()
return r.json()
curl -X POST "https://graph.facebook.com/v21.0/YOUR_PHONE_ID/messages" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"messaging_product":"whatsapp","to":"14155551234","type":"text","text":{"body":"Hello"}}'
const payload = {
messaging_product: 'whatsapp',
to: phoneNumber,
type: 'template',
template: {
name: 'hello_world', // your approved template name
language: { code: 'en_US' },
components: [{
type: 'body',
parameters: [
{ type: 'text', text: 'John' }, // fills {{1}}
{ type: 'text', text: 'Order #4521' } // fills {{2}}
]
}]
}
};
Image:
const payload = {
messaging_product: 'whatsapp',
to: phoneNumber,
type: 'image',
image: {
link: 'https://your-domain.com/image.jpg' // must be publicly accessible HTTPS
}
};
Document:
const payload = {
messaging_product: 'whatsapp',
to: phoneNumber,
type: 'document',
document: {
link: 'https://your-domain.com/file.pdf',
caption: 'Invoice' // optional
}
};
Audio:
const payload = {
messaging_product: 'whatsapp',
to: phoneNumber,
type: 'audio',
audio: {
link: 'https://your-domain.com/audio.mp3'
}
};
Video:
const payload = {
messaging_product: 'whatsapp',
to: phoneNumber,
type: 'video',
video: {
link: 'https://your-domain.com/video.mp4',
caption: 'Demo video' // optional
}
};
Important constraints:
// GET — Meta calls this to verify your endpoint
app.get('/webhook', (req, res) => {
const { 'hub.mode': mode, 'hub.verify_token': token, 'hub.challenge': challenge } = req.query;
if (mode === 'subscribe' && token === process.env.VERIFY_TOKEN)
return res.status(200).send(challenge); // raw string only — NOT JSON
res.sendStatus(403);
});
// POST — CRITICAL: return 200 IMMEDIATELY, process async
app.post('/webhook', express.json(), (req, res) => {
// Return 200 immediately so Meta doesn't retry
res.sendStatus(200);
// Process webhook payload asynchronously (don't block)
setImmediate(() => {
processWebhookAsync(req.body).catch(err => {
logger.error(`Webhook processing failed: ${err.message}`);
});
});
});
async function processWebhookAsync(body) {
body.entry?.forEach(entry =>
entry.changes?.forEach(change => {
const value = change.value;
// Incoming messages
if (value.messages) {
value.messages.forEach(msg => {
console.log(`Message from ${msg.from}: ${msg.text?.body}`);
handleMessage(msg);
});
}
// Delivery status
if (value.statuses) {
value.statuses.forEach(status => {
console.log(`Message ${status.id} status: ${status.status}`);
handleDeliveryStatus(status);
});
}
})
);
}
Meta sends status updates via webhook when a message is delivered, read, or fails:
{
"object": "whatsapp_business_account",
"entry": [{
"changes": [{
"value": {
"statuses": [{
"id": "wamid.xxx", // Message ID from your send response
"status": "delivered", // "sent" | "delivered" | "read" | "failed"
"timestamp": "1675262308",
"recipient_id": "14155551234",
"type": "message"
}]
}
}]
}]
}
Status values:
sent — Message reached Meta serversdelivered — Message delivered to user's deviceread — User opened the messagefailed — Delivery failed (permanent)// When you send, store the message ID
const sendResult = await sendMessage(phone, text);
const messageId = sendResult.messages[0].id;
// Log it for webhook tracking
db.messages.insert({
message_id: messageId,
recipient: phone,
sent_at: Date.now(),
status: 'sent',
body: text
});
// When webhook arrives with status update, match by message_id
function handleDeliveryStatus(statusUpdate) {
const { id, status, recipient_id } = statusUpdate;
// Update your database
db.messages.updateOne(
{ message_id: id },
{ status: status, updated_at: Date.now() }
);
// Handle delivery failures
if (status === 'failed') {
logger.error(`Message ${id} failed to deliver to ${recipient_id}`);
// Retry logic here
}
}
Your WhatsApp Business Account (WABA) has a quality rating that affects your sending ability:
| Rating | Impact | Recovery |
|---|---|---|
| GREEN | Full functionality, no restrictions | Maintain this (stay green) |
| YELLOW | Slight rate limit reduction, monitor closely | Improve within 7 days or drops to RED |
| RED | Severe restrictions, may lose messaging access | Contact Meta Support |
curl "https://graph.facebook.com/v21.0/PHONE_NUMBER_ID?fields=quality_rating" \
-H "Authorization: Bearer YOUR_TOKEN"
# Response: { "quality_rating": "GREEN" }
Note: WhatsApp Business API does NOT support group messaging directly. You can only send to individual recipients (1:1 conversations).
If you need group functionality:
All examples use v21.0 (current as of February 2026). Meta deprecates API versions annually.
# List all available versions
curl "https://graph.facebook.com/versions" \
-H "Authorization: Bearer YOUR_TOKEN"
# Current version recommendations
# - v21.0 (current, recommended)
# - v20.0 (previous, will deprecate in 6 months)
// Store version in config, not hardcoded
const API_VERSION = process.env.WHATSAPP_API_VERSION || 'v21.0';
const url = `https://graph.facebook.com/${API_VERSION}/${PHONE_NUMBER_ID}/messages`;
// When Meta deprecates a version, update .env:
// WHATSAPP_API_VERSION=v22.0
Always test before upgrading — make requests against the new version in your dev environment first.
| Constraint | Limit |
|---|---|
| Text body max length | 4,096 characters |
| Link preview | Enabled by default, disable with preview_url: false |
| Carriage returns / newlines | Supported (use \n) |
| Type | Items | Character Limit |
|---|---|---|
| Button | 1-3 | Title: 20 chars |
| List | 1-10 | Title: 24 chars per row |
| Limit | Value |
|---|---|
| Default throughput | 80 messages/second |
| Burst capacity | 1,000 messages/second (request increase) |
| Requests per minute | 60 API calls/minute |
Create template in Meta Business Manager
↓
Submit for review (human review by Meta)
↓
Status: PENDING (24-72 hours typical)
↓
Status: APPROVED (can now use in messages)
OR
Status: REJECTED (reason provided in dashboard)
| Issue | Why Rejected |
|---|---|
| Variable format | Must use {{1}}, {{2}} format |
| Template starts/ends with variable | Must have text before first variable |
| URL shorteners | Use full domain URLs only |
| Placeholder quality | Placeholder values must be realistic examples |
| Sensitive data request | Never ask for SSN, card numbers, passwords |
| Unclear purpose | Purpose field must clearly state intent |
| Warm language in utility | Use formal wording; warmth triggers "marketing" category (costs more) |
| Duplicate template | Name/wording too similar to existing template |
Check rejection reason in Meta Business Manager → Business Support → Rejected Template Messages.
Symptom: Registration fails with error 133010, status stays PENDING
Cause: Phone number is already active on a personal WhatsApp account
Fix:
1. Remove phone from personal WhatsApp (go to Settings → Devices → Remove phone)
2. Wait 24 hours
3. Re-run registration API call
Symptom: API returns "Invalid parameters" for phone operations
How to tell them apart:
PHONE_NUMBER_ID: 120######## (11-12 digits, starts with 120)
WABA_ID: ######### (9-10 digits, higher number)
# Correct endpoint
POST /v21.0/PHONE_NUMBER_ID/messages ✅
# Wrong endpoint
POST /v21.0/WABA_ID/messages ❌
Symptom: Token debug shows valid, but messaging fails with error 3/10
# Token is "valid" but missing scopes
curl "https://graph.facebook.com/debug_token?input_token=TOKEN&access_token=TOKEN"
# Response: { "is_valid": true, "scopes": ["manage_pages"] } ← NO whatsapp_business_messaging
# Fix: Regenerate System User token with correct permissions
Symptom: Webhook verification fails silently in Meta dashboard
Wrong:
return res.json({ challenge }); // ❌ returns JSON
Correct:
return res.status(200).send(challenge); // ✅ returns raw string
Symptom: Webhooks never arrive (silent failure since 2025 Meta UI change)
Fix:
curl -X POST \
"https://graph.facebook.com/v21.0/WABA_ID/subscribed_apps" \
-H "Authorization: Bearer YOUR_SYSTEM_USER_TOKEN"
Symptom: Error 132001 "Template Unavailable"
Cause: Template still in PENDING status, not yet APPROVED
Fix: Check status in Meta Business Manager → Message Templates → wait for APPROVED status
Symptom: Error 131009 when trying to send
How to verify:
# Check which phone numbers are in your WABA
curl "https://graph.facebook.com/v21.0/WABA_ID/phone_numbers?fields=id,display_phone_number" \
-H "Authorization: Bearer YOUR_TOKEN"
Symptom: API returns 200, message ID issued, but user never receives it
Root cause: Only templates allowed as first message to any number
Fix: Always use a template for first contact
Check the status field via API. Each status blocks different operations:
| Status | Meaning | Action |
|---|---|---|
| PENDING | Number is registered but not verified | Set up 2FA (either manual or API), then run register call |
| REGISTERED | Number is verified and ready | Check code_verification_status — should be VERIFIED |
| FLAGGED | Account or number under review for policy violation | Contact Meta Support |
| BANNED | Number permanently disabled | Contact Meta Support |
| Code | Name | Cause | Fix |
|---|---|---|---|
| 190 | Token Expired | User token (24h lifetime) used in production | Switch to System User token; debug at developers.facebook.com/tools/debug/accesstoken |
| 3 / 10 | Permission Denied | Token missing required scopes | Regenerate System User token with whatsapp_business_messaging + whatsapp_business_management |
| 100 | Invalid Parameter | Misspelled field or wrong value | Check request body against API docs; verify phone number format (E.164, no +) |
| 130429 | Rate Limit (MPS) | Exceeded 80 messages/sec default | Add send queue + exponential backoff (see below) |
| 131047 | 24h Window Expired | > 24h since customer last replied | Replace free-form text with a pre-approved template message |
| 131026 | Undeliverable | Recipient blocked you, no WhatsApp, or outdated app | Verify recipient number; confirm they have WhatsApp installed and accepted Meta terms |
| 131048 | Spam Rate Limit | Messages flagged as spam | Check Quality Rating in WhatsApp Manager; review message content and opt-in practices |
| 131056 | Pair Rate Limit | Too many messages to same recipient too fast | Wait before retrying the same number |
| 131009 | Invalid Parameter Value | Phone number not in WABA, or wrong parameter | Verify number is registered in your WABA under Phone Numbers |
| 131021 | Same Sender/Recipient | from and to are the same number | Use a different recipient |
| 131031 | Account Locked | Policy violation or wrong 2-step PIN | Contact Meta Support |
| 132001 | Template Unavailable | Wrong template name, wrong language code, or not yet approved | Check WhatsApp Manager → Message Templates for exact name, language, and status |
| 133010 | Phone Not Registered | Sender number not registered in Cloud API | Run the registration API call (see below) |
| 368 | Policy Violation | Account restricted | Contact Meta Support |
| 1 / 2 | API Service Error | Meta outage or server error | Check metastatus.com; retry with exponential backoff |
Diagnose first:
curl "https://graph.facebook.com/debug_token?input_token=YOUR_TOKEN&access_token=YOUR_APP_ID|YOUR_APP_SECRET"
Check is_valid, expires_at (0 = never expires), and scopes in the response.
Fix — create a non-expiring System User token:
whatsapp_business_messaging + whatsapp_business_management permissionsStep 1 — check phone number status:
curl "https://graph.facebook.com/v21.0/PHONE_NUMBER_ID?fields=verified_name,code_verification_status,quality_rating,status" \
-H "Authorization: Bearer YOUR_TOKEN"
If status is PENDING, the number is waiting for verification. Continue below.
Step 2 — set up Two-Step Verification (choose ONE method):
Option A: Manual setup (easy)
Option B: API setup (easier for automation)
# Set 2FA PIN via API
curl -X POST \
"https://graph.facebook.com/v21.0/PHONE_NUMBER_ID/two_step_verification" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"pin": "123456"}' # any 6-digit PIN you choose
Step 3 — register the number with the PIN:
curl -X POST \
"https://graph.facebook.com/v21.0/PHONE_NUMBER_ID/register" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"messaging_product": "whatsapp", "pin": "123456"}' # same PIN from step 2
Step 4 — wait 5 minutes, then verify registration:
curl "https://graph.facebook.com/v21.0/PHONE_NUMBER_ID?fields=verified_name,code_verification_status,quality_rating,status" \
-H "Authorization: Bearer YOUR_TOKEN"
Look for status: REGISTERED and code_verification_status: VERIFIED.
Diagnose in this order:
hub.challenge string value only, not JSONhub.verify_token Meta sends must match exactly what you set in the dashboard (case-sensitive)# Subscribe your WABA to your App (run once)
curl -X POST \
"https://graph.facebook.com/v21.0/WABA_ID/subscribed_apps" \
-H "Authorization: Bearer YOUR_SYSTEM_USER_TOKEN"
# Verify the subscription exists
curl "https://graph.facebook.com/v21.0/WABA_ID/subscribed_apps" \
-H "Authorization: Bearer YOUR_SYSTEM_USER_TOKEN"
Local development — expose localhost with a tunnel:
# Using ngrok
ngrok http 3000
# Use the https:// URL ngrok provides as your webhook callback URL in Meta
Default limit is 80 messages per second. Fix with a queue and exponential backoff:
// npm install limiter
const { RateLimiter } = require('limiter');
const limiter = new RateLimiter({ tokensPerInterval: 70, interval: 'second' });
async function sendWithRetry(phone, message, attempt = 0) {
await limiter.removeTokens(1);
try {
return await sendMessage(phone, message);
} catch (err) {
const code = err.response?.data?.error?.code;
if (code === 130429 && attempt < 5) {
const delay = Math.pow(2, attempt) * 1000; // 1s, 2s, 4s, 8s, 16s
await new Promise(r => setTimeout(r, delay));
return sendWithRetry(phone, message, attempt + 1);
}
throw err;
}
}
To increase throughput beyond 80 MPS, apply in Meta Business Manager → WhatsApp → Phone Numbers → Request Increased Messaging Limit.
You cannot send free-form text to a user more than 24 hours after their last message. You must use a template.
// Instead of free-form text, send an approved template
await sendTemplate(phoneNumber, 'order_update', 'en_US', [
{ type: 'text', text: 'John' },
{ type: 'text', text: '#4521' }
]);
Create and submit templates at: Meta Business Manager → WhatsApp → Message Templates.
| Rejection Reason | Fix |
|---|---|
| Variable format wrong | Use {{1}}, {{2}} — double curly braces, sequential integers only |
| Template starts/ends with variable | Add plain text before {{1}} and after the last variable |
| Variables not sequential | Must be {{1}}, {{2}} — no gaps allowed |
| URL shorteners used | Use full, unshortened URLs to your own domain |
| Language code mismatch | Match language.code to the actual content language, e.g. en_US, pt_BR |
| Warm language in utility template | Use formal transactional wording; warm language causes auto-reclassification to marketing category |
| Sensitive data | Never request SSNs, full card numbers, or passwords |
| Duplicate of existing template | Change the wording — even minor variation is required |
| Purpose unclear | Each variable must have a descriptive example value in the template submission |
Where to find rejection reason: Meta Business Manager → Business Support Home → Your WhatsApp Account → Rejected Template Messages → view policy issue.
Run this to check if a number has WhatsApp before sending:
curl "https://graph.facebook.com/v21.0/PHONE_NUMBER_ID/contacts" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-X POST \
-d '{"messaging_product": "whatsapp", "contacts": ["+14155551234"]}'
# Response includes "wa_id" if the number has WhatsApp, empty if not
This is the most common silent failure. The API returns success (messages[0].id) but the user receives nothing.
Root cause: You sent a free-form text message as the first outreach. Meta silently drops it.
How to tell: Check the message status webhook — the message will show failed with error 131047 or show sent but never delivered.
Rule: The very first message your app sends to any number must be a template. No exceptions.
❌ Wrong — app sends free-form text first:
POST /messages → type: "text", body: "Hello John, your order is ready"
→ API may return 200 but message is silently dropped or returns 131047
✅ Correct — app sends template first:
POST /messages → type: "template", name: "order_ready"
→ User receives the message and can reply
→ After user replies, free-form text is allowed for 24h
Fix: Create and approve a template for every type of first-contact message you need to send. Submit templates at Meta Business Manager → WhatsApp → Message Templates.
Always validate phone number format and WhatsApp registration before sending:
def is_valid_phone_format(phone_digits: str) -> bool:
"""
E.164 format validation:
- 7-15 digits (not including +)
- Not all same digit (e.g., 0000000 is invalid)
"""
if not phone_digits or len(phone_digits) < 7 or len(phone_digits) > 15:
return False
if len(set(phone_digits)) == 1: # all same digit
return False
return True
def is_registered_on_whatsapp(phone_digits: str, token: str, phone_id: str) -> bool:
"""
Check if number is a registered WhatsApp user.
Returns False only on definitive error 131026 (not on WhatsApp).
Returns True if successful, uncertain (auth error, etc.), or timeout.
"""
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
data = {
"messaging_product": "whatsapp",
"to": phone_digits,
"type": "text",
"text": {"body": "_"} # minimal text to check
}
try:
r = requests.post(
f"https://graph.facebook.com/v21.0/{phone_id}/messages",
headers=headers,
json=data,
timeout=10
)
if r.status_code == 200:
return True # number is valid
error_code = r.json().get("error", {}).get("code")
if error_code == 131026:
return False # NOT on WhatsApp
return True # other errors — don't block
except:
return True # network error — don't block
def extract_error_code(response_json: dict) -> int:
"""Extract error code from Meta API response."""
return (
response_json.get("error", {}).get("code")
or response_json.get("error", {}).get("error_subcode")
)
def send_with_error_handling(phone_id: str, recipient: str, message_body: str, token: str):
"""Send message and extract detailed error info."""
url = f"https://graph.facebook.com/v21.0/{phone_id}/messages"
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
data = {
"messaging_product": "whatsapp",
"to": recipient,
"type": "text",
"text": {"body": message_body}
}
try:
r = requests.post(url, headers=headers, json=data)
r.raise_for_status()
return {"success": True, "message_id": r.json().get("messages")[0].get("id")}
except requests.HTTPError as e:
error_code = extract_error_code(e.response.json())
error_msg = e.response.json().get("error", {}).get("message")
return {
"success": False,
"error_code": error_code,
"error_message": error_msg,
"response_text": e.response.text
}
def send_interactive_message(phone_id: str, recipient: str, text: str, options: list, token: str):
"""
Intelligently sends:
- Buttons (up to 3 options)
- List Menu (4-10 options)
Each option: {"id": "unique_id", "title": "Text (max 20 chars)"}
"""
url = f"https://graph.facebook.com/v21.0/{phone_id}/messages"
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
if len(options) <= 3:
# Send as buttons
button_data = {
"messaging_product": "whatsapp",
"to": recipient,
"type": "interactive",
"interactive": {
"type": "button",
"body": {"text": text},
"action": {
"buttons": [
{
"type": "reply",
"reply": {"id": opt["id"], "title": opt["title"][:20]}
}
for opt in options
]
}
}
}
return requests.post(url, headers=headers, json=button_data)
else:
# Send as list menu
list_data = {
"messaging_product": "whatsapp",
"to": recipient,
"type": "interactive",
"interactive": {
"type": "list",
"body": {"text": text},
"action": {
"button": "See options",
"sections": [{
"title": "Available options",
"rows": [
{
"id": opt["id"],
"title": opt["title"][:24]
}
for opt in options
]
}]
}
}
}
return requests.post(url, headers=headers, json=list_data)
Token & Phone Verification Script:
# Check if token is valid and phone number is accessible
import requests, os
from dotenv import load_dotenv
load_dotenv()
TOKEN = os.getenv('WHATSAPP_TOKEN')
PHONE_ID = os.getenv('PHONE_NUMBER_ID')
# Debug token
r = requests.get(f"https://graph.facebook.com/debug_token?input_token={TOKEN}&access_token={TOKEN}")
data = r.json()
if 'error' in data:
print(f"❌ Token Error: {data['error']['message']}")
else:
print(f"✅ Token Valid")
print(f" Expires: {data['data'].get('expires_at')} (0=never)")
print(f" Scopes: {data['data'].get('scopes')}")
# Check phone number
r = requests.get(
f"https://graph.facebook.com/v21.0/{PHONE_ID}",
headers={"Authorization": f"Bearer {TOKEN}"}
)
if r.status_code == 200:
print(f"✅ Phone Number Accessible")
print(f" Display: {r.json().get('display_phone_number')}")
print(f" Quality Rating: {r.json().get('quality_rating')}")
else:
print(f"❌ Phone Error: {r.json().get('error', {}).get('message')}")
When a message fails, check in this order:
curl "https://graph.facebook.com/debug_token?input_token=TOKEN&access_token=APP_ID|APP_SECRET"+, no spaces: "14155551234"GET /WABA_ID/subscribed_apps