Send WhatsApp messages from Python: a working example
Send WhatsApp template messages from Python with requests and a REST API — test keys, safe retries, error handling by type and signed webhook verification.
October 28, 2026 · 5 min read
To send a WhatsApp message from Python you need an approved template, an API key and one POST with requests. This guide shows that call, how to retry it without sending twice, how to handle the errors WhatsApp's rules cause, and how to verify the signed webhooks that tell you what happened next.
SendFromChat is an independent product and is not affiliated with Meta or WhatsApp. It runs on Meta's official WhatsApp Cloud API; you keep your own WhatsApp Business account.
What was tested: the code below was assembled from our REST API reference and run against a small local mock server on Python 3.14 with requests 2.32. It was not run against a live SendFromChat key, and the Flask route in step 5 was not run at all. Test it with a test key first. It needs Python 3.10 or newer (for the str | None type hints).
The short version
- Get a template approved (usually minutes, sometimes up to 24 hours).
- Create a test key, which runs every check and sends nothing.
POST /messageswith anIdempotency-Key, so a retry never double-sends.- Verify the
SendFromChat-Signatureheader on webhooks before you trust them.
1. A template
A message you start outside a 24-hour conversation window must use a template Meta has approved (how the window works). A shipping update is a Utility template, for example:
Hi {{1}}, your order {{2}} has shipped.
Create it on the dashboard or ask your assistant to draft and submit it. It's checked against Meta's formatting rules first (the rules, explained). Only message people who agreed to WhatsApp updates (how to collect opt-in).
2. A key you can't hurt yourself with
On the dashboard's Developers page, create a test key (sfc_test_…). It checks your plan, opt-outs, the template and the 24-hour window, then stops: nothing is sent, saved or counted. Swap in a live key (sfc_live_…) when you go to production.
Keep the key on your server, in an environment variable. Never put it in browser or mobile-app code.
pip install requests
export SENDFROMCHAT_KEY=sfc_test_...
3. Send a template
import os
import requests
API = "https://sendfromchat.com/api/v1"
class SendFromChatError(Exception):
def __init__(self, status: int, type_: str, message: str):
super().__init__(f"{type_}: {message}")
self.status = status
self.type = type_
def send_template(to: str, name: str, variables: list[str],
idempotency_key: str, language: str = "en_US") -> dict:
res = requests.post(
f"{API}/messages",
headers={
"Authorization": f"Bearer {os.environ['SENDFROMCHAT_KEY']}",
"Idempotency-Key": idempotency_key,
},
json={"to": to, "template": {"name": name, "language": language, "variables": variables}},
timeout=10,
)
body = res.json()
if not res.ok:
err = body.get("error", {})
raise SendFromChatError(res.status_code, err.get("type", "unknown"), err.get("message", ""))
return body # {"id": "wamid…", "status": "accepted", …}
send_template("+491512345678", "order_shipped", ["Ana", "#4821"], "order-4821-shipped")
Three details matter:
tois international format with the country code, such as+491512345678. If your numbers come from forms or a spreadsheet, normalise them first (phone number format, with a Python example).variablesfill{{1}},{{2}}… in order.- The idempotency key comes from the event, not from the attempt.
order-4821-shippedis the same on every retry, so a timeout or a re-run job gets the first response back instead of a second message. Reusing a key with a different body returnsidempotency_mismatch.
With a test key the response is 200 with "status": "test". With a live key it is 202 accepted, which means the request was accepted, not that the customer received or read it. Check GET /messages/{id} or subscribe to webhooks for the status.
4. Handle errors by type
Errors share one shape, {"error": {"type": …, "message": …}}. Branch on type, not on the message text:
try:
send_template("+491512345678", "order_shipped", ["Ana", "#4821"], "order-4821-shipped")
except SendFromChatError as e:
if e.type in ("opted_out", "window_closed", "template_not_approved"):
pass # WhatsApp's rules, not a bug: log it and move on
elif e.type == "warmup_limit":
pass # a new number hit today's cap: retry later
elif e.status >= 500:
pass # safe to retry with the same Idempotency-Key
else:
raise
opted_out, window_closed, template_not_approved and whatsapp_rejected come back as 422. warmup_limit is a 429: brand-new numbers are capped at 50, 100, then 200 template sends a day for the first three days. The full table is in the REST API reference.
5. Know what happened next
For delivery status and customer replies, add a webhook endpoint on the Developers page and subscribe to message.status and message.received. Each request carries a header like SendFromChat-Signature: t=1790300000,v1=5f2b…, where v1 is the hex HMAC-SHA256 of <t>.<raw request body> keyed with your endpoint secret. Verify it over the raw bytes, compare in constant time and reject timestamps older than five minutes:
import hashlib
import hmac
import time
def verify_signature(raw_body: bytes, header: str | None, secret: str) -> bool:
if not header:
return False
try:
parts = dict(p.split("=", 1) for p in header.split(","))
t = int(parts["t"])
except (ValueError, KeyError):
return False
if abs(time.time() - t) > 300:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
Wired into Flask (this route was not run; adapt it to your framework):
from flask import Flask, request
app = Flask(__name__)
@app.post("/webhooks/sendfromchat")
def sendfromchat_webhook():
if not verify_signature(request.get_data(), request.headers.get("SendFromChat-Signature"), WEBHOOK_SECRET):
return "", 400
event = request.get_json()
# deliveries can repeat: skip event["id"] values you have already handled
return "", 204
Read the body with request.get_data(), not request.json: re-serialised JSON no longer matches the signature. Answer with a 2xx within 5 seconds; failures are retried with backoff for about a day. The event payloads are in the REST API reference, and the webhooks guide shows the Node version.
Replies
When a customer answers, a 24-hour window opens and you can reply with free-form text, no template:
requests.post(f"{API}/messages",
headers={"Authorization": f"Bearer {os.environ['SENDFROMCHAT_KEY']}"},
json={"to": "+491512345678", "text": "Thanks, we're on it."}, timeout=10)
Replies, auto-replies and chatbot messages are unlimited on every plan. Only messages you start count toward the monthly allowance (plans and limits).
Costs, in plain terms
Two separate things: SendFromChat's flat monthly plan (from $19), and Meta's per-message charge, which Meta bills to your own WhatsApp Business account at its published rates, with no markup from us. Rates depend on the message category and the recipient's country; see WhatsApp API pricing.
A note for EU and UK customers
SendFromChat stores data in Mumbai, India. Transfers from the EU and UK are covered by the Standard Contractual Clauses and the UK Addendum in our Data Processing Agreement, and the sub-processors are listed publicly. Read it before you decide; our GDPR guide covers consent, opt-out and records on your side.
Try it
Start the 30-day free trial (no card), connect a number, create a test key and run the snippet above. If you build with Claude Code, you can ask it to "add WhatsApp order notifications to my app" and it writes the integration in your language (Claude Code guide). Weighing other routes? See SendFromChat vs Twilio and using Meta's Cloud API directly.
Do this from your AI assistant
SendFromChat connects Claude, ChatGPT, Cursor or any MCP client to the WhatsApp Business API — templates, bulk sends, inbox, chatbots and sequences, just by asking. 30 days free, no card.
Start freeMore guides
WhatsApp click-to-chat link (wa.me): how to make one, and what it can't do
How to build a wa.me link with a prefilled message, the number mistakes that break it (the + and the leading 0), a small code helper, and when you need the WhatsApp Business API instead.
WhatsApp API phone number format for European numbers (E.164, the leading 0 and the missing +)
How to write a customer's phone number for the WhatsApp Business API: the + and country code, which European countries drop the leading 0, why Italy keeps it, and how to normalise numbers in code.
Add WhatsApp to your app with Claude Code: connect it, then review four things
Connect Claude Code to SendFromChat with one command, ask it to add WhatsApp order notifications, then check what it wrote: where the key lives, retries, STOP replies and templates.
Questions to ask a WhatsApp provider before you sign: who owns your account, number and templates
Three questions to put to any WhatsApp Business API provider (BSP or Tech Provider) before you sign — who owns the WhatsApp Business Account, whether you can leave with your number and templates, and who bills Meta's charges.