cd /blog
GET/blog/whatsapp-otp-verification-python—200OK
pythonsecuritytutorial

How to Send WhatsApp OTP Codes (and Verify Them Safely)

September 28, 2026|4 min read

Sending a code over WhatsApp is one API call. Building verification that holds up is the rest of the work, and it is where most examples stop short. This guide covers both, using the official Python SDK.

Why WhatsApp for verification codes

Your users already have it open, the message arrives in a chat they trust, and with WSAPI there is no fee per message, so resending a code costs you nothing. The catch is that not every number has WhatsApp, so a good implementation checks first and falls back.

The implementation

This needs wsapi-client 3.1.0 or later, which you can install with pip install "wsapi-client>=3.1.0". Earlier versions do not accept the shorter import used below.

otp.py
import hashlib
import hmac
import os
import secrets
import time

from wsapi_client import WSApiClient
from wsapi_client.models import MessageSendTextRequest

client = WSApiClient(os.environ["WSAPI_API_KEY"], os.environ["WSAPI_INSTANCE_ID"])
PEPPER = os.environ["OTP_PEPPER"].encode()   # server-side secret, never stored with the codes

CODE_TTL = 300        # seconds
MAX_ATTEMPTS = 5


def _digest(code: str) -> str:
    return hmac.new(PEPPER, code.encode(), hashlib.sha256).hexdigest()


def send_code(phone: str, store: dict) -> bool:
    """Returns False when the number has no WhatsApp, so the caller can fall back to SMS or email."""
    if not client.users.check(phone).is_in_whats_app:
        return False

    code = f"{secrets.randbelow(1_000_000):06d}"
    store[phone] = {"digest": _digest(code), "expires": time.time() + CODE_TTL, "attempts": 0}

    client.messages.send_text(MessageSendTextRequest(
        to=f"{phone}@s.whatsapp.net",
        text=f"Your verification code is {code}. It expires in 5 minutes.",
    ))
    return True


def verify_code(phone: str, code: str, store: dict) -> bool:
    entry = store.get(phone)
    if entry is None or time.time() > entry["expires"] or entry["attempts"] >= MAX_ATTEMPTS:
        return False

    entry["attempts"] += 1
    if hmac.compare_digest(entry["digest"], _digest(code)):
        del store[phone]          # one use only
        return True
    return False

The store here is a dict to keep the example short. In production it is Redis or a database table, with the same fields.

The decisions that matter

Check the number before sending. users.check() tells you whether the phone has WhatsApp. If it does not, send_code returns False instead of silently sending into nothing, and your login flow offers SMS or email. Skipping this check is how users end up waiting for a code that is never coming.

Use secrets, not random. The random module is predictable by design. For anything that grants access, secrets.randbelow is the one to use.

Never store the code itself. A six digit code has only a million possible values, so a plain SHA-256 of it can be reversed in seconds if your database leaks. Keying the hash with a server-side secret (the PEPPER, kept in your environment and not in the same database) closes that gap.

Limit attempts, expire quickly, and allow one use. Five tries and five minutes turns guessing into a losing game, and deleting the entry on success stops a code from being replayed.

Compare in constant time. hmac.compare_digest takes the same time whether the first digit matches or not, so response timing leaks nothing.

What this example deliberately leaves out

Rate limiting on requests for a code. Without it, anyone can make your system send a stream of codes to someone else's phone. Cap it per phone number and per IP before this goes live. A reasonable starting point is one code per number every 60 seconds and no more than five per hour, tightened if you see abuse. How you enforce it depends on your framework, which is why it is not in the snippet, but it is not optional.

Going further

If you are new to the Python SDK, start with our guide to sending WhatsApp messages from Python. For how WSAPI handles keys, isolation, and webhook signing on its side, see the security overview.

To try this end to end, the quickstart connects a number in a few minutes, and pricing starts at $5.00 per instance per month with a 14 day trial and no credit card.

FAQ

Is there a fee per verification code?

No. WSAPI does not charge per message, so failed attempts and resends cost nothing extra.

What if the user does not have WhatsApp?

send_code returns False. Send the code by SMS or email instead, using the same storage and verification logic.

Can my WhatsApp number get blocked?

Any unofficial API carries that risk, which is one more reason to keep a fallback channel for login codes.