EnoSend
Home Features How it works Pricing Blog Documentation Contact Sign in Get started

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

A complete walkthrough: model, send view, verify view, rate-limiting, and the gotchas.

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) -> 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) -> str:
        import hashlib
        return hashlib.sha256(code.encode()).hexdigest()

    def verify(self, code: str) -> bool:
        if self.used_at or timezone.now() > self.expires_at or self.attempts >= 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/<message_id> 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

Frequently asked questions

Why hash the OTP instead of storing it plain?

So a database snapshot or backup doesn't leak active codes. The user-facing verification still works because we hash the user's input the same way and compare.

Do I need to get a template approved by Meta?

No. EnoSend sends from a real WhatsApp number you've linked by QR — there are no Cloud API templates to submit. You can send any text you like, the same way you would from your phone.

Can I let users retry the OTP?

Yes — the model caps attempts at 5 per code, and the rate-limit decorator caps OTP requests at 3 per hour per phone. Together they prevent both brute-force on the code and abuse of the send endpoint.