DEV Community

Cover image for How to Send WhatsApp Broadcast Messages at Scale with Python
Priyansh Kansara
Priyansh Kansara

Posted on

How to Send WhatsApp Broadcast Messages at Scale with Python

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. 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 hit 429 Too Many Requests and then get throttled hard — error #130429 means Meta stopped accepting sends because of accumulated errors.
  2. 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.
  3. 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
Enter fullscreen mode Exit fullscreen mode
  • 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']}")
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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.")
Enter fullscreen mode Exit fullscreen mode

Run it:

export WA_TOKEN="EAAG..."      # permanent token from a system-user, not your own account
export WA_PHONE_ID="123456789"
python broadcast.py
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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 components array)
  • 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_CAP in 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)