Stop Paying for SMS: How to Send WhatsApp OTPs in Django

If you are running an app or an e-commerce store in Pakistan or India, you already know that SMS OTPs are becoming a financial drain. Telcos in Pakistan like Jazz, Zong, and Telenor have steadily increased branded SMS rates, and getting PTA approval for a new SMS mask can take up to a month of bureaucratic back-and-forth. In India, while SMS rates are lower, dealing with DLT registration, template approvals, and scrubbing entities is a slow, frustrating process.
WhatsApp OTPs are the logical alternative. But do not jump in blindly. The rules, costs, and technical setups are entirely different from SMS. If you configure this incorrectly, your Django application will either leak money or fail to deliver codes when your users are trying to log in.
The Real Costs and Limits You Will Encounter
Meta does not charge for WhatsApp messages the way telcos charge for SMS. Instead of billing per message, Meta charges per 24-hour conversation window. This window starts the moment your message is delivered. Inside this 24-hour window, you can send multiple messages to the user without paying extra.
For OTPs, you must use Meta's official "Authentication" templates. You cannot sneak an OTP into a standard utility or marketing message; Meta's automated filters will flag and reject the template.
| Region | Approx. Cost per Authentication Window | Delivery Rate (Average) | Setup Time |
|---|---|---|---|
| India (IN) | ₹0.11 to ₹0.13 | 98.2% | 1 to 2 Days |
| Pakistan (PK) | $0.005 to $0.011 (billed in USD) | 97.5% | 2 to 3 Days |
These rates fluctuate slightly based on exchange rates and Meta's regional pricing updates. You can check the current, exact pricing sheets directly on the Meta Developer Pricing Portal.
This pricing model means if a user requests an OTP, enters it incorrectly, and clicks "Resend OTP" three times within 24 hours, you only pay for one single conversation. With traditional SMS, you would pay for three separate messages. This is where the actual cost savings happen.
There is a catch. Meta limits new, unverified accounts to 250 business-initiated conversations per rolling 24-hour period. To scale past this, you must verify your business in the Meta Business Suite with official documents like a tax registration certificate (NTN in Pakistan, GST or PAN in India). Once verified, your limit increases to 1,000, then 10,000, and eventually 100,000 daily conversations.
Choosing Your Gateway: Meta Cloud API vs Twilio vs WA Link
You have three primary paths to connect Django to WhatsApp. Each has distinct trade-offs.
1. Direct Meta Cloud API: This is the cheapest route. You pay Meta’s raw wholesale rates with zero markup. You write your own Python code to hit Meta's endpoints. The downside is that you must manage token rotation and handle raw JSON payloads yourself.
2. Twilio WhatsApp API: Twilio provides a developer-friendly Python SDK. However, Twilio adds an extra flat fee of $0.005 per message on top of Meta's standard template charge. In price-sensitive markets like India and Pakistan, this extra fee can double your messaging bill. For high-volume OTPs, Twilio is rarely a smart financial decision.
3. WA Link: We at WA Link provide a streamlined gateway designed to bypass the complexity of Meta's raw developer console for standard customer communication. However, we do not handle Meta's strict, direct authentication templates for security-critical OTPs. If you need raw, high-volume transactional OTPs with copy-code buttons, going direct to Meta's Cloud API is your best choice. We recommend using our service for customer support and utility notifications instead.
Setting Up the Meta Developer Console
To write any Django code, you must first register your app with Meta. Follow these exact steps:
- Go to developers.facebook.com and sign in with your Facebook account.
- Click My Apps and select Create App. Choose the "Other" option, then select "Business" as the app type.
- Add your app name and select your Meta Business Account. If you do not have one, let Meta create a default one for you.
- Scroll down to the WhatsApp product card and click Set Up.
- Meta will provide a temporary access token and a test phone number. Write down the Phone Number ID (this is different from your actual phone number).
Creating the Authentication Template
You cannot send raw text like "Your code is 1234" via WhatsApp. You must submit a template for approval. Go to your WhatsApp Manager inside the Meta Business Suite, navigate to Message Templates, and click Create Template.
Select Authentication as your category. Choose One-Time Password. You must configure a button. Choose either "Copy code" or "One-tap autofill". The "Copy code" option is the most reliable for web apps built with Django.
Once submitted, Meta's automated system usually approves authentication templates within two minutes.
The Django Implementation
Do not install heavy, third-party Django packages that claim to manage WhatsApp. They frequently break when Meta updates its Graph API version. Instead, write a clean, native implementation using the standard requests library.
First, add your credentials to your settings.py file. Never hardcode these values; pull them from environment variables.
# settings.py
import os
WHATSAPP_ACCESS_TOKEN = os.getenv("WHATSAPP_ACCESS_TOKEN")
WHATSAPP_PHONE_NUMBER_ID = os.getenv("WHATSAPP_PHONE_NUMBER_ID")
WHATSAPP_TEMPLATE_NAME = "your_approved_otp_template_name"
Next, create a helper module to handle the API call. We will use Django's built-in cache framework to store the generated OTP for validation. This avoids hitting your primary database every time a user requests a code.
# utils.py
import random
import requests
from django.conf import settings
from django.core.cache import cache
def generate_otp(phone_number):
"""Generates a 6-digit OTP and stores it in cache for 5 minutes."""
otp = str(random.randint(100000, 999999))
cache_key = f"otp_{phone_number}"
cache.set(cache_key, otp, timeout=300) # 300 seconds = 5 minutes
return otp
def send_whatsapp_otp(phone_number, otp):
"""Sends the OTP using Meta's Cloud API."""
url = f"https://graph.facebook.com/v20.0/{settings.WHATSAPP_PHONE_NUMBER_ID}/messages"
headers = {
"Authorization": f"Bearer {settings.WHATSAPP_ACCESS_TOKEN}",
"Content-Type": "application/json"
}
# Meta authentication templates require the OTP to be passed
# as a parameter for both the body text and the button.
payload = {
"messaging_product": "whatsapp",
"recipient_type": "individual",
"to": phone_number,
"type": "template",
"template": {
"name": settings.WHATSAPP_TEMPLATE_NAME,
"language": {
"code": "en_US"
},
"components": [
{
"type": "body",
"parameters": [
{
"type": "text",
"text": otp
}
]
},
{
"type": "button",
"sub_type": "url",
"index": "0",
"parameters": [
{
"type": "text",
"text": otp
}
]
}
]
}
}
response = requests.post(url, json=payload, headers=headers)
return response
Now, write the Django view to handle the user's request. This view expects a POST request containing the phone number in international format (for example, 923001234567 for Pakistan or 919876543210 for India).
# views.py
import json
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from .utils import generate_otp, send_whatsapp_otp
@csrf_exempt
def request_otp_view(request):
if request.method != "POST":
return JsonResponse({"error": "Only POST requests allowed"}, status=405)
try:
data = json.loads(request.body)
phone_number = data.get("phone_number")
except json.JSONDecodeError:
return JsonResponse({"error": "Invalid JSON"}, status=400)
if not phone_number:
return JsonResponse({"error": "Phone number is required"}, status=400)
# Simple validation for international format without the plus sign
if not phone_number.isdigit() or len(phone_number) < 10:
return JsonResponse({"error": "Invalid phone number format. Use country code without +"}, status=400)
otp = generate_otp(phone_number)
response = send_whatsapp_otp(phone_number, otp)
if response.status_code == 200:
return JsonResponse({"success": "OTP sent successfully"})
else:
# Log the error response from Meta for debugging
print(f"Meta API Error: {response.text}")
return JsonResponse({"error": "Failed to send OTP via WhatsApp"}, status=500)
To verify the OTP entered by the user, create a simple validation view that checks the cache.
# views.py (continued)
@csrf_exempt
def verify_otp_view(request):
if request.method != "POST":
return JsonResponse({"error": "Only POST requests allowed"}, status=405)
try:
data = json.loads(request.body)
phone_number = data.get("phone_number")
user_otp = data.get("otp")
except json.JSONDecodeError:
return JsonResponse({"error": "Invalid JSON"}, status=400)
if not phone_number or not user_otp:
return JsonResponse({"error": "Phone number and OTP are required"}, status=400)
cache_key = f"otp_{phone_number}"
cached_otp = cache.get(cache_key)
if cached_otp is None:
return JsonResponse({"error": "OTP expired or never requested"}, status=400)
if cached_otp == str(user_otp).strip():
# Clear the OTP immediately after successful verification
cache.delete(cache_key)
return JsonResponse({"success": "OTP verified successfully"})
else:
return JsonResponse({"error": "Incorrect OTP"}, status=400)
When WhatsApp OTP is the Wrong Choice
WhatsApp is not a complete replacement for SMS. If you run a pure WhatsApp-only authentication flow, you will lock out a percentage of your user base.
First, think about internet connectivity. WhatsApp requires an active data connection (Wi-Fi, 3G, or 4G). In rural parts of Pakistan and India, or inside concrete buildings, users might have standard GSM cellular signals but zero mobile data. They can receive a basic SMS, but their WhatsApp app will stay offline, leaving them unable to log in.
Second, consider users who do not use WhatsApp. While the app is incredibly popular in South Asia, some corporate users, older demographics, or privacy-conscious individuals do not have it installed.
The correct architecture is a hybrid fallback flow:
- The user enters their phone number on your Django site.
- Your system attempts to send a WhatsApp OTP first.
- Show a countdown timer of 45 seconds on the frontend.
- If the user does not receive the code within 45 seconds, display a button: "Send via SMS instead".
- If they click it, trigger a standard SMS gateway API call.
This hybrid approach keeps your delivery rates close to 100% while cutting your SMS bill by 75% to 80%, as most users will successfully verify via WhatsApp.
Real-World Gotchas to Avoid
If you deploy this code to production, you will eventually run into Meta's strict security and rate-limiting policies. Here is what you need to prepare for:
The Webhook Requirement
Meta's Cloud API is asynchronous. When you send a POST request to Meta, they return a "success": true response almost instantly. This only means Meta accepted your request. It does not mean the message was delivered. If the recipient's phone is switched off, or if their number is not registered on WhatsApp, the message will fail.
To know if the OTP was actually delivered, you must set up a Django webhook view. Meta will send POST requests to this webhook with status updates: sent, delivered, and failed. If you get a failed status with error code 131026 (Receiver is not a valid WhatsApp user), you should immediately trigger your SMS fallback automated system.
The "Unverified Business" Ban
If you start sending more than 200 OTPs a day without verifying your Meta Business Manager, your phone number will be flagged and temporarily restricted. Meta's automated algorithms are highly sensitive to sudden spikes in outbound messages. Do not launch your marketing campaign or user acquisition drive until your Business Manager displays a green "Verified" badge.
Frequently Asked Questions
Can I use my personal WhatsApp number for sending OTPs?
No. Meta does not allow personal numbers or WhatsApp Business App numbers to use the Cloud API. You must register a clean phone number that is not currently linked to a personal WhatsApp account. If you try to use a personal number via unofficial web automation tools, Meta will ban your number permanently within hours.
What happens if a user replies to the OTP message?
Because you are using an official business API, users can type a reply. If they do, Meta will send a webhook payload to your Django server. You should set up your webhook to ignore these replies or send an automated message back saying: "This is an automated verification channel. Please do not reply."
How long does Meta take to approve the template?
Authentication templates are processed by Meta's automated AI scanner. They are usually approved in under three minutes, provided you do not alter the standard template structure or add marketing text inside the body parameters.