If you've ever sent a WhatsApp broadcast to 10,000 customers from a for loop, you've probably met this error:
(#130429) Too many requests sent after receiving other errors
Or worse — you "successfully" sent 10,000 messages, paid Meta ~₹8,600 for them, and never found out how many actually landed on phones.
WhatsApp broadcast messaging looks trivial until it has to be reliable. In this article I'll build a production-grade broadcast engine in Python that handles Meta's Cloud API rate limits, retries, delivery tracking, and — the part almost every tutorial skips — real cost control under India's per-message pricing.
The new math: why broadcasts are a media budget now
On July 1, 2025, Meta switched WhatsApp Business API billing from per-conversation to per-message pricing. The practical effect in India:
| Category | Rate per delivered message* | Examples |
|---|---|---|
| Marketing | ~₹0.86 | Offers, launches, re-engagement |
| Utility | ~₹0.115 | Order updates, reminders (free inside an open 24h service window) |
| Authentication | ~₹0.115 | OTPs, PIN reminders |
| Service (replies) | Free | Any free-form reply within 24h of a customer message |
*Meta's published rates fluctuate quarterly — always check the official Meta pricing page. BSPs may add a markup, and 18% GST applies on top.
A "broadcast" is a series of template messages, and templates are always business-initiated — so they're always billed. A 10,000-person marketing broadcast is roughly ₹8,600 + GST before your platform fee. That's a real media spend, and it deserves real engineering: deduplication, cost caps, pause switches, and delivery analytics.
Why your for loop breaks
Three reasons:
-
Rate limits. Cloud API throughput depends on your messaging tier (entry: ~80 template messages/second, scaling up to 1,000/s and beyond as your reputation grows). A tight
requests.post()loop with one connection will hit429 Too Many Requestsand then get throttled hard — error#130429means Meta stopped accepting sends because of accumulated errors. - Fire-and-forget blindness. The API response only tells you Meta accepted the message. Whether it was delivered, read, or bounced comes later, via webhooks. If you don't ingest them, you're paying without measuring.
- No idempotency. Any script long enough to send 10k messages is long enough to crash at message 7,412. Without per-message state tracking, your only recovery option is "start over and duplicate half your list."
Architecture
contacts.csv ──> SQLite queue ──> throttled sender ──> Meta Cloud API
│ │
cost ledger │
│ ▼
Flask webhook receiver <── status callbacks
│
delivered / read / failed ──> dashboard SQL
- SQLite as the queue (no Redis dependency; fine up to millions of rows)
- Throttled sender with adaptive backoff
-
Flask webhook that records
sent → delivered → read / failed - Ledger table so you can compute cost per delivered message
Step 1: Schema and contacts import
# db.py
import sqlite3
SCHEMA = """
CREATE TABLE IF NOT EXISTS contacts (
phone TEXT PRIMARY KEY,
name TEXT,
status TEXT DEFAULT 'queued', -- queued|sent|delivered|read|failed|skipped
wa_id TEXT, -- WhatsApp message ID (for webhook matching)
error TEXT,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS sends (
wa_id TEXT PRIMARY KEY,
phone TEXT,
category TEXT, -- marketing|utility|auth
cost REAL,
status TEXT,
ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
"""
def get_db(path="broadcast.db"):
con = sqlite3.connect(path, timeout=30)
con.row_factory = sqlite3.Row
con.executescript(SCHEMA)
return con
def import_contacts(con, csv_path):
import csv
rows = [(r["phone"].strip(), r.get("name", "").strip()) for r in
csv.DictReader(open(csv_path)) if r.get("phone")]
con.executemany(
"INSERT OR IGNORE INTO contacts(phone, name) VALUES (?, ?)", rows)
con.commit()
print(f"Imported. Queue size: "
f"{con.execute('SELECT COUNT(*) c FROM contacts WHERE status=\"queued\"').fetchone()['c']}")
Two India-specific data-cleaning rules before you import:
def normalize_indian_phone(raw: str) -> str | None:
"""Convert 09876543210, 98765-43210, +91-9876543210 → 919876543210"""
digits = "".join(c for c in raw if c.isdigit())
if digits.startswith("0091"): digits = digits[4:]
elif digits.startswith("91") and len(digits) == 12: pass
elif digits.startswith("0") and len(digits) == 11: digits = digits[1:]
if len(digits) == 10 and digits[0] in "6789":
return "91" + digits
return None # landline/VPA-style/invalid → skip, don't burn a failed send
Step 2: The template send
Make sure your template is approved in WhatsApp Manager under the right category. An order reminder mistakenly categorized as marketing costs ₹0.86 instead of ₹0.115 — a 7.5× penalty on every send.
# sender_core.py
import requests, time
GRAPH = "https://graph.facebook.com"
API_VER = "v23.0" # check developers.facebook.com for the latest version
def send_template(token, phone_number_id, to, template, language="en_IN"):
url = f"{GRAPH}/{API_VER}/{phone_number_id}/messages"
payload = {
"messaging_product": "whatsapp",
"to": to,
"type": "template",
"template": {
"name": template["name"],
"language": {"code": language},
"components": template.get("components", []),
},
}
r = requests.post(url, json=payload,
headers={"Authorization": f"Bearer {token}"}, timeout=15)
data = r.json()
if r.status_code != 200:
raise MetaAPIError(r.status_code, data)
return data["messages"][0]["id"] # wa_id → track this!
class MetaAPIError(Exception):
def __init__(self, code, data):
self.code = code
self.subcode = data.get("error", {}).get("error_data", {}).get("messaging_product_tag")
self.message = data.get("error", {}).get("message", str(data))
super().__init__(self.message)
Step 3: The throttled, retry-safe sender
This is the heart of the system. Key behaviors:
- Token-bucket throttle (start conservatively: ~20 msg/s; raise only if your tier allows)
- Adaptive backoff: any 429 or 130429 doubles the delay across the whole batch
-
Permanent-fail classification: invalid numbers and opted-out users get marked
failed/skipped, never retried (retrying them hurts your quality rating) - Cost ledger: every accepted send is logged with its expected cost
# broadcast.py
import time, os
from db import get_db
from sender_core import send_template, MetaAPIError
TOKEN = os.environ["WA_TOKEN"]
PHONE_ID = os.environ["WA_PHONE_ID"]
RATE = 20.0 # messages/sec — stay inside your tier
CAT_COST = {"marketing": 0.86, "utility": 0.115, "auth": 0.115}
COST_CAP = 5000.0 # ₹ hard stop — refuse to spend past this
RETRYABLE = {429, 130429, 90084, 1} # throttle / transient
PERMANENT = {131026, 131021, 131047, 131016} # invalid number, not on WA, etc.
def run_batch(con, template, category="marketing", batch=500):
delay = 1.0 / RATE
while True:
rows = con.execute(
"SELECT phone, name FROM contacts WHERE status='queued' LIMIT ?",
(batch,)).fetchall()
if not rows:
break
for row in rows:
spent = con.execute(
"SELECT COALESCE(SUM(cost),0) s FROM sends").fetchone()["s"]
if spent + CAT_COST[category] > COST_CAP:
print("⛔ Cost cap reached — pausing broadcast."); return
try:
wa_id = send_template(TOKEN, PHONE_ID, row["phone"], template)
con.execute("UPDATE contacts SET status='sent', wa_id=? "
"WHERE phone=?", (wa_id, row["phone"]))
con.execute("INSERT INTO sends(wa_id, phone, category, cost, status) "
"VALUES (?,?,?,?, 'sent')",
(wa_id, row["phone"], category, CAT_COST[category]))
con.commit()
except MetaAPIError as e:
status = getattr(e, "code", 0)
if status in PERMANENT:
con.execute("UPDATE contacts SET status='failed', error=? "
"WHERE phone=?", (e.message, row["phone"]))
elif status in RETRYABLE:
delay = min(delay * 2, 5.0) # back the whole batch off
print(f"⚠️ throttled ({e.message}); sleeping {delay:.1f}s")
time.sleep(delay)
else:
print(f"❓ {row['phone']}: {e.message}")
time.sleep(delay)
print("Batch complete.")
Run it:
export WA_TOKEN="EAAG..." # permanent token from a system-user, not your own account
export WA_PHONE_ID="123456789"
python broadcast.py
Because every message's state lives in SQLite, restarting after a crash resumes exactly where it stopped — queued rows only. No duplicate sends, no lost spend.
Step 4: Delivery tracking via webhooks
The send loop tells you what Meta accepted. Webhooks tell you what actually happened. Expose an HTTPS endpoint (ngrok for dev, any VPS in production) from Meta's app dashboard:
# webhook.py
from flask import Flask, request
from db import get_db
app = Flask(__name__)
con = get_db()
@app.get("/webhook") # Meta's verification handshake
def verify():
if request.args.get("hub.mode") == "subscribe" and \
request.args.get("hub.verify_token") == "MY_SECRET":
return request.args.get("hub.challenge")
return "forbidden", 403
@app.post("/webhook")
def receive():
body = request.get_json(silent=True) or {}
for change in body.get("entry", [{}])[0].get("changes", [{}])[0:1]:
for st in change.get("value", {}).get("statuses", []):
con.execute(
"UPDATE sends SET status=? WHERE wa_id=?",
(st["status"], st["id"]))
con.execute(
"UPDATE contacts SET status=? WHERE wa_id=?",
(st["status"], st["id"]))
con.commit()
return "ok", 200
if __name__ == "__main__":
app.run(port=5000)
Statuses arrive in order: sent → delivered → read, or failed with a reason. Only delivered messages are billed by Meta, so delivered count is your true denominator.
Step 5: The numbers that matter
-- Cost per DELIVERED message — the only cost metric worth watching
SELECT category,
COUNT(*) AS accepted,
SUM(CASE WHEN s.status IN ('delivered','read') THEN 1 ELSE 0 END) AS delivered,
ROUND(SUM(cost) /
NULLIF(SUM(CASE WHEN s.status IN ('delivered','read')
THEN 1 ELSE 0 END), 0), 3) AS cost_per_delivered
FROM sends s GROUP BY category;
-- Bounce reasons: fix your list, not your code
SELECT error, COUNT(*) FROM contacts
WHERE status='failed' GROUP BY error ORDER BY 2 DESC;
A healthy India D2C broadcast lands at 90–95% delivery. If cost_per_delivered is meaningfully above Meta's rate, your list quality — not your code — is the problem.
Template discipline (saves 7.5× on miscategorized sends)
- Every template is reviewed by Meta for category accuracy. "Hey, flash sale tonight!" submitted as utility gets re-categorized to marketing at the marketing rate — after you've built your campaign budget around ₹0.115.
- Keep marketing broadcasts under ~5% of total traffic mix if you can; businesses with poor quality ratings get rate-limited or paused entirely. Unsubscribe paths ("Reply STOP") aren't optional — they protect your number.
- Media doesn't cost more. A template with a 2MB image costs the same ₹0.86 as text-only. Use carousels and images liberally; just keep text under 1,024 characters so nothing gets silently truncated.
When to skip the code
Be honest about build-vs-buy. If the requirement is "non-technical marketing team schedules Diwali broadcasts," a BSP dashboard (AiSensy from ~₹999/month, Wati, Interakt) is the right tool — I compare them in detail in this article. Write custom code when you need:
- Own infrastructure / no per-message BSP markup
- Dynamic per-customer variables from your own database (this script's
componentsarray) - Broadcast logic wired to Shopify/order events, not manual campaigns
Production checklist
- [ ] Permanent token via a Meta system user (never a personal token that expires in 24h)
- [ ] Webhook endpoint behind HTTPS with the verify token set
- [ ]
COST_CAPin code, not in a spreadsheet someone checks - [ ] Opt-out handling inside the 24-hour service window (free-form replies are free — use them)
- [ ] Number warming: new numbers start at the entry tier; don't push 10k/hour on day one
- [ ] Nightly backup of
broadcast.db— it's your invoice reconciliation source of truth
The full script is ~200 lines of Python and no external services beyond Meta's free Cloud API. Next time a client asks you to "set up WhatsApp broadcasts," you're not selling a dashboard — you're selling measurable delivery at ₹0.90 per customer.
Building WhatsApp automation for Indian SMBs? I write about the API, BSPs, and real unit economics — links to more articles below.
Top comments (0)