How do I send a WhatsApp message with Python?#
Three officially supported paths: the Meta Cloud API directly, Twilio as a Meta-approved BSP, or a managed inbox API like Instant Reply. All three take a POST request and a bearer token; the call itself is about ten lines. What differs is everything past the first message: webhook signature verification, the 24-hour reply window, template messages, media, and retries.
This post has working code for all three: text messages, template messages, media messages, webhook receivers with signature verification, and retry handling. If you already know which one you want, skip to that section. If you want the trade-offs first, jump to "Which option should I actually use?" below.
What do you need before you send anything?#
For the Meta Cloud API you need a Meta Business Manager account, a WhatsApp Business Account (WABA) connected to a phone number, and a System User permanent access token so your script doesn't have to refresh OAuth. Set up the project first:
python3 -m venv venv
source venv/bin/activate # venvScriptsactivate on Windows
pip install requests flask twilio
Then get your Meta credentials:
- Create an app at developers.facebook.com/apps and add the WhatsApp product.
- Under WhatsApp → API Setup, note the Phone number ID and generate a temporary token to test with.
- Under Business Settings → System Users, create a system user with WhatsApp Business Messaging permission and generate a permanent token for production. Temporary tokens expire in 24 hours.
- Once you have a public HTTPS endpoint (see the webhook section below), add it under WhatsApp → Configuration along with a verify token of your choosing.
How do I send a message with the Meta Cloud API?#
Sending a text message is one POST request:
import os
import requests
PHONE_ID = os.environ["WA_PHONE_NUMBER_ID"]
TOKEN = os.environ["WA_PERMANENT_TOKEN"]
URL = f"https://graph.facebook.com/v18.0/{PHONE_ID}/messages"
def send_text(to_e164: str, body: str):
r = requests.post(
URL,
headers={"Authorization": f"Bearer {TOKEN}"},
json={
"messaging_product": "whatsapp",
"to": to_e164,
"type": "text",
"text": {"body": body},
},
timeout=10,
)
r.raise_for_status()
return r.json()
if __name__ == "__main__":
print(send_text("14155551234", "Your order is on its way."))
Phone numbers go in E.164 format without the plus sign. A successful send returns this shape:
{
"messaging_product": "whatsapp",
"contacts": [
{ "input": "14155551234", "wa_id": "14155551234" }
],
"messages": [
{ "id": "wamid.HBgLMTQxNTU1NTEyMzQVAgARGBI5QTNDQTVCM0Q0Q0Q2RTY1RTcA" }
]
}
Store messages[0].id if you want to correlate delivery-status webhooks back to this send. Errors come back as JSON with a Meta error code; 131000 is a general error and 131005 is a rate limit. The one you'll hit most in testing is 131047, which fires when you send free text outside the 24-hour window:
{
"error": {
"message": "Re-engagement message",
"type": "OAuthException",
"code": 131047,
"error_data": {
"messaging_product": "whatsapp",
"details": "Message failed to send because more than 24 hours have passed since the customer last replied to this number."
},
"fbtrace_id": "AXNOZ3F3s-eqOSfDh4wm8gA"
}
}
That error means one thing: switch to a template message. There's no free-text workaround.
How do I send a template message?#
Outside the 24-hour window, only pre-approved templates go through. Create and submit templates in Meta Business Manager under WhatsApp Manager → Message Templates, wait for approval, then reference the approved name and language exactly:
def send_template(to_e164: str, template_name: str, lang: str, params: list[str]):
r = requests.post(
URL,
headers={"Authorization": f"Bearer {TOKEN}"},
json={
"messaging_product": "whatsapp",
"to": to_e164,
"type": "template",
"template": {
"name": template_name,
"language": {"code": lang},
"components": [
{
"type": "body",
"parameters": [{"type": "text", "text": p} for p in params],
}
],
},
},
timeout=10,
)
r.raise_for_status()
return r.json()
send_template("14155551234", "order_shipped", "en_US", ["Ahmed", "#48213"])
The parameters list fills the {{1}}, {{2}} placeholders in your approved template body, in order. An unapproved or misspelled template name fails with an OAuthException saying the template couldn't be found. The name and language code have to match what's approved in WhatsApp Manager exactly, including case.
How do I send an image or other media?#
Send by public URL, which is simplest, or upload first and reference a media_id, which works for private files and avoids Meta re-fetching the URL on every send:
def send_image(to_e164: str, image_url: str, caption: str = ""):
r = requests.post(
URL,
headers={"Authorization": f"Bearer {TOKEN}"},
json={
"messaging_product": "whatsapp",
"to": to_e164,
"type": "image",
"image": {"link": image_url, "caption": caption},
},
timeout=10,
)
r.raise_for_status()
return r.json()
Meta enforces per-type size caps that are lower for images and stickers than for video and documents, and it re-hosts whatever you send, returning its own media_id in the response. Check the current limits under Meta's media messages docs before you build around a specific number; they've changed before.
How do I handle rate limits and retries?#
Retry 5xx responses and 429s with backoff. Don't retry 4xx errors like an invalid phone number or a missing template. They'll fail the same way every time:
import time
def send_with_retry(to_e164: str, body: str, max_attempts: int = 4):
for attempt in range(max_attempts):
r = requests.post(
URL,
headers={"Authorization": f"Bearer {TOKEN}"},
json={
"messaging_product": "whatsapp",
"to": to_e164,
"type": "text",
"text": {"body": body},
},
timeout=10,
)
if r.status_code < 500 and r.status_code != 429:
r.raise_for_status()
return r.json()
if attempt == max_attempts - 1:
r.raise_for_status()
time.sleep(2 ** attempt) # 1s, 2s, 4s
This is a fixed backoff with no jitter, which is fine at low volume. At high enough send volume, add random jitter so retries from a batch don't all land on the same second.
How do I receive messages with a Flask webhook?#
For two-way conversations, expose a webhook. Meta posts JSON to your URL whenever a customer messages you, replies, or reads a message. Verify the signature on every request:
import hmac, hashlib, os
from flask import Flask, request, abort
app = Flask(__name__)
APP_SECRET = os.environ["WA_APP_SECRET"].encode()
VERIFY_TOKEN = os.environ["WA_VERIFY_TOKEN"]
def verify_signature(raw_body: bytes, sig_header: str) -> bool:
expected = "sha256=" + hmac.new(APP_SECRET, raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig_header or "")
@app.route("/webhook", methods=["GET", "POST"])
def webhook():
if request.method == "GET":
# Verification handshake
if request.args.get("hub.verify_token") == VERIFY_TOKEN:
return request.args.get("hub.challenge")
abort(403)
raw = request.get_data()
if not verify_signature(raw, request.headers.get("X-Hub-Signature-256")):
abort(403)
event = request.json
# event["entry"][0]["changes"][0]["value"]["messages"][0]
# Handle incoming text, image, button reply, etc.
return ("", 200)
That's a production-ready signature check using hmac.compare_digest, which is timing-safe. Do not compare signatures with ==; an attacker can probe them character by character.
How do I send a WhatsApp message with Twilio?#
Twilio wraps the Cloud API and gives you a sandbox WhatsApp number you can hit in five minutes without a verified WABA. Production still needs a verified number under the same Meta requirements. If you're weighing Twilio against a managed inbox for the long run, see the full Twilio WhatsApp alternative comparison.
import os
from twilio.rest import Client
client = Client(
os.environ["TWILIO_ACCOUNT_SID"],
os.environ["TWILIO_AUTH_TOKEN"],
)
message = client.messages.create(
from_="whatsapp:+14155238886", # Twilio sandbox number
to="whatsapp:+14155551234",
body="Your order is on its way.",
)
print(message.sid)
Twilio charges a per-message platform fee on top of Meta's message fees. The total depends on destination, category, volume, and the current rate cards, so calculate it from your actual message mix rather than a universal estimate.
How do I verify Twilio webhook signatures?#
from twilio.request_validator import RequestValidator
import os
validator = RequestValidator(os.environ["TWILIO_AUTH_TOKEN"])
@app.route("/twilio/webhook", methods=["POST"])
def twilio_webhook():
sig = request.headers.get("X-Twilio-Signature", "")
url = request.url
params = request.form.to_dict()
if not validator.validate(url, params, sig):
abort(403)
incoming_body = request.form.get("Body")
from_number = request.form.get("From") # whatsapp:+14155551234
# Process the message...
return ("", 200)
How do I send a WhatsApp message with the Instant Reply API?#
If you also want a shared inbox UI, AI auto-replies, voice transcription, image understanding, calendar booking, and CRM sync, the Cloud API alone won't get you there without months of extra build. The Instant Reply API ships those as a managed product, with the same request/response ergonomics across WhatsApp, Instagram DMs, and Messenger from one endpoint:
import os, requests
IR_API_KEY = os.environ["IR_API_KEY"]
BASE = "https://api.instantreply.co/v1"
def send_message(conversation_id: str, content: str):
r = requests.post(
f"{BASE}/conversations/{conversation_id}/messages",
headers={
"Authorization": f"Bearer {IR_API_KEY}",
"Content-Type": "application/json",
},
json={"content": content},
timeout=10,
)
r.raise_for_status()
return r.json()
# List all open conversations across WhatsApp, IG, Messenger:
def list_open():
r = requests.get(
f"{BASE}/conversations?status=active&limit=100",
headers={"Authorization": f"Bearer {IR_API_KEY}"},
timeout=10,
)
r.raise_for_status()
return r.json()["data"]
if __name__ == "__main__":
for conv in list_open():
if "refund" in (conv.get("last_message_preview") or "").lower():
send_message(conv["id"], "Hi! Your refund is processed. Confirmation email in 2 minutes.")
Test keys start with ir_test_ and return a fake 201 without touching Meta, so you can run send and trigger simulations against live tenant data safely. Live keys start with ir_live_.
Template and journey sends work differently here than in the raw Meta payload above: Instant Reply triggers pre-approved WhatsApp templates through the Journey Trigger API (POST /v1/trigger, keyed by trigger_name and a metadata dict for the template variables) rather than a name/language/components body. The full example, including the status_url you poll for delivery, is in the API quickstart.
Webhook signature verification#
import hmac, hashlib, os
from flask import Flask, request, abort
app = Flask(__name__)
SIGNING_SECRET = os.environ["IR_WEBHOOK_SECRET"].encode() # starts with whsec_
@app.route("/instantreply/webhook", methods=["POST"])
def ir_webhook():
raw = request.get_data()
sig = request.headers.get("X-InstantReply-Signature", "")
ts = request.headers.get("X-InstantReply-Timestamp", "")
delivery_id = request.headers.get("X-InstantReply-Delivery")
event_name = request.headers.get("X-InstantReply-Event")
signed = ts.encode() + b"." + raw
expected = "sha256=" + hmac.new(SIGNING_SECRET, signed, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, sig):
abort(403)
event = request.json
# event["event"] = "message.received" | "conversation.closed" | ...
# event["data"] = the payload
return ("", 200)
Deliveries are at-least-once, retried at roughly one minute and ten minutes after the first attempt (three attempts total). Process X-InstantReply-Delivery or event["id"] idempotently on your side so a retried delivery doesn't double-fire your logic.
Which option should I actually use?#
The code above covers sending a message. It doesn't cover what a real product needs next: a 24-hour-window timer per conversation, a retry queue, a team inbox, AI replies that don't sound like a template. That part is months of engineering, not an afternoon, and it's the same whether you build it on the Cloud API or on Twilio.
Twilio's WhatsApp pricing page (checked October 3, 2026) lists its own message handling fee of $0.005 per message on top of Meta's fees, and that fee applies to every message Twilio processes, including replies inside the 24-hour customer-service window. At real volume that adds up, so compare it with the sandbox convenience for your message mix.
Instant Reply runs on the same Cloud API underneath, with a plan price and no per-message markup from Instant Reply: WhatsApp starts on the Growth plan at $129/month (35,000 AI replies a month included, 5 members), and Meta's charges are billed through your own Meta account. If you're weighing this specifically against Twilio, the full cost breakdown compares them.
What gotchas should I watch for?#
1 · The 24-hour window#
Once a customer messages you, you have 24 hours to reply with any message. After that, only pre-approved template messages (Meta calls these "marketing", "utility", or "authentication" templates) go through; free-form text outside the window returns error 131047. The Instant Reply inbox shows the window timer per conversation.
2 · Phone number format#
WhatsApp wants E.164: +14155551234 (with the plus). Meta wants it without the plus: 14155551234. Twilio wants whatsapp:+14155551234. Three slightly different formats. Write a normalizer once and use it everywhere.
3 · Don't compare signatures with ==#
The wrong code: if sig == expected:. The right code: hmac.compare_digest(sig, expected). The first leaks timing info that lets an attacker probe the signature one character at a time. Use compare_digest.
4 · Avoid these libraries#
- yowsup: reverse-engineered WhatsApp Web protocol. Numbers get banned.
- selenium-whatsapp: browser automation. Same risk.
- pywhatkit: relies on WhatsApp Web. Brittle, bannable.
- whatsapp-web.js: Node, not Python, but the same underlying risk.
None of these touch the official API. Meta detects and bans the underlying numbers. Use the Cloud API, Twilio, or Instant Reply.
What do you need before shipping to production?#
- Permanent access tokens stored in your secrets manager, not
.envin git - Signature verification on every webhook (timing-safe compare)
- Retry with exponential backoff on 5xx and rate-limit responses
- Idempotency keys on POST requests (the message ID plus recipient is a good one)
- Log
X-Request-Idfrom every API response for support escalation - Monitor delivery webhooks (
sent,delivered,read,failed) - Respect the 24-hour customer-care window
Where do you go from here?#
If you just need to send messages, the Meta Cloud API docs cover every endpoint this post doesn't. If you're building the inbox around it too, either build the pieces above yourself or use Instant Reply, which ships auto-replies, voice transcription, image understanding, CRM sync, and a Python-friendly REST API across WhatsApp, Instagram, and Messenger.
- Full API quickstart: API keys, journey triggers, webhook registration and delivery inspection, template validation
- API reference: every endpoint and webhook event
- MCP server: Claude, Cursor, Windsurf, and Zed can read and act on your inbox
7-day Pro trial, no credit card. Start the trial or read the quickstart directly.






