WhatsApp API Python Tutorial: Messages, Templates, and Webhooks

About the Author
Suleiman Alnsour
Founder & CEO at Instant Reply
7+ years building SaaS for WhatsApp ecosystem. Expert in conversational AI and sales automation.
Published: May 17, 2026
Last updated: August 24, 2026
- How do I send a WhatsApp message with Python?
- What do you need before you send anything?
- How do I send a message with the Meta Cloud API?
- How do I send a template message?
- How do I send an image or other media?
- How do I handle rate limits and retries?
- How do I receive messages with a Flask webhook?
- How do I send a WhatsApp message with Twilio?
- How do I verify Twilio webhook signatures?
- How do I send a WhatsApp message with the Instant Reply API?
- Webhook signature verification
- Which option should I actually use?
- What gotchas should I watch for?
- 1 · The 24-hour window
- 2 · Phone number format
- 3 · Don't compare signatures with ==
- 4 · Avoid these libraries
- What do you need before shipping to production?
- Where do you go from here?
People Also Ask
Related Questions
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. Never 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 adds its own $0.005 platform fee on top of Meta's per-message price -- and that $0.005 still applies even inside the free 24-hour customer-service window, where Meta itself charges nothing. So the "free" replies you'd get sending directly through Meta's Cloud API aren't free on Twilio; you're paying Twilio's markup on every single message regardless of Meta's own pricing tier. At real volume that adds up faster than the sandbox convenience is worth.
Instant Reply runs on the same Cloud API underneath, with a flat monthly fee and zero per-message markup -- $129/mo covers the platform whether you send 500 messages or 50,000. If you're weighing this specifically against Twilio, the full cost breakdown has the numbers at different volumes.
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. Always 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
10-day Pro trial, no credit card. Start the trial or read the quickstart directly.
Frequently asked questions
Quick answers to what people ask most.
- Yes. Three credible paths: the Meta Cloud API (official, free, you handle WABA setup), Twilio (fastest to bootstrap, pay-per-message), and managed inbox APIs like Instant Reply (REST API across WhatsApp, Instagram, Messenger with a Python SDK). All three use plain HTTP requests, so the standard requests library handles them, and full-featured SDKs cover async + retries.
- The Meta Cloud API itself has no separate platform subscription, but eligible template messages have Meta fees that vary by category and recipient market. Twilio may add its own platform fee. Instant Reply software pricing and API limits are shown on the current pricing and developer pages.
- Start with the requests library and a single send_message function. Add a Flask or FastAPI endpoint to receive webhooks. Use the official Meta Cloud API for direct messaging or Instant Reply's REST API for a managed inbox across multiple channels. Both have working Python examples in this post.
- Meta sends a hub.verify_token query parameter on the initial webhook setup, and signs each subsequent payload with X-Hub-Signature-256 (HMAC-SHA256). Verify with hmac.compare_digest. Twilio uses X-Twilio-Signature with HMAC-SHA1 and the request body. Instant Reply uses X-InstantReply-Signature with HMAC-SHA256 over timestamp + '.' + raw request body.
- No. WhatsApp blocks unofficial libraries (like yowsup or Selenium-based scrapers) and bans the phone numbers using them. Stick to the official Meta Cloud API, an approved BSP like Twilio, or a managed inbox like Instant Reply. The official path takes about 10 minutes via embedded signup.
- The Cloud API is Meta's direct offering: you register a WhatsApp Business Account and pay Meta's current message fees. Twilio is a BSP that wraps the Cloud API and adds its own developer experience and platform pricing. Compare control, implementation effort, and current fee schedules before choosing.
- Skip yowsup, selenium-whatsapp, and pywhatkit; all rely on the unofficial WhatsApp Web protocol and risk a number ban. Use requests with the Cloud API directly, twilio-python for Twilio, or instantreply-python for the managed inbox API. All three are officially supported as of 2026.
10-day Pro trial · no credit card
Give every DM a faster first response.
Instant Reply drafts replies for WhatsApp, Instagram, and Messenger, then keeps humans in control for approvals, exceptions, and booking handoff.
Keep reading

AI Inbox
How to Turn Instagram Comments into Sales with an AI Agent
11 min read

AI Inbox
AI Chatbot vs AI Inbox: The Real Difference (and Which You Need)
11 min read

AI Inbox
AI Customer Service Agent: What It Is and How to Deploy One (2026)
14 min read

AI Inbox
AI Customer Service Software: The Complete 2026 Buyer's Guide
14 min read

AI Inbox
AI Customer Support Software in 2026: Buyer's Guide and Comparison
13 min read

AI Inbox
AI in Customer Service (2026): A Release-Gate Playbook
12 min read
Explore Instant Reply
More tools and solutions
Industry solutions