Build a WhatsApp OTP flow with Django and EnoSend in 30 minutes

SMS OTP delivery in West Africa has gotten worse, not better, over the past few years. Carriers throttle, smart-routers drop, and the per-message cost in Naira or Cedis adds up quickly when you're verifying thousands of users a month.

WhatsApp OTPs solve all three problems at once: delivery is near-instant, you're sending from a real WhatsApp number your user already trusts, and EnoSend's flat plan means you're not paying per OTP at all.

Here's a complete Django implementation. About 30 minutes of work end to end if you've used Django before.

0. One-time setup

Before any code: create an API key at /whatsapp/api-keys/ (it starts with wa_live_ — copy it once), then connect a WhatsApp number by scanning the QR. Grab the resulting instance ID from the dashboard. You'll need both:

# .env
ENOSEND_API_KEY=wa_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxx
ENOSEND_INSTANCE_ID=01HXXXXXXXXXXXXXXXXXXXXXXX
ENOSEND_API_BASE=https://api.enosend.com

1. The model

# otp/models.py
import secrets
from django.db import models
from django.utils import timezone

class OneTimeCode(models.Model):
phone = models.CharField(max_length=20, db_index=True)
code_hash = models.CharField(max_length=64) # SHA-256 of the 6-digit code
expires_at = models.DateTimeField(db_index=True)
attempts = models.PositiveSmallIntegerField(default=0)
used_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)

@classmethod
def issue(cls, phone: str, ttl_minutes: int = 10) -&gt; tuple["OneTimeCode", str]:
# 6-digit code, plenty for OTP — entropy comes from server-side hashing + rate limits.
code = f"{secrets.randbelow(10**6):06d}"
obj = cls.objects.create(
phone=phone,
code_hash=cls._hash(code),
expires_at=timezone.now() + timezone.timedelta(minutes=ttl_minutes),
)
return obj, code

@staticmethod
def _hash(code: str) -&gt; str:
import hashlib
return hashlib.sha256(code.encode()).hexdigest()

def verify(self, code: str) -&gt; bool:
if self.used_at or timezone.now() &gt; self.expires_at or self.attempts &gt;= 5:
return False
self.attempts += 1
ok = self.code_hash == self._hash(code)
if ok:
self.used_at = timezone.now()
self.save(update_fields=["attempts", "used_at"])
return ok

The OTP code itself is never stored — only the hash. Even a stolen database dump doesn't leak active codes.

2. The send view

# otp/views.py
import os
import requests
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from django_ratelimit.decorators import ratelimit
from .models import OneTimeCode

API_BASE = os.environ["ENOSEND_API_BASE"] # e.g. https://api.enosend.com
INSTANCE = os.environ["ENOSEND_INSTANCE_ID"]
API_KEY = os.environ["ENOSEND_API_KEY"] # wa_live_...

@csrf_exempt
@require_POST
@ratelimit(key="post:phone", rate="3/h", block=True)
def send_otp(request):
phone = request.POST.get("phone", "").strip()
if not phone.startswith("+"):
return JsonResponse({"error": "phone must be E.164 with +"}, status=400)

otp, code = OneTimeCode.issue(phone)

# EnoSend wants the number in E.164 *without* the leading "+".
resp = requests.post(
f"{API_BASE}/instances/{INSTANCE}/messages/text",
headers={"Authorization": f"Bearer {API_KEY}"},
json={
"number": phone.lstrip("+"),
"text": f"Your verification code is {code}. It expires in 10 minutes.",
},
timeout=10,
)
data = resp.json()
if not data.get("ok"):
return JsonResponse({"error": "send failed", "detail": data}, status=502)

return JsonResponse({
"ok": True,
"expires_in": 600,
"message_id": data.get("message_id"),
})

3. The verify view

@csrf_exempt
@require_POST
@ratelimit(key="post:phone", rate="5/h", block=True)
def verify_otp(request):
phone = request.POST.get("phone", "").strip()
code = request.POST.get("code", "").strip()

otp = (
OneTimeCode.objects
.filter(phone=phone, used_at__isnull=True)
.order_by("-created_at")
.first()
)
if not otp or not otp.verify(code):
return JsonResponse({"ok": False}, status=400)

# ... mark the user as verified, issue a session, etc.
return JsonResponse({"ok": True})

4. The gotchas

Phone number normalisation

Users will type their number a dozen different ways. Normalise to E.164 (+233200000000) at the boundary, use the phonenumbers library — before storing. EnoSend's /messages/text endpoint then wants the same number without the leading + (233200000000), which is what the phone.lstrip("+") above is doing. Get this wrong and a user who signed up with 0200000000 can't be matched against an OTP sent to +233200000000.

Confirm delivery with the message_id

A 200 from /messages/text means EnoSend accepted the send — not that WhatsApp delivered it. For high-value flows, take the message_id from the response and poll GET /messages/&lt;message_id&gt; for a few seconds until status=delivered. If it stays queued or flips to failed, fall back to SMS or surface a "didn't get it?" button to the user.

Keep your instance connected

EnoSend sends from a real WhatsApp number you've linked by QR. If that phone is offline for too long, the session disconnects and sends start failing. Subscribe to the instance.state webhook (signed, HMAC-verified) so you get a heads-up when your number drops, and re-pair from the dashboard before users start hitting failed OTPs.

Don't log the code

Obvious, but worth saying — keep the actual code out of structured logs, exception reports, and database query logs. Hash early, log the hash if you must.

Rate-limit by IP and phone

The example above limits by phone. In a hostile environment add a second limit by IP, otherwise a single attacker can rotate through phone numbers to bypass.

What you've built

A WhatsApp OTP flow that's faster than SMS, cheaper than SMS, and harder to abuse than a naïve implementation. Total cost on EnoSend: nothing per OTP — included in your flat plan.

If you want the same flow in FastAPI, Express, or Laravel, the shape is identical — the EnoSend API is plain HTTP. See the developer docs for the full endpoint reference