February 26, 2026

How We Built Native WhatsApp OTP Into Wasel Without Managing a Single Template

Discover how Wasel built a zero-configuration WhatsApp OTP API. Learn about Meta's Authentication templates, auto-provisioning architecture, and why it's faster and safer than SMS.

WhatsApp OTP WhatsApp API Authentication Engineering Security
How We Built Native WhatsApp OTP Into Wasel Without Managing a Single Template

How We Built Native WhatsApp OTP Into Wasel Without Managing a Single Template

By the Wasel Engineering Team · February 2026


One-time passwords via SMS have been the default for phone verification for decades. They work, but they have real problems: delivery delays, international costs, SIM-swap fraud, and users switching between apps to copy a 6-digit code.

WhatsApp changes all of this. With over 2 billion active users — and near-100% penetration in markets like Morocco, Algeria, and the broader MENA region — WhatsApp OTP delivers codes through a channel your users already have open all day. And Meta’s AUTHENTICATION template type makes the experience even better: a native “Copy Code” button copies the code directly to the clipboard so users never have to read or type anything.

This post explains how we built WhatsApp OTP into Wasel’s external API, the decisions we made along the way, and why the result is simpler for developers to integrate than most alternatives.


The Meta Authentication Template — What It Actually Is

Before building anything, we spent time reading Meta’s documentation carefully, because there’s a widespread misconception: WhatsApp OTP templates are NOT pre-built or shared by Meta. Every WhatsApp Business Account (WABA) must register their own.

But here’s the key insight: the body text is completely standardized and locked by Meta. You cannot write custom text. Every authentication template in every language says the same thing:

{{1}} is your verification code. For your security, do not share this code.

You control only two optional additions:

  • A security recommendation footer (always shown by default)
  • A code expiration notice (This code expires in N minutes.)

The button type is also fixed to one of three options:

  • COPY_CODE — the user taps “Copy code” and the code goes to clipboard (most common)
  • ONE_TAP — autofills into your native Android app via broadcast receiver
  • ZERO_TAP — fully silent delivery to your native app

And critically: AUTHENTICATION templates skip the normal review process and are auto-approved instantly by Meta. You create them and they’re live immediately.

This changes the architecture completely.


The Wrong Approach: Asking Developers to Manage Templates

Our first design had developers pass a template_name parameter:

POST /external/v1/otp/send
{
"phone": "+212600000000",
"template_name": "my_otp_template",
"lang": "fr"
}

This was wrong for several reasons:

  1. The developer doesn’t know what template to create. They’d need to read Meta docs, learn the AUTHENTICATION component format, create the template in WhatsApp Manager, wait for approval, and then reference it by name.
  2. There’s nothing to customize anyway. The body is fixed by Meta. The template name is irrelevant to the user’s experience.
  3. Each language is a separate template. An app supporting 3 languages needs 3 templates, and the developer would need to track and pass the right name.

The right approach is zero-configuration OTP — the developer passes a phone and a language, and everything else is automatic.


The Auto-Provisioning Architecture

We fixed a single internal template name: wasel_otp. This name is never exposed to the developer. Here’s what happens on the first call:

Developer: POST /otp/send { "phone": "+212600000000", "lang": "fr" }
Wasel:
1. Check DB — does org #42 have a "wassel_otp" template in "fr"? No.
2. Call Meta Graph API: POST /<WABA_ID>/message_templates
{ "name": "wassel_otp", "category": "AUTHENTICATION", "language": "fr",
"components": [BODY+FOOTER+COPY_CODE_BUTTON] }
3. Meta auto-approves → returns template ID
4. Save to MessageTemplate table (org_id=42, lang=fr, status=approved)
5. Generate 6-digit code
6. Send template message to +212600000000 with code as parameter
7. Save WhatsAppOtp record (status=pending, expires_in=10min)
8. Return { otp_id, expires_at } to developer

On every subsequent call (same org, same language): step 1 returns a DB hit, steps 2–4 are skipped entirely. The first call takes ~1 second extra, all subsequent calls are instant.

When Meta already has the template but our DB doesn’t (e.g. after a data migration), we catch the “already exists” error and write the local record anyway — no crashing, no duplicates.


The OTP Lifecycle

Each OTP goes through a state machine:

pending → verified (code matched, within TTL, within attempt limit)
pending → expired (TTL passed before verification)
pending → failed (max attempts exceeded)
Any new send() for the same phone atomically expires all existing pending OTPs first.

The WhatsAppOtp table stores:

ColumnPurpose
org_idMulti-tenant isolation
phoneRecipient in E.164
codeThe generated code (hashed in production)
referenceCaller-provided session/user ID
statuspending · verified · expired · failed
attemptsWrong guesses so far
max_attemptsConfigurable limit (default 3)
expires_atComputed from ttl_minutes
wa_message_idMeta message ID for delivery tracking

The reference field is key for multi-session apps. If a user has two checkout flows open simultaneously, each gets a unique reference, and OTP lookups are scoped to it. Without reference, a verify call always targets the most recent pending OTP for the phone.


The Send Payload — What Actually Goes to Meta

When sending a COPY_CODE AUTHENTICATION template, the runtime payload to the Messages API is:

{
"messaging_product": "whatsapp",
"recipient_type": "individual",
"to": "+212600000000",
"type": "template",
"template": {
"name": "wassel_otp",
"language": { "code": "fr" },
"components": [
{
"type": "body",
"parameters": [{ "type": "text", "text": "847291" }]
},
{
"type": "button",
"sub_type": "COPY_CODE",
"index": "0",
"parameters": [{ "type": "coupon_code", "coupon_code": "847291" }]
}
]
}
}

Note that the code appears twice: once as the body variable {{1}} substitution, and again as the coupon_code parameter for the COPY_CODE button. This is the format Meta requires.

The user sees:

847291 is your verification code. For your security, do not share this code. This code expires in 10 minutes. [Copy code] ← tapping this copies “847291” to clipboard


The Developer API — As Simple As Possible

The final external API is three endpoints:

POST /external/v1/otp/send
POST /external/v1/otp/verify
GET /external/v1/otp/status

Sending:

Terminal window
curl -X POST https://api.wassel.app/external/v1/otp/send \
-H "X-API-Key: wsk_live_..." \
-H "Content-Type: application/json" \
-d '{"phone": "+212600000000", "reference": "checkout_abc123"}'
{
"success": true,
"otp_id": 42,
"phone": "+212600000000",
"expires_at": "2026-02-26T12:10:00Z",
"status": "pending"
}

Verifying:

Terminal window
curl -X POST https://api.wassel.app/external/v1/otp/verify \
-H "X-API-Key: wsk_live_..." \
-H "Content-Type: application/json" \
-d '{"phone": "+212600000000", "code": "847291", "reference": "checkout_abc123"}'
{
"success": true,
"verified": true,
"reason": "verified",
"otp_id": 42
}

No template names, no WABA configuration, no Meta Developer Console. A developer with a Wassel API key can have WhatsApp OTP live in their app in under 10 minutes.


Security Considerations

Code generation. Codes are 6-digit numeric strings generated with random.choices(string.digits). For a 6-digit code, the brute force space is 1,000,000 combinations. With the default limit of 3 attempts before lockout, the probability of a successful brute force attack per OTP is 3/1,000,000 = 0.0003%.

Attempt limiting. Each wrong guess increments the attempts counter. When attempts > max_attempts, the OTP status transitions to failed and it can no longer be used — a fresh OTP must be requested.

Expiry. Expired OTPs are rejected without incrementing the attempt counter (there’s nothing to exploit in a dead code). The expiry check happens before the attempt check.

Invalidation on resend. When a new OTP is requested for the same phone, all previous pending OTPs for that phone are atomically set to expired. This prevents replay attacks using a previously valid code that hadn’t been verified yet.

Reference scoping. When reference is provided, the OTP lookup is scoped to that reference. This prevents cross-session collisions in multi-flow applications.

Delivery channel integrity. The code is delivered exclusively via WhatsApp, which requires the physical device and the associated SIM card. This is inherently more secure than SMS (no SS7 protocol vulnerabilities) and than email (which may be accessible from untrusted devices).


What’s Next

  • Fraud detection hooks — flagging phone numbers with high OTP request volume
  • Webhook on verify — push a notification to your backend automatically when a code is verified, so you don’t need to poll
  • One-tap autofill — for teams building native Android apps, we’re working on support for the ONE_TAP button type which delivers the code directly to the app without clipboard

Try It

WhatsApp OTP is available on all Wassel plans. Generate an API key from your dashboard and follow the Frontend Integration Guide to go live.

For questions, reach us at tech@wasel.ma or open an issue in the developer community.