If you've priced out WhatsApp OTP login for your website, you've probably seen monthly platform fees on top of per-message charges from providers like Twilio, MSG91, Interakt, AiSensy, or 360dialog. I've personally integrated all of these for clients at one point or another. What I found, repeatedly, is that once you understand Meta's own Cloud API, most of that provider markup is optional — not necessary.
This guide walks through exactly how to build WhatsApp OTP login yourself, directly against Meta Cloud API, with real PHP and Laravel code, production security practices, and the mistakes I've made so you don't have to repeat them.
Table of contents
- What is WhatsApp OTP?
- Why businesses prefer WhatsApp OTP
- SMS OTP vs WhatsApp OTP
- Why many businesses spend more than necessary
- How third-party providers work
- Direct Meta Cloud API architecture
- Required accounts and setup
- Authentication templates
- Permanent access tokens
- Webhook verification
- Database design
- PHP implementation
- Laravel implementation
- Security best practices
- Production deployment tips
- Common Meta errors and troubleshooting
- Real project experience
- Cost comparison
- Real client examples
- Frequently asked questions
What is WhatsApp OTP?
WhatsApp OTP is a one-time password sent to a user's WhatsApp number instead of by SMS or email, used to verify a login, a signup, or a sensitive action like a password reset. The user receives a code inside WhatsApp, types it into your website, and your backend checks it against what it generated.
Functionally, it's identical to SMS OTP from the user's point of view. The difference is entirely in the delivery channel, the cost structure, and — in my experience — the delivery reliability.
Why businesses prefer WhatsApp OTP
Across the projects I've worked on, three reasons come up constantly when clients ask for WhatsApp OTP instead of SMS:
- Delivery reliability. SMS OTPs are frequently delayed or silently dropped by mobile carriers, especially for DND-registered numbers in India. WhatsApp messages don't pass through the same carrier filtering.
- Cost. In most markets I've billed for, WhatsApp authentication conversations cost less than an equivalent SMS, particularly for international numbers where SMS costs spike.
- User trust. Users increasingly recognize and trust a verified WhatsApp Business message more than an SMS from an unknown short code.
SMS OTP vs WhatsApp OTP
| Factor | SMS OTP | WhatsApp OTP |
|---|---|---|
| Typical delivery speed | Seconds to minutes, carrier-dependent | Near-instant while WhatsApp is online |
| Delivery reliability | Affected by DND/carrier filtering | Not subject to telecom DND filtering |
| Cost per message (typical) | Higher in most markets | Lower for domestic authentication conversations |
| Requires internet | No | Yes |
| Works without WhatsApp installed | Yes | No — needs fallback |
Neither channel is strictly better. In my implementations, the most resilient setup sends WhatsApp OTP first and falls back to SMS only if the WhatsApp message fails to deliver, which the Cloud API webhook will tell you.
Why many businesses spend more than necessary
While implementing OTP systems for different clients, I noticed a consistent pattern: businesses sign up with a third-party OTP provider because it's the fastest way to get started, then never revisit that decision once volume grows. The provider's dashboard, quick-start SDKs, and support are genuinely valuable at day one — but that same convenience keeps costing a recurring markup on every single message, indefinitely.
None of this is criticism of those providers — they built real infrastructure and support that has value, especially for teams without in-house developers. But if you already have a PHP or Laravel developer on your team, or are paying one anyway for your website, the marginal cost of removing the middle layer is small compared to what it saves over the life of the product.
How third-party providers work
Providers like Twilio, MSG91, Interakt, AiSensy, and 360dialog are, at the infrastructure level, all built on top of the same Meta WhatsApp Business Platform. They act as a Meta Business Solution Provider (BSP), meaning they already hold the underlying relationship with Meta and resell access to it through their own dashboard, SDKs, and billing.
You send your OTP request to their API, they translate it into a Meta Cloud API call using their own Meta connection, and they bill you their rate — which includes Meta's underlying cost plus their margin for the tooling and support layered on top.
Direct Meta Cloud API architecture
Cutting out the middle layer means your own server talks to Meta directly. The flow looks like this:
User enters phone number
↓
Your Website
↓
Generate 6-digit OTP
↓
Hash OTP → Save to Database
↓
Call Meta Cloud API (Authentication Template)
↓
WhatsApp
↓
User receives OTP, enters it on your site
↓
Your Website verifies hash against Database
↓
Login / Signup complete
Everything after "Generate 6-digit OTP" happens on infrastructure you control. There's no third-party billing layer between your server and Meta.
Required accounts and setup
Before writing any code, you need the following set up in this order:
- A Meta Business Account (business.facebook.com)
- A WhatsApp Business Platform app created inside Meta for Developers
- A dedicated business phone number, verified via SMS or voice call through Meta, not currently active on regular WhatsApp or WhatsApp Business consumer apps
- At least one approved Authentication template
- A System User with a permanent access token generated in Meta Business Settings
- A publicly reachable HTTPS webhook URL on your own server
Phone number verification
In practice, the phone number verification step is usually the fastest part — Meta sends a code via SMS or automated call to confirm you control the number. The number then becomes permanently tied to that WhatsApp Business Account and can't be reused on the personal WhatsApp app afterward.
Authentication templates
Meta requires OTP messages to use a specific template category called Authentication. Unlike marketing or utility templates, authentication templates have a restricted, simplified format specifically designed to prevent misuse — you can't add promotional text, images, or extra body copy.
A typical approved authentication template body looks like this:
Your verification code is {{1}}. Do not share this code with anyone.
One challenge I repeatedly encountered early on: submitting a template with extra branding language ("Welcome to [Company]! Your code is...") gets rejected, because Meta's authentication category strictly limits the template to the verification code and a security warning. Keep it minimal and it approves quickly.
Permanent access tokens
Meta's default access tokens generated from the API Setup screen expire in 24 hours, which is unusable for a production server. The fix is creating a System User inside Meta Business Settings, assigning it to your WhatsApp Business app with the correct permissions, and generating a token from that System User instead — these don't expire on the same 24-hour cycle.
I found that this step is where most first-time integrations get stuck: people copy the temporary token from the quick-start page, it works for a day, then silently breaks in production. Always generate your production token from a System User, not the quick-start screen.
Webhook verification
To receive delivery status updates (sent, delivered, read, failed) and inbound replies, Meta requires a webhook endpoint on your server. Setting it up involves a verification handshake:
// webhook.php — GET verification handshake
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$verifyToken = 'your-own-secret-string';
if (($_GET['hub_verify_token'] ?? '') === $verifyToken) {
echo $_GET['hub_challenge'] ?? '';
exit;
}
http_response_code(403);
exit;
}
Once verification succeeds, Meta starts POSTing JSON payloads to the same URL for every status event. In production, this is what tells you whether an OTP actually reached the user's device — critical for deciding when to trigger an SMS fallback.
Database design
Every OTP implementation I've built uses some version of this table. Each field earns its place:
| Field | Purpose |
|---|---|
| id | Primary key |
| phone | The recipient's phone number in E.164 format |
| otp_hash | The bcrypt hash of the OTP — never the plain code |
| expires_at | Timestamp after which this OTP is no longer valid, typically now + 5 minutes |
| attempts | Number of failed verification attempts made against this record |
| verified | Boolean flag set to true once successfully verified, preventing replay |
| created_at / updated_at | Standard audit timestamps |
CREATE TABLE otp_verifications (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
phone VARCHAR(20) NOT NULL,
otp_hash VARCHAR(255) NOT NULL,
expires_at DATETIME NOT NULL,
attempts TINYINT UNSIGNED NOT NULL DEFAULT 0,
verified TINYINT UNSIGNED NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_phone (phone)
);
PHP implementation
Here's the core logic, broken into the steps a login flow actually needs. I'm using plain PDO here so it's portable to any PHP project, Laravel or not.
1. Generate the OTP
function generateOtp(): string {
return str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
}
random_int() is cryptographically secure — never use rand() or mt_rand() for anything security-related.
2. Hash and store it
$otp = generateOtp();
$hash = password_hash($otp, PASSWORD_BCRYPT);
$stmt = $pdo->prepare(
"INSERT INTO otp_verifications (phone, otp_hash, expires_at, attempts, verified)
VALUES (?, ?, ?, 0, 0)"
);
$stmt->execute([$phone, $hash, date('Y-m-d H:i:s', time() + 300)]);
3. Send it via the Authentication Template
function sendWhatsAppOtp(string $phone, string $otp, string $phoneNumberId, string $accessToken): array {
$url = "https://graph.facebook.com/v20.0/{$phoneNumberId}/messages";
$payload = [
'messaging_product' => 'whatsapp',
'to' => $phone,
'type' => 'template',
'template' => [
'name' => 'otp_login',
'language' => ['code' => 'en_US'],
'components' => [
['type' => 'body', 'parameters' => [['type' => 'text', 'text' => $otp]]],
],
],
];
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer {$accessToken}",
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => json_encode($payload),
]);
$response = curl_exec($ch);
curl_close($ch);
return json_decode($response, true) ?? [];
}
4. Verify what the user enters
$stmt = $pdo->prepare(
"SELECT * FROM otp_verifications WHERE phone = ? ORDER BY id DESC LIMIT 1"
);
$stmt->execute([$phone]);
$record = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$record || $record['verified']) {
// no active OTP for this number
} elseif (strtotime($record['expires_at']) < time()) {
// expired — ask the user to request a new one
} elseif ($record['attempts'] >= 5) {
// too many attempts — block further tries on this record
} elseif (password_verify($enteredOtp, $record['otp_hash'])) {
$pdo->prepare("UPDATE otp_verifications SET verified = 1 WHERE id = ?")
->execute([$record['id']]);
// proceed to log the user in
} else {
$pdo->prepare("UPDATE otp_verifications SET attempts = attempts + 1 WHERE id = ?")
->execute([$record['id']]);
// incorrect code
}
5. Resend with a cooldown
$stmt = $pdo->prepare(
"SELECT created_at FROM otp_verifications WHERE phone = ? ORDER BY id DESC LIMIT 1"
);
$stmt->execute([$phone]);
$last = $stmt->fetchColumn();
if ($last && (time() - strtotime($last)) < 60) {
// reject — must wait before resending
}
// otherwise, repeat steps 1-3 to issue a new OTP
Laravel implementation
In Laravel, I split this into a controller, a service class, a model, and a queued job — never one fat controller method. Here's the architecture, not a full application:
Model
class OtpVerification extends Model
{
protected $fillable = ['phone', 'otp_hash', 'expires_at', 'attempts', 'verified'];
protected $casts = ['expires_at' => 'datetime', 'verified' => 'boolean'];
}
Service class
class WhatsAppOtpService
{
public function generateAndSend(string $phone): void
{
$otp = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
OtpVerification::create([
'phone' => $phone,
'otp_hash' => Hash::make($otp),
'expires_at' => now()->addMinutes(5),
]);
SendWhatsAppOtpJob::dispatch($phone, $otp);
}
public function verify(string $phone, string $code): bool
{
$record = OtpVerification::where('phone', $phone)
->where('verified', false)
->latest('id')
->first();
if (!$record || $record->expires_at->isPast() || $record->attempts >= 5) {
return false;
}
if (Hash::check($code, $record->otp_hash)) {
$record->update(['verified' => true]);
return true;
}
$record->increment('attempts');
return false;
}
}
Queued job
Sending the WhatsApp message goes through a queue, not inline in the request, so a slow Meta API response never delays the user's page load:
class SendWhatsAppOtpJob implements ShouldQueue
{
use Queueable, Dispatchable, InteractsWithQueue, SerializesModels;
public function __construct(protected string $phone, protected string $otp) {}
public function handle(WhatsAppClient $client): void
{
$client->sendTemplate($this->phone, 'otp_login', [$this->otp]);
}
}
Controller, routes, validation, and middleware
// routes/web.php
Route::post('/otp/send', [OtpController::class, 'send'])
->middleware('throttle:5,1'); // 5 requests per minute per IP
Route::post('/otp/verify', [OtpController::class, 'verify'])
->middleware('throttle:10,1');
class OtpController extends Controller
{
public function send(SendOtpRequest $request, WhatsAppOtpService $service)
{
$service->generateAndSend($request->validated('phone'));
return response()->json(['message' => 'OTP sent']);
}
public function verify(VerifyOtpRequest $request, WhatsAppOtpService $service)
{
if (!$service->verify($request->validated('phone'), $request->validated('code'))) {
return response()->json(['message' => 'Invalid or expired code'], 422);
}
// log the user in / regenerate session here
return response()->json(['message' => 'Verified']);
}
}
Validation lives in dedicated FormRequest classes (SendOtpRequest, VerifyOtpRequest) so phone format and code format checks never leak into the controller itself. Laravel's built-in throttle middleware handles rate limiting at the route level, on top of the attempt limit stored in the database.
Security best practices
- Hash the OTP. Never store the raw 6-digit code — use
password_hash()/Hash::make()exactly as you would for a password. - Five-minute expiry. Long enough for a real user, short enough to limit exposure.
- Maximum retry attempts. Lock a record after 5 failed checks rather than allowing unlimited guesses.
- Rate limiting on send. Cap how many OTP requests a single phone number or IP can trigger per minute, independent of the attempt limit on verification.
- Replay protection. Mark a code as
verifiedimmediately on success so it can never be reused. - IP throttling. Combine phone-based and IP-based limits — a phone-only limit can still be abused across many numbers from one attacker.
- Phone number validation. Validate format and country code server-side before ever calling Meta's API, to avoid wasting send quota on malformed numbers.
- Audit logging. Log every send and verify event (without logging the plain OTP) for later investigation if abuse is suspected.
Production deployment tips
For shared hosting deployments — which is where a lot of my client work runs — a few things matter more than they would on a dedicated server:
- Confirm
cURLandopensslare enabled in your PHP configuration; most shared hosts have this by default, but it's worth checking on new accounts. - Your webhook URL must be served over HTTPS with a valid certificate — Meta will not send events to plain HTTP or self-signed certificates.
- Store your permanent access token in an environment variable or
.envfile, never hardcoded in a committed file. - If you don't have a queue worker available on shared hosting, sending the WhatsApp API call synchronously is acceptable at low volume — just set a short cURL timeout (3-5 seconds) so a slow Meta response doesn't hang the whole request.
Common Meta errors and troubleshooting
| Error | Likely cause |
|---|---|
| Error 131026 — Message undeliverable | Recipient's WhatsApp is inactive, or the number is not on WhatsApp at all |
| Error 132000 — Template does not exist | Template name/language code in your API call doesn't match an approved template exactly |
| Error 190 — Invalid access token | You're using a 24-hour temporary token instead of a System User's permanent token, or the token was revoked |
| Error 100 — Invalid parameter | Phone number not in E.164 format, or a missing required template parameter |
| Webhook not receiving events | Verification handshake wasn't completed correctly, or the URL isn't reachable over HTTPS from Meta's servers |
Real project experience
A few lessons that only became obvious after actually running this in production, not from reading Meta's documentation:
Template approval delays are unpredictable. While implementing this for one client, an authentication template sat in review for over 48 hours with no clear reason, then approved without any changes on our end. I now build in a buffer of at least 2-3 days before a planned launch, specifically for template approval.
Permanent token confusion is the single most common setup mistake I've seen — including in my own early integrations. The quick-start page in Meta for Developers hands you a token that looks permanent but expires in 24 hours, and it's easy to miss that distinction until production breaks the next day.
Webhook verification failures are almost always a subtle mismatch between the verify token in your code and the one entered in the Meta dashboard — a trailing space or case difference is enough to fail the handshake silently.
Testing against your own number before going live catches most issues early, but I always test with at least one number that has never messaged the business number before, since first-contact delivery sometimes behaves differently than delivery to an existing conversation thread.
Cost comparison
| Platform | Platform fee | Monthly fee | Flexibility | Developer control | Vendor lock-in | Best use case |
|---|---|---|---|---|---|---|
| Meta Cloud API (direct) | None | None | Full | Full | None | Teams with an in-house or freelance developer |
| Twilio | Per-message markup | Sometimes | High | Moderate | Low | Multi-channel messaging beyond just WhatsApp |
| MSG91 | Per-message markup | Sometimes | Moderate | Moderate | Moderate | Teams already using MSG91 for SMS |
| Interakt | Per-message markup | Yes | Moderate | Low | Higher | Non-technical teams wanting a full CRM-style dashboard |
| AiSensy | Per-message markup | Yes | Moderate | Low | Higher | Marketing-led teams needing broadcast + support tools |
| 360dialog | Small markup | Sometimes | High | High | Low | Developers wanting BSP support without a heavy dashboard layer |
None of these providers are a bad choice — they solve a real problem for teams that don't want to manage Meta Business Settings, template submissions, and webhook infrastructure themselves. The trade-off is simply cost versus convenience, and that trade-off shifts as your message volume grows.
Real client examples
Early-stage SaaS signup flow
A subscription-based SaaS startup was paying a third-party OTP provider a fixed monthly platform fee plus a per-message rate, which didn't make sense at their early signup volume of a few hundred verifications a month.
Migrated their signup verification directly onto Meta Cloud API, built the OTP generation, hashing, and verification logic in Laravel, and set up a queued job so OTP sending never blocked the signup request.
Removed the monthly platform fee entirely and reduced their per-verification cost, while keeping the same 5-minute expiry and attempt-limit security they had with the previous provider.
Custom PHP eCommerce checkout
An online store wanted to verify phone numbers at checkout to reduce fake orders and cash-on-delivery abandonment, but was hesitant to add a costly third-party OTP subscription to an already thin margin business.
Implemented WhatsApp OTP verification directly at the checkout phone number field using Meta Cloud API, with a fallback to SMS OTP for numbers where WhatsApp delivery failed, using the same otp_verifications table for both channels.
Fake and abandoned cash-on-delivery orders dropped noticeably after launch, with no added per-transaction platform fee beyond the raw Meta messaging cost.
Custom CRM client login portal
A CRM used by field staff and external clients needed passwordless login for non-technical users, but the client wanted to avoid recurring per-seat OTP provider costs as their user base grew.
Built a WhatsApp OTP-based passwordless login using Meta Cloud API and a permanent system-user token, with rate limiting on both the send and verify endpoints and audit logging of every OTP event for compliance.
Login friction dropped since users no longer needed to remember passwords, and the OTP cost stayed proportional to actual logins rather than a fixed per-seat license.
Closing thoughts
WhatsApp OTP login through Meta Cloud API isn't more complicated than going through a third-party provider — it's roughly the same amount of setup work, just with the accounts and configuration living directly under your own Meta Business Account instead of a reseller's dashboard. What you get in exchange is full control over cost, no recurring platform fee, and no dependency on a provider's uptime layered on top of Meta's own.
If you already have a developer capable of writing the PHP or Laravel side of this, the case for going direct is straightforward. If you don't, that's exactly the kind of project I take on.
