Add WhatsApp OTP Login to Your App (Node.js, Python, PHP)
Build WhatsApp OTP login two ways: let PayPerWA's Verify API generate, send, expire and check each code in two calls, or generate, hash and verify codes on your own server and send them through your approved authentication template. Node.js, Python and PHP examples for both, plus webhooks, resend limits and an SMS fallback.
Key Takeaways
- PayPerWA's Verify API handles the code in two calls: POST /api/v1/otp/send generates, sends and expires it, and POST /api/v1/otp/verify returns approved, incorrect, max_attempts, expired or not_found.
- If you prefer to own the codes, generate them on your server and send each one with POST /api/v1/messages/send as variables[0]; PayPerWA fills both the message body and the Copy Code button.
- Either way, use a 6-digit numeric code, a 10-minute expiry and a small, fixed number of attempts, and store only an HMAC hash if your server keeps the codes.
- The Verify API applies a 30-second resend cooldown, at most 5 sends per number per hour and no new code after 15 wrong codes in 24 hours, all before any charge, but CAPTCHA, per-IP limits and a country allow-list on your request form remain your job.
- In India each OTP costs Meta ₹0.115 + PayPerWA ₹0.20 with no subscription or DLT registration, verifying is free, and an SMS fallback needs your own DLT-registered SMS provider.
How WhatsApp OTP login works: who does what
WhatsApp OTP login has two halves: getting a one-time code to the user's WhatsApp, and checking the code they type back. PayPerWA gives you two ways to split them:
- The Verify API (shortest path). PayPerWA generates the code, sends it through your approved WhatsApp authentication template, stores only a keyed hash, expires it and checks it. Your server makes two calls,
POST /api/v1/otp/sendandPOST /api/v1/otp/verify, and never handles the code. - Do it yourself. Your server owns the secret: it generates the code, stores a hashed copy with an expiry and checks what the user types. PayPerWA only sends it, through the same
POST /api/v1/messages/sendcall you would use for any other template.
Both cost the same to send: in India, Meta ₹0.115 + PayPerWA ₹0.20 per OTP, with no subscription and no DLT registration, and checking a code with the Verify API is free. A complete login flow looks like this:
- The user enters their WhatsApp number and taps "Send code on WhatsApp".
- Your server runs its own checks (CAPTCHA, per-IP limits) and asks for a code. With the Verify API, PayPerWA generates a 6-digit code, stores its hash with a 10-minute expiry and returns a
verification_id; on the DIY path, your server checks its resend limits, generates the code and stores the hash itself. - PayPerWA sends the code and returns a
message_id, which is the WhatsApp message ID (wamid). - The user receives Meta's fixed message, for example "483920 is your verification code.", with a Copy Code button.
- If you have subscribed to webhook events, PayPerWA posts delivery events to your webhook, and you record them against the
message_id. - The user pastes the code. Your server checks it, with
/api/v1/otp/verifyor against its own hash; the code is used up on success, and your server starts a session. - If WhatsApp delivery fails, your app offers the code by SMS through your own SMS provider.
The Verify API is the shorter build and comes first below. The do-it-yourself path, steps 1 to 7, suits teams that want verification never to depend on a third party, or one code store shared by WhatsApp and an SMS fallback. If you want the background first, including why Meta requires a special template type and what each message costs, read the WhatsApp OTP API guide for India. The examples use Node.js 18 or later with Express, plus Python and PHP.
Before you write code: template, API key and opt-in
No DLT registration and no subscription are involved. Four things need to be in place before the first line of code.
1. An approved Authentication template. Meta requires an authentication template whenever an app offers one-time codes over WhatsApp, and marketing or utility templates cannot be used for them. In PayPerWA, open Dashboard → Templates → New and choose the Authentication category. You do not write the message yourself: Meta generates the body ("{{1}} is your verification code."), so you cannot add your brand name, a link or an emoji. The builder gives you three settings:
- Add security recommendation is on by default and adds the line "For your security, do not share this code."
- Code expires in (minutes) accepts 1 to 90 and defaults to 15. Set it to the lifetime your server enforces. This guide uses 10 minutes.
- A Copy Code button is added automatically.
Submit the template and wait until Meta approves it. Until then the Verify API answers 400 OTP_TEMPLATE_REQUIRED, and /api/v1/messages/send answers 404 with "Template 'login_otp' not found or not approved". If Meta reports your business as not verified or pending, PayPerWA blocks the submission and shows a link to complete verification.
The Verify API picks your most recently updated approved Authentication template and its language by itself; pass template_name if you have several, such as one per language. On the DIY path, write down the template's exact name and language code: /api/v1/messages/send sends the language you pass (default en) straight to Meta, so a template created as en_US or hi must be sent with that code. GET /api/v1/templates?category=AUTHENTICATION returns the name, category and language of each template and needs the templates:read permission. The authentication templates guide covers every option in detail.
2. An API key that stays on your server. Create one under Dashboard → Settings → API. Keys start with ppw_live_sk_, and new keys include the messages:send permission, which both Verify API calls also use. Keep the key in an environment variable such as PAYPERWA_API_KEY and never ship it inside a mobile app or browser code, because anyone who holds it can send messages charged to your wallet. On the DIY path, create a second secret, OTP_PEPPER, a long random string used to hash codes; the Verify API does not need one.
3. Opt-in and clear wording. Meta requires opt-in before you message anyone on WhatsApp, and authentication messages are not exempt. Since Meta's November 2024 policy update, opt-in can be general rather than WhatsApp-specific, as long as it names your business, makes clear the person is agreeing to hear from you and complies with local law. On the login screen, confirm the number and say plainly that the code will arrive on WhatsApp. Meta also suggests offering a choice of channels and, on Android, showing the WhatsApp option only when WhatsApp is installed.
4. Realistic expectations about autofill. Templates created in PayPerWA use the Copy Code button, which works on every platform. One-tap and zero-tap autofill are Android-only and not offered by PayPerWA. On iOS 26 and later, the keyboard can suggest the code from the WhatsApp notification, but only for numeric codes of 3 to 8 digits. Authentication messages are delivered only to the user's primary phone and are masked on WhatsApp Web and other linked devices, so tell users to check WhatsApp on their phone.
No subscription. ₹0.20/msg + Meta's rate (or $0.004/msg on USD wallets).
Create free account →Shortest path: send and verify with the Verify API
The Verify API turns the whole code lifecycle into two HTTPS calls. PayPerWA generates each code with a cryptographic random number generator, stores only a keyed hash, expires it, accepts it once and cancels it after 5 wrong attempts. It also refuses a new code to the same number within 30 seconds, after 5 sends in an hour, or once 15 wrong codes have been entered for that number in the past 24 hours, and checks these limits before anything is charged. The 5-attempt count is per code, so a resend starts a fresh count, while the 24-hour count of wrong codes carries across resends; if you want NIST's stricter rule that a new code never resets the failed-attempt count, keep your own counter per number and stop requesting codes once it is reached. You pay per OTP sent, Meta ₹0.115 + PayPerWA ₹0.20 in India, and verifying is free. The WhatsApp OTP API page summarises it and the OTP API reference lists every field.
Send. POST https://payperwa.com/api/v1/otp/send needs only to, the number with its country code (a leading plus or 00, spaces and dashes are stripped; PayPerWA does not add 91 for you, and a number that still starts with 0 is refused). Optional fields: template_name and language (by default, your most recently updated approved Authentication template and its language), code_length (4 to 8 digits, default 6), expiry_minutes (1 to 30, default 10) and channelId if you run more than one number. Unknown fields are rejected with a 400 and listed under details._errors. Verify. POST https://payperwa.com/api/v1/otp/verify takes the code (a string of 4 to 8 digits) and exactly one of to or verification_id. Each check counts toward your key's rate limit (100 requests a minute by default). Test both with curl:
curl -X POST https://payperwa.com/api/v1/otp/send \
-H "Authorization: Bearer $PAYPERWA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"to": "919876543210"}'
curl -X POST https://payperwa.com/api/v1/otp/verify \
-H "Authorization: Bearer $PAYPERWA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"to": "919876543210", "code": "483920"}'
A successful send returns a verification_id and the charge, never the code. On a Meta-direct channel meta_fee is 0, because Meta bills its fee to your WhatsApp Business account, and wallet_deduction is PayPerWA's ₹0.20:
{
"success": true,
"data": {
"verification_id": "0b6f8f1e-6c1d-4f43-9a51-2f3c8f1d7a20",
"to": "919876543210",
"status": "pending",
"expires_at": "2026-11-09T10:50:00.000Z",
"code_length": 6,
"template_name": "login_otp",
"message_id": "wamid.HBgM...",
"cost": {
"meta_fee": 0,
"platform_fee": 0.2,
"wallet_deduction": 0.2,
"total_outlay": 0.2,
"billing_mode": "meta_direct",
"breakdown": "Meta bills customer directly + PayPerWA ₹0.20 fee",
"country": "IN",
"pricing_source": "meta_direct"
}
}
}
A well-formed verify request always answers HTTP 200 with valid and a status, for example:
{ "success": true, "data": { "valid": true, "status": "approved",
"verification_id": "0b6f8f1e-6c1d-4f43-9a51-2f3c8f1d7a20" } }
{ "success": true, "data": { "valid": false, "status": "incorrect",
"verification_id": "0b6f8f1e-6c1d-4f43-9a51-2f3c8f1d7a20", "attempts_remaining": 3 } }
Map each status to something useful on the login screen:
| Status | Meaning | What your app should do |
|---|---|---|
approved | valid is true. The code is now used up and cannot be replayed. | Start the session. |
incorrect | Wrong code, including an older code after a resend. attempts_remaining says how many tries are left. | Say the code is not right and show the tries left. |
max_attempts | The 5th wrong code cancelled it. | Ask the user to request a new code. |
expired | The code is past expires_at. After this answer, or from 2 minutes past expiry, checks return not_found. | Say it expired and offer a new code. |
not_found | No pending code for this number under your account: none was sent, it was used, cancelled or expired, or (when you check by verification_id) a resend replaced it. | Ask the user to request a new code. |
The send call can also fail with 429 OTP_COOLDOWN, OTP_TOO_MANY_SENDS or OTP_TOO_MANY_FAILED_CHECKS, which carry retry_after_seconds (and a Retry-After header) for a countdown and cost nothing; 400 OTP_TEMPLATE_REQUIRED or OTP_TEMPLATE_NOT_AUTHENTICATION; and 503 OTP_UNAVAILABLE, when nothing was sent or charged and you can try again shortly. Every other error, including 402 for an empty wallet, 404 for an unknown template_name, 409 META_API_OFF and Meta's errors, matches /api/v1/messages/send in step 3 below. The verify call answers 400 for a malformed request, 503 OTP_UNAVAILABLE if it cannot check right now, 500 on an unexpected error, and the same 401, 403 and 429 key errors.
If a send is rejected immediately (any error response), nothing is kept: the new code is discarded, the attempt does not count toward the limits, any fee taken is refunded, and an earlier code for that number stays valid. If Meta accepts it and later reports it failed, PayPerWA refunds its fee, but that send still counts toward the 30-second cooldown and the hourly cap.
Node.js (Express). The browser talks only to your server, and the server-side session remembers which number asked for the code, so the verify step is bound to that browser:
// verify-routes.js (Node.js 18+, Express, express-session set up in app.js)
import express from 'express';
const BASE = 'https://payperwa.com/api/v1';
const API_KEY = process.env.PAYPERWA_API_KEY; // ppw_live_sk_..., server only
async function ppw(path, body) {
try {
const res = await fetch(BASE + path, {
method: 'POST',
headers: { Authorization: 'Bearer ' + API_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: AbortSignal.timeout(15_000),
});
const json = await res.json().catch(() => ({}));
return { status: res.status, json };
} catch {
return { status: 0, json: {} }; // timeout or network error: the outcome is unknown
}
}
const router = express.Router();
router.post('/auth/otp/request', express.json(), async (req, res) => {
// Run your CAPTCHA check and per-IP limit here, before anything is sent.
const phone = String(req.body.phone || '').replace(/[^0-9]/g, '');
if (phone.length < 11 || phone.length > 15) {
return res.status(400).json({ error: 'Enter your WhatsApp number with country code.' });
}
const { status, json } = await ppw('/otp/send', { to: phone });
if (status === 200) {
req.session.otpPhone = phone; // bind the code to this browser session
req.session.otpMessageId = json.data.message_id; // webhooks send it back as data.wamid
// delivery_warning: Meta accepted it, but your WhatsApp account cannot start
// conversations right now (often a payment problem), so offer SMS straight away.
const warning = Boolean(json.data.delivery_warning);
return res.json({ sent: true, expiresAt: json.data.expires_at, offerSms: warning });
}
if (status === 0) {
// The code may have gone out, so do not resend automatically. A resend within
// 30 s of this request gets OTP_COOLDOWN at no charge if this one was sent;
// a later one sends a new, charged code that replaces it.
req.session.otpPhone = phone;
req.session.otpMessageId = null;
return res.status(504).json({ error: 'unknown', action: 'enter_code_or_resend_later' });
}
if (status === 429 && json.retry_after_seconds) {
// OTP_COOLDOWN, OTP_TOO_MANY_SENDS or OTP_TOO_MANY_FAILED_CHECKS: nothing sent or charged
return res.status(429).json({ error: 'wait', retryAfterSeconds: json.retry_after_seconds });
}
console.error('OTP send failed', status, json.code || json.error); // responses never contain the code
return res.status(502).json({ error: 'not_sent' }); // offer SMS or another sign-in method
});
router.post('/auth/otp/verify', express.json(), async (req, res) => {
const phone = req.session.otpPhone;
const code = String(req.body.code || '').trim();
if (!phone) return res.status(400).json({ error: 'no_code' });
if (!/^[0-9]{4,8}$/.test(code)) return res.status(400).json({ error: 'invalid_format' });
const { status, json } = await ppw('/otp/verify', { to: phone, code });
if (status !== 200) return res.status(502).json({ error: 'try_again' });
const { valid, status: result, attempts_remaining } = json.data;
if (valid) {
// result === 'approved': the code is now used up. Start a fresh session.
return req.session.regenerate((err) => {
if (err) return res.status(500).json({ error: 'session_error' });
req.session.phone = phone; // look up or create the user for this number here
res.json({ ok: true });
});
}
if (result === 'incorrect') {
return res.status(401).json({ error: 'wrong_code', attemptsLeft: attempts_remaining });
}
// 'max_attempts', 'expired' or 'not_found': this code can no longer be used
return res.status(401).json({ error: result, action: 'request_new_code' });
});
export default router;
Python (3.10 or later, with pip install requests):
# verify_otp.py (Python 3.10+, pip install requests)
import os
import requests
BASE = 'https://payperwa.com/api/v1'
API_KEY = os.environ['PAYPERWA_API_KEY'] # ppw_live_sk_...
def _post(path: str, body: dict) -> tuple[int, dict]:
try:
res = requests.post(
BASE + path,
headers={'Authorization': f'Bearer {API_KEY}'},
json=body,
timeout=15,
)
except requests.RequestException:
return 0, {} # timeout or network error: the outcome is unknown
try:
return res.status_code, res.json()
except ValueError:
return res.status_code, {}
def send_code(phone: str) -> dict:
status, data = _post('/otp/send', {'to': phone})
if status == 200:
d = data['data'] # keep message_id: webhooks send it back as data.wamid
return {'sent': True, 'expires_at': d['expires_at'], 'message_id': d['message_id']}
if status == 0:
# the code may have gone out: let the user enter it, do not resend automatically
return {'sent': None, 'error': 'unknown'}
if status == 429 and 'retry_after_seconds' in data:
# OTP_COOLDOWN, OTP_TOO_MANY_SENDS or OTP_TOO_MANY_FAILED_CHECKS: nothing sent or charged
return {'sent': False, 'retry_after_seconds': data['retry_after_seconds']}
return {'sent': False, 'error': data.get('code') or data.get('error')}
def check_code(phone: str, code: str) -> dict:
status, data = _post('/otp/verify', {'to': phone, 'code': code.strip()})
if status != 200:
return {'ok': False, 'action': 'try_again'} # no answer, 400 bad format, key problem or 503
result = data['data']
if result['valid']: # 'approved': the code is now used up
return {'ok': True}
if result['status'] == 'incorrect':
return {'ok': False, 'attempts_left': result.get('attempts_remaining')}
# 'max_attempts', 'expired' or 'not_found'
return {'ok': False, 'action': 'request_new_code'}
PHP (8.0 or later, with the curl extension):
<?php
// verify_otp.php (PHP 8.0+, curl extension)
function ppwPost(string $path, array $body): array
{
$ch = curl_init('https://payperwa.com/api/v1' . $path);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('PAYPERWA_API_KEY'), // ppw_live_sk_...
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode($body),
]);
$raw = curl_exec($ch);
// A timeout or network error gives status 0: the outcome is unknown.
$status = $raw === false ? 0 : curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
return [$status, $raw === false ? [] : (json_decode($raw, true) ?? [])];
}
function sendCode(string $phone): array
{
[$status, $data] = ppwPost('/otp/send', ['to' => $phone]);
if ($status === 200) {
$d = $data['data']; // keep message_id: webhooks send it back as data.wamid
return ['sent' => true, 'expires_at' => $d['expires_at'], 'message_id' => $d['message_id']];
}
if ($status === 0) {
// the code may have gone out: let the user enter it, do not resend automatically
return ['sent' => null, 'error' => 'unknown'];
}
if ($status === 429 && isset($data['retry_after_seconds'])) {
// OTP_COOLDOWN, OTP_TOO_MANY_SENDS or OTP_TOO_MANY_FAILED_CHECKS: nothing sent or charged
return ['sent' => false, 'retry_after_seconds' => $data['retry_after_seconds']];
}
return ['sent' => false, 'error' => $data['code'] ?? $data['error'] ?? 'unknown'];
}
function checkCode(string $phone, string $code): array
{
[$status, $data] = ppwPost('/otp/verify', ['to' => $phone, 'code' => trim($code)]);
if ($status !== 200) {
return ['ok' => false, 'action' => 'try_again'];
}
$result = $data['data'];
if ($result['valid']) { // 'approved': the code is now used up
return ['ok' => true];
}
if ($result['status'] === 'incorrect') {
return ['ok' => false, 'attempts_left' => $result['attempts_remaining'] ?? null];
}
return ['ok' => false, 'action' => 'request_new_code']; // max_attempts, expired, not_found
}
What stays yours. The Verify API's limits are per number, so they do not stop a bot sending one code each to thousands of numbers. Keep a CAPTCHA, per-IP limits and an allow-list of the countries you serve in front of your request route (step 6 covers them), bind the code to the session that asked for it as the routes above do, and never call PayPerWA from a browser or mobile app. Each new send replaces the previous code, so tell users to enter the most recent one: an older code is answered incorrect and uses up one of the new code's attempts. For webhooks (step 4) on this path, match data.wamid against the message_id your request route keeps (the routes above store it in the session), instead of the do-it-yourself module's lookup. An SMS fallback (step 7) needs a code store of its own, because the Verify API checks only the codes it sent.
Step 1: Generate a secure code and store only its hash
From here on, the guide builds the do-it-yourself path: your server owns the codes and PayPerWA only delivers them. A login code is a short-lived password, so treat it like one. These rules come from the OWASP Multifactor Authentication Cheat Sheet and NIST SP 800-63B-4:
- Use a cryptographically secure random generator, never
Math.random(). - Use at least six decimal digits. NIST sets six as the minimum. Meta accepts codes of up to 15 characters, but keep them numeric and between 6 and 8 digits so iOS keyboard suggestions can read them.
- Expire codes quickly. NIST treats an authentication as invalid unless it is completed within 10 minutes. Match this to the expiry footer on your template.
- Store a keyed hash, never the code. A 6-digit code has only about a million possible values, so a plain SHA-256 hash can be reversed almost instantly. An HMAC with a server-side secret (a "pepper") cannot be brute-forced without that secret.
- Make each code single use, tie it to one phone number and one purpose (login, password reset), and never write it to logs.
Here is a complete module with no dependencies beyond Node.js. It keeps records in a Map so you can run it as is. In production, move the records to Redis or a database table so every server process sees the same state.
// otp.js: generate, store and verify one-time codes on YOUR server.
// PayPerWA only delivers the code. It never sees these records.
import crypto from 'node:crypto';
const OTP_TTL_MS = 10 * 60 * 1000; // match 'Code expires in' on your template
const MAX_ATTEMPTS = 5; // wrong guesses per number per window
const MAX_SENDS = 5; // codes per number per window
const SEND_WINDOW_MS = 60 * 60 * 1000; // counters reset after one hour
const RESEND_BASE_MS = 30 * 1000; // 30 s, then 60 s, 120 s, 240 s
const OTP_PEPPER = process.env.OTP_PEPPER;
if (!OTP_PEPPER) throw new Error('OTP_PEPPER is not set');
// Demo storage. In production use Redis or a database with a TTL per key,
// so every server process sees the same records.
const records = new Map(); // 'purpose:phone' -> record
const byMessageId = new Map(); // wamid -> 'purpose:phone'
const keyFor = (phone, purpose) => purpose + ':' + phone;
function hashCode(phone, purpose, code) {
return crypto
.createHmac('sha256', OTP_PEPPER)
.update(phone + '|' + purpose + '|' + code)
.digest(); // 32-byte Buffer
}
export function createOtp(phone, purpose) {
const key = keyFor(phone, purpose);
const now = Date.now();
let prev = records.get(key);
if (prev && now - prev.windowStart > SEND_WINDOW_MS) prev = undefined;
if (prev) {
if (prev.sends >= MAX_SENDS || prev.attempts >= MAX_ATTEMPTS) {
return { ok: false, reason: 'locked', retryAfterMs: prev.windowStart + SEND_WINDOW_MS - now };
}
const wait = RESEND_BASE_MS * 2 ** (prev.sends - 1);
const elapsed = now - prev.lastSentAt;
// Skip the cooldown only when the previous send definitely failed.
if (prev.status !== 'failed' && elapsed < wait) {
return { ok: false, reason: 'cooldown', retryAfterMs: wait - elapsed };
}
}
const code = crypto.randomInt(0, 1_000_000).toString().padStart(6, '0');
records.set(key, {
hash: hashCode(phone, purpose, code),
expiresAt: now + OTP_TTL_MS,
attempts: prev ? prev.attempts : 0, // a new code must NOT reset failures
sends: (prev ? prev.sends : 0) + 1,
lastSentAt: now,
windowStart: prev ? prev.windowStart : now,
status: 'sending',
messageId: null,
});
return { ok: true, code }; // hand this to the sender only; never log it
}
export function verifyOtp(phone, purpose, submitted) {
const key = keyFor(phone, purpose);
const record = records.get(key);
if (!record || !record.hash) return { ok: false, reason: 'no_code' };
if (Date.now() > record.expiresAt) {
record.hash = null; // drop the code, keep the counters
return { ok: false, reason: 'expired' };
}
if (record.attempts >= MAX_ATTEMPTS) {
record.hash = null;
return { ok: false, reason: 'too_many_attempts' };
}
record.attempts += 1;
const candidate = hashCode(phone, purpose, String(submitted).trim());
if (crypto.timingSafeEqual(record.hash, candidate)) {
records.delete(key); // single use
if (record.messageId) byMessageId.delete(record.messageId);
return { ok: true };
}
return { ok: false, reason: 'wrong_code', attemptsLeft: MAX_ATTEMPTS - record.attempts };
}
// Delivery tracking, used by the send route and the webhook handler.
export function setStatus(phone, purpose, status, messageId = null) {
const key = keyFor(phone, purpose);
const record = records.get(key);
if (!record) return;
record.status = status;
if (messageId) {
record.messageId = messageId;
byMessageId.set(messageId, key);
}
}
export function setStatusByMessageId(messageId, status) {
const key = byMessageId.get(messageId);
const record = key ? records.get(key) : undefined;
// Ignore late events for a code that has since been replaced.
if (record && record.messageId === messageId) record.status = status;
}
export function getStatus(phone, purpose) {
const record = records.get(keyFor(phone, purpose));
return record ? record.status : 'none';
}
Two details are easy to miss. First, a new code keeps the earlier failed-attempt count, because NIST says generating a new secret must not reset it. Otherwise an attacker could request a fresh code after every few guesses. (The Verify API counts attempts per code instead, and adds a 24-hour cap on wrong codes per number, as described above.) Second, each new code overwrites the old record, as OWASP recommends for resends, so only the latest code works. Tell users to use the most recent message. The OTP security best practices guide explains the reasoning behind each rule.
Step 2: Send the code with the PayPerWA API
On the DIY path, sending is one HTTPS request to https://payperwa.com/api/v1/messages/send with your key in an Authorization: Bearer header. For an Authentication template, pass the code as the first and only item in variables. PayPerWA places that value in both the message body and the Copy Code button, so you do not build the button parameter yourself. Always include the code: an authentication send with an empty variables array goes out without the button parameter.
The to field accepts 10 to 15 characters and is passed to Meta exactly as you send it, so normalise numbers on your side: country code plus number, digits only, with no plus sign and no spaces (for example 919876543210). Template messages can be delivered at any time, so the user does not need to have messaged your business first. If you run more than one WhatsApp number, choose one with the X-Channel-Id header or a channelId field. Otherwise PayPerWA uses the channel the template belongs to, preferring your primary channel.
Test the call with curl first:
curl -X POST https://payperwa.com/api/v1/messages/send \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "919876543210",
"template_name": "login_otp",
"language": "en",
"variables": ["483920"]
}'
A successful response on a Meta-direct channel looks like this:
{
"success": true,
"data": {
"message_id": "wamid.HBgM...",
"status": "sent",
"cost": {
"meta_fee": 0,
"platform_fee": 0.2,
"wallet_deduction": 0.2,
"total_outlay": 0.2,
"billing_mode": "meta_direct",
"breakdown": "Meta bills customer directly + PayPerWA ₹0.20 fee",
"country": "IN",
"pricing_source": "meta_direct"
},
"otp_button_detection": "cache_copy_code"
}
}
A status of sent means Meta accepted the message, not that it reached the phone. The cost block describes your wallet charge only. On a Meta-direct channel meta_fee is 0 because Meta bills its fee to your WhatsApp Business account directly, while wallet_deduction is PayPerWA's ₹0.20. The otp_button_detection field is informational and can be ignored.
In Node.js, wrap the call in a small module that throws a typed error, so your route can tell a definite failure from a timeout:
// payperwa.js: deliver a code through PayPerWA (Node.js 18+, built-in fetch)
const PAYPERWA_API_KEY = process.env.PAYPERWA_API_KEY; // ppw_live_sk_...
export class OtpSendError extends Error {
constructor(status, error, details) {
super('OTP send failed (' + status + '): ' + error);
this.status = status;
this.details = details; // Meta's error body when error is 'WhatsApp API error'
}
}
/**
* Sends code to phone using your APPROVED Authentication template.
* phone: country code + number, digits only, e.g. '919876543210'.
* Returns data: { message_id, status, cost, delivery_warning? }
*/
export async function sendWhatsAppOtp(phone, code) {
const res = await fetch('https://payperwa.com/api/v1/messages/send', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + PAYPERWA_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
to: phone,
template_name: 'login_otp', // your APPROVED Authentication template
language: 'en', // must match the template's language code
variables: [code], // the OTP only: body and Copy Code button are filled for you
}),
signal: AbortSignal.timeout(30_000), // a timeout throws, but the message may still arrive
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.success) {
throw new OtpSendError(res.status, data.error || 'unknown error', data.details);
}
return data.data;
}
The Python and PHP versions of this function appear later in the guide.
Step 3: Handle the response and errors
Every response carries a success flag. Errors come back as {"success": false, "error": "..."}, sometimes with extra fields. Map each case to something useful on the login screen. The Verify API's send call returns the same errors, plus the OTP-specific ones listed in the Verify API section above:
| Status | Meaning | What your app should do |
|---|---|---|
| 200 | Meta accepted the message. data.message_id holds the wamid. | Show "Code sent on WhatsApp", start the resend timer, save the message ID and check delivery_warning. |
| 400 | Validation failed (see details), invalid channel, OWN_NUMBER, or WhatsApp account not connected. | Ask the user to re-check the number. OWN_NUMBER means you sent to your own business number, usually while testing. |
| 401 | Missing, invalid, expired or deactivated API key. | Alert your team and offer the fallback. |
| 402 | Wallet balance too low (INSUFFICIENT_WALLET_BALANCE), with the required and available amounts and the wallet's currency. | Recharge the wallet and offer the fallback meanwhile. |
| 403 | The key lacks messages:send, or the account is suspended. (CONTACT_OPTED_OUT applies only to Marketing and Utility templates, so it never blocks an OTP.) | Fix the key or the account and offer the fallback. |
| 404 | Template not found or not approved. | Check template_name and the template's status. |
| 409 | META_API_OFF: the connected access token can no longer reach your WhatsApp Business account, for example after it expired or lost permissions. | Reconnect WhatsApp under Dashboard → Settings → WhatsApp Channels and offer the fallback meanwhile. |
| 429 | The key hit its per-minute rate limit. | Back off and try again later. There is no Retry-After header. |
| Meta's status | "error": "WhatsApp API error", with Meta's error body in details. | Log the status and error (never the code) and offer the fallback. |
| 500 | Failed to send message. | Offer the fallback. Do not retry automatically. |
Check delivery_warning on every 200. It appears when Meta accepted the message but reports that your WhatsApp Business account cannot currently start conversations, most often because of a payment problem on the account. Unless the user has an open 24-hour customer service window with you, the code will not be delivered. It contains code, message and action fields. Treat it as a failure, offer the fallback and check the payment method and account status in your Meta account.
Know how the wallet behaves. PayPerWA charges its fee before calling Meta. If Meta rejects the request immediately, the same amount is refunded straight away. If Meta accepts the message and later reports it as failed, PayPerWA refunds its fee once and fires a message.failed webhook. Meta's own fee is separate and billed by Meta.
Do not retry blindly after a timeout. The API has no idempotency key, so repeating a request that timed out can send, and charge for, a second message. A timeout is ambiguous because the first message may still arrive. Mark it as unknown, let the resend cooldown run and let the user ask for a new code. Treat a 5xx response the same way, because it can follow a lost connection between PayPerWA and Meta. The 400, 401, 402, 403, 404, 409 and 429 errors from PayPerWA's own checks are returned before anything is sent, and a 4xx WhatsApp API error means Meta refused the message, so neither can create a duplicate.
Plan for the rate limit. Each API key allows 100 requests per minute by default, and the dashboard has no setting to change it. Every call counts, and on the Verify API that includes checks, so budget at least two requests per login: one send plus one to five checks. If your login traffic can exceed that, queue or spread requests instead of firing them all at once.
Step 4: Track delivery with webhooks
There is no API endpoint for fetching a message's status later, so delivery information reaches your server only through webhooks (you can also see it in the dashboard). Add a webhook under Dashboard → Settings → API → Webhook Events. The URL must use HTTPS, and each webhook gets its own secret starting with whsec_. For OTPs, subscribe to message.delivered and message.failed; message.sent and message.read are optional.
Each request is a POST whose JSON body has three keys: event, timestamp and data. The X-PayPerWA-Signature header carries sha256= followed by the hex HMAC-SHA256 of the raw body, computed with your webhook secret. X-PayPerWA-Event and X-PayPerWA-Timestamp are sent too, but the timestamp is not part of the signature. For API sends, from either endpoint, data.sentFrom is api and data.wamid equals the message_id from your send call. Failed events also carry errorCode and errorMessage.
// webhooks.js: receive PayPerWA delivery events (Express)
import express from 'express';
import crypto from 'node:crypto';
import { setStatusByMessageId } from './otp.js';
const router = express.Router();
// Use the RAW body: PayPerWA signs the exact bytes it sends.
router.post('/webhooks/payperwa', express.raw({ type: 'application/json' }), (req, res) => {
const raw = req.body.toString('utf8');
const expected = Buffer.from('sha256=' + crypto
.createHmac('sha256', process.env.PAYPERWA_WEBHOOK_SECRET) // whsec_...
.update(raw, 'utf8')
.digest('hex'));
const got = Buffer.from(req.get('X-PayPerWA-Signature') || '');
if (got.length !== expected.length || !crypto.timingSafeEqual(got, expected)) {
return res.sendStatus(401);
}
const { event, data } = JSON.parse(raw); // { event, timestamp, data }
if (data && data.sentFrom === 'api' && data.wamid) {
if (event === 'message.delivered' || event === 'message.read') {
setStatusByMessageId(data.wamid, 'delivered');
} else if (event === 'message.failed') {
// data.errorCode and data.errorMessage explain why. PayPerWA has
// already refunded its own fee for this message to your wallet.
setStatusByMessageId(data.wamid, 'failed');
}
}
res.sendStatus(200); // reply within 8 seconds; deliveries are not retried
});
export default router;
Four points to get right:
- Verify against the raw bytes. Mount this route before any global
express.json()middleware. Parsing and re-serialising the JSON can change the bytes and break the signature. - Answer quickly. PayPerWA waits 8 seconds and tries each delivery once. A timeout or non-2xx reply is not retried, so do slow work after responding.
- Never make login depend on a webhook. Because a missed event is not resent, use webhooks to speed up the fallback, never as a condition for accepting a correct code. Read events depend on the recipient's WhatsApp settings and may never arrive.
- Match on the wamid. Only
message.sentincludestemplateName. Delivered and failed events do not, so look messages up bydata.wamid.
Step 5: Wire up the request and verify endpoints
With the module, the sender and the webhook in place, your login API needs three small routes: one to request a code, one the login screen polls for delivery status, and one to verify. This example stores the phone number in a server-side session (express-session), so the verify step is bound to the browser that asked for the code and the status route reveals nothing about other numbers. Install the dependencies with npm install express express-session.
// routes.js (Express). Assumes express-session is set up in app.js.
import express from 'express';
import { createOtp, verifyOtp, setStatus, getStatus } from './otp.js';
import { sendWhatsAppOtp, OtpSendError } from './payperwa.js';
const router = express.Router();
const PURPOSE = 'login';
function normalizePhone(input) {
const digits = String(input || '').replace(/[^0-9]/g, '');
// This example app serves India: a bare 10-digit number is treated as +91.
return digits.length === 10 ? '91' + digits : digits;
}
function tooSoon(res, issued) {
return res.status(429).json({
error: issued.reason, // 'cooldown' or 'locked'
retryAfterSeconds: Math.ceil(issued.retryAfterMs / 1000),
});
}
router.post('/auth/otp/request', express.json(), async (req, res) => {
const phone = normalizePhone(req.body.phone);
if (phone.length < 11 || phone.length > 15) {
return res.status(400).json({ error: 'Enter your WhatsApp number with country code.' });
}
const issued = createOtp(phone, PURPOSE);
if (!issued.ok) return tooSoon(res, issued);
req.session.otpPhone = phone;
try {
const result = await sendWhatsAppOtp(phone, issued.code);
// delivery_warning: Meta accepted it, but your WhatsApp account cannot
// currently start conversations (often a payment problem), so it will not arrive.
const status = result.delivery_warning ? 'failed' : 'sent';
setStatus(phone, PURPOSE, status, result.message_id);
return res.json({ channel: 'whatsapp', status });
} catch (err) {
// A 4xx error response means nothing was sent. A 5xx, timeout or network
// error is ambiguous (the message may still arrive), so never retry it automatically.
const definite = err instanceof OtpSendError && err.status < 500;
setStatus(phone, PURPOSE, definite ? 'failed' : 'unknown');
console.error('WhatsApp OTP send error:', err instanceof OtpSendError ? err.status : err.name); // never log the code
return res.status(502).json({ channel: 'whatsapp', status: definite ? 'failed' : 'unknown' });
}
});
// The login screen polls this every few seconds to decide when to offer SMS.
router.get('/auth/otp/status', (req, res) => {
const phone = req.session.otpPhone;
res.json({ status: phone ? getStatus(phone, PURPOSE) : 'none' });
});
router.post('/auth/otp/verify', express.json(), (req, res) => {
const phone = req.session.otpPhone;
if (!phone) return res.status(400).json({ error: 'no_code' });
const result = verifyOtp(phone, PURPOSE, req.body.code || '');
if (!result.ok) {
return res.status(401).json({ error: result.reason, attemptsLeft: result.attemptsLeft });
}
// Correct code, already consumed. Start a fresh session for the signed-in user.
req.session.regenerate((err) => {
if (err) return res.status(500).json({ error: 'session_error' });
req.session.phone = phone; // look up or create the user for this number here
res.json({ ok: true });
});
});
export { router as default, PURPOSE, tooSoon };
And the app that ties it together, with the webhook mounted first so its route receives the raw body:
// app.js
import express from 'express';
import session from 'express-session';
import webhooks from './webhooks.js';
import routes from './routes.js';
const app = express();
app.set('trust proxy', 1); // when running behind a reverse proxy or load balancer
app.use(webhooks); // raw-body route first; no global express.json()
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: { httpOnly: true, secure: true, sameSite: 'lax' }, // secure cookies need HTTPS
// store: use a Redis or database session store in production
}));
app.use(routes);
app.listen(3000);
How the verify side enforces its limits:
- Five wrong guesses per number per hour. After that, both verify and request are locked until the window resets. NIST allows at most 100 consecutive failures, and Twilio Verify's default is 5 check attempts per verification. Pick a number your support team can live with.
- Constant-time comparison.
timingSafeEqualcompares two 32-byte digests, so response time does not reveal how much of the code matched. - Single use. The record is deleted the moment a code is accepted, so a replayed code fails.
- No account enumeration. The request route behaves the same whether or not the number belongs to an existing account, as OWASP recommends. Create the user only after a successful verify.
- A fresh session after login. Regenerating the session ID on sign-in prevents session fixation.
Step 6: Add a resend cooldown and abuse limits
On the DIY path, PayPerWA limits requests per API key only (100 a minute) and does not throttle per recipient, so the limits in your own code are what stop a script from sending hundreds of codes to one number at your expense. The Verify API applies a 30-second cooldown and 5 sends per number per hour for you, and sends a number no new code after 15 wrong codes in 24 hours. Neither path stops one code each to thousands of numbers; that takes the extra protections further down. The module from step 1 already enforces three:
- An exponential cooldown per number: 30 seconds before the first resend, then 60, 120 and 240 seconds. Twilio's Verify guidance suggests about one request per 30 seconds per phone number with exponential backoff, which is where this starting value comes from.
- A cap of 5 codes per number per hour, after which the number is locked until the window ends.
- Failed attempts that survive a resend, so requesting a new code does not buy extra guesses.
These values are sensible starting points, not rules from any standard. Add these protections on top, whichever path you use:
- Per-IP and per-device limits on
/auth/otp/request, using whatever rate-limiting middleware you already run. - A CAPTCHA or bot check on the request form. MITRE ATT&CK lists CAPTCHA protection as a mitigation for SMS pumping (technique T1496.003), and it matters most for your SMS fallback, where fraudsters trigger messages to numbers on operators they collude with and take a share of the revenue.
- An allow-list of countries you serve. Reject numbers with country codes you do not support before generating a code.
- A conversion-rate alert. Track codes verified against codes sent, as Twilio recommends, and alert when the rate drops sharply. Verifications that are never completed are a warning sign of abuse.
- Spending alerts. OWASP's API Security Top 10 (API4:2023) recommends spending limits for every paid integration. The prepaid wallet means PayPerWA can never charge more than the balance you loaded, but Meta bills its fee to your WhatsApp Business account payment method, so watch that bill too.
On the client, use retryAfterSeconds from the 429 response to show a countdown on the "Resend code" button, so users know when they can try again instead of tapping a disabled control.
Step 7: Add an SMS fallback with your own provider
PayPerWA sends WhatsApp messages only, so an SMS fallback runs through a separate SMS provider you contract with. Offer it when any of these happen:
- The send call returns an error, or a 200 response with
delivery_warning. - A
message.failedwebhook arrives, witherrorCodeanderrorMessageexplaining why. - No
message.deliveredevent has arrived after a reasonable wait, such as 30 seconds. Because webhooks are not retried, show this as a choice ("Didn't get it? Send by SMS") rather than sending automatically.
Meta drops authentication messages it cannot deliver within their time-to-live, which defaults to 10 minutes for templates created on or after 23 October 2024. Templates created in PayPerWA are submitted without a custom time-to-live, so that default applies. It lines up with a 10-minute code expiry: a WhatsApp code that has not arrived by then will not arrive at all.
// At the top of routes.js. sendSmsOtp() wraps your DLT-registered SMS provider's API.
import { sendSmsOtp } from './sms-provider.js';
// With the other routes in routes.js:
router.post('/auth/otp/request-sms', async (req, res) => {
const phone = req.session.otpPhone;
if (!phone) return res.status(400).json({ error: 'no_code' });
// Same counters as WhatsApp. A fresh code replaces the old one, and the
// cooldown is skipped only when the WhatsApp send definitely failed.
const issued = createOtp(phone, PURPOSE);
if (!issued.ok) return tooSoon(res, issued);
try {
await sendSmsOtp(phone, issued.code);
setStatus(phone, PURPOSE, 'sent');
return res.json({ channel: 'sms', status: 'sent' });
} catch (err) {
setStatus(phone, PURPOSE, 'failed');
console.error('SMS OTP send error:', err.name);
return res.status(502).json({ channel: 'sms', status: 'failed' });
}
});
On the DIY path the verify route does not change, because verification never depended on the channel, and the SMS code replaces the WhatsApp one, so tell the user to enter the code from the SMS. With the Verify API, PayPerWA checks only the codes it sent, so check an SMS code with your own store or your SMS provider. There is no call to cancel a Verify code, so the WhatsApp code stays valid until it expires, is used or runs out of attempts; decide which store to check, for example your SMS store first, then /api/v1/otp/verify.
In India, SMS has its own compliance layer. Under TRAI's commercial communication regulations (TCCCPR 2018, amended in February 2025), senders work through the Distributed Ledger Technology (DLT) system:
- Register your business as a Principal Entity with a telecom operator, and send only from a registered header.
- Register the OTP content template before use. Each message is checked against it and blocked if it does not match. Templates need at least 30% fixed text, normally no more than three variables, and variables of up to 30 characters.
- Make sure your provider has declared your message path (the PE-TM chain). Since 11 December 2024, SMS sent through unregistered paths is rejected.
- Headers and templates unused for 90 days are temporarily deactivated. A fallback that rarely fires is exactly the template that goes stale, so check its status regularly.
A DLT OTP template could read {#var#} is your OTP to log in to YourBrand. It is valid for 10 minutes. Do not share it with anyone. - YourBrand, where {#var#} is the code. Unlike the WhatsApp template, SMS can carry your brand name. Under TRAI's 2025 definition, an OTP sent within 30 minutes of a transaction the customer started is a transactional message, so it does not need separate explicit consent and is not blocked by DND preferences. For a side-by-side look at both channels, see WhatsApp OTP vs SMS OTP in India.
The same pattern in Python and PHP
For the DIY path, the design carries over to any language, as long as you use the right primitives. Each standard library has all three you need:
| Need | Node.js | Python | PHP |
|---|---|---|---|
| Secure random code | crypto.randomInt | secrets.randbelow | random_int |
| Keyed hash (HMAC-SHA256) | crypto.createHmac | hmac.new with hashlib.sha256 | hash_hmac |
| Constant-time comparison | crypto.timingSafeEqual | hmac.compare_digest | hash_equals |
Python (3.10 or later, with pip install requests):
# otp_whatsapp.py
import hashlib
import hmac
import os
import secrets
import time
import requests
PAYPERWA_API_KEY = os.environ['PAYPERWA_API_KEY'] # ppw_live_sk_...
OTP_PEPPER = os.environ['OTP_PEPPER'].encode()
OTP_TTL_SECONDS = 10 * 60
MAX_ATTEMPTS = 5
class OtpSendError(Exception):
def __init__(self, status: int, error: str | None, details=None):
super().__init__(f'OTP send failed ({status}): {error}')
self.status = status
self.details = details
def send_whatsapp_otp(phone: str, code: str) -> dict:
# phone: country code + number, digits only, e.g. '919876543210'
res = requests.post(
'https://payperwa.com/api/v1/messages/send',
headers={
'Authorization': f'Bearer {PAYPERWA_API_KEY}',
'Content-Type': 'application/json',
},
json={
'to': phone,
'template_name': 'login_otp', # your APPROVED Authentication template
'language': 'en', # must match the template's language code
'variables': [code], # OTP only; body and Copy Code button are filled for you
},
timeout=30, # a timeout raises, but the message may still arrive: do not auto-retry
)
try:
data = res.json()
except ValueError:
data = {}
if res.status_code != 200 or not data.get('success'):
raise OtpSendError(res.status_code, data.get('error'), data.get('details'))
return data['data'] # message_id, status, cost, optional delivery_warning
def _hash(phone: str, purpose: str, code: str) -> bytes:
msg = f'{phone}|{purpose}|{code}'.encode()
return hmac.new(OTP_PEPPER, msg, hashlib.sha256).digest()
def issue_otp(phone: str, purpose: str, prev: dict | None) -> tuple[str, dict]:
code = f'{secrets.randbelow(1_000_000):06d}'
record = {
'hash': _hash(phone, purpose, code),
'expires_at': time.time() + OTP_TTL_SECONDS,
'attempts': prev['attempts'] if prev else 0, # a new code keeps the failure count
}
return code, record # save record, send code, never log code
def verify_otp(record: dict, phone: str, purpose: str, submitted: str) -> bool:
if not record.get('hash') or time.time() > record['expires_at']:
return False
if record['attempts'] >= MAX_ATTEMPTS:
return False
record['attempts'] += 1 # persist this before returning
if hmac.compare_digest(record['hash'], _hash(phone, purpose, submitted.strip())):
record['hash'] = None # single use: delete the record from your store
return True
return False
PHP (8.0 or later, with the curl extension):
<?php
// otp_whatsapp.php
const OTP_TTL_SECONDS = 600;
const MAX_ATTEMPTS = 5;
function sendWhatsAppOtp(string $phone, string $code): array
{
$ch = curl_init('https://payperwa.com/api/v1/messages/send');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('PAYPERWA_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'to' => $phone, // digits only, e.g. '919876543210'
'template_name' => 'login_otp', // your APPROVED Authentication template
'language' => 'en', // must match the template's language code
'variables' => [$code], // OTP only; body and Copy Code button are filled for you
]),
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($body === false) {
// Timeout or network error: the message may still arrive. Do not auto-retry.
throw new RuntimeException('Network error: ' . curl_error($ch));
}
$data = json_decode($body, true) ?? [];
if ($status !== 200 || empty($data['success'])) {
throw new RuntimeException("OTP send failed ($status): " . ($data['error'] ?? 'unknown'), $status);
}
return $data['data']; // message_id, status, cost, optional delivery_warning
}
function otpHash(string $phone, string $purpose, string $code): string
{
$pepper = getenv('OTP_PEPPER') ?: throw new RuntimeException('OTP_PEPPER is not set');
return hash_hmac('sha256', "$phone|$purpose|$code", $pepper);
}
function issueOtp(string $phone, string $purpose, ?array $prev): array
{
$code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
$record = [
'hash' => otpHash($phone, $purpose, $code),
'expires_at' => time() + OTP_TTL_SECONDS,
'attempts' => $prev['attempts'] ?? 0, // a new code keeps the failure count
];
return [$code, $record]; // save $record, send $code, never log $code
}
function verifyOtp(array &$record, string $phone, string $purpose, string $submitted): bool
{
if (empty($record['hash']) || time() > $record['expires_at']) {
return false;
}
if ($record['attempts'] >= MAX_ATTEMPTS) {
return false;
}
$record['attempts']++; // persist this before returning
if (hash_equals($record['hash'], otpHash($phone, $purpose, trim($submitted)))) {
$record['hash'] = null; // single use: delete the record from your store
return true;
}
return false;
}
Both versions leave storage, the resend cooldown and the hourly send cap to you. Apply the same rules as the Node.js module, keep records in Redis or your database, and check delivery_warning in the returned data exactly as the Node.js route does. Treat a 5xx status or a network timeout as "unknown" rather than "failed", for the same reason.
What each login code costs, and a go-live checklist
In India, each authentication message costs Meta ₹0.115 + PayPerWA ₹0.20, and the two parts are billed separately. On a Meta-direct channel, PayPerWA's ₹0.20 comes from your prepaid wallet, with no subscription, and Meta bills its ₹0.115 per delivered message to the payment method on your WhatsApp Business account. ₹0.115 is Meta's India list rate on its official pricing calculator as of 8 November 2026, and Meta can revise rates at the start of each quarter, so check before you budget.
- As an example, 10,000 login codes in a month cost Meta ₹1,150 + PayPerWA ₹2,000, before resends. Checks with
/api/v1/otp/verifyare free; each resend is a new OTP and is charged. - Authentication messages are charged even inside the 24-hour customer service window, and the 1,000 free service messages per number per month do not apply to them.
- Meta's volume discounts for India authentication start only above 750,000 charged messages in a month, counted across your whole business portfolio.
- If your PayPerWA wallet is in US dollars, PayPerWA's fee is $0.004 per message instead of ₹0.20. Meta's authentication rate depends on the recipient's country; see Meta rates by country.
Before you switch WhatsApp login on, run through this list:
- The Authentication template is APPROVED, its expiry footer matches your server's code lifetime, and you send its exact language code.
- The API key,
OTP_PEPPER(DIY path only), the session secret and the webhook secret live in server environment variables only. - Codes are 6 digits from a secure generator, stored only as HMAC hashes, expire in 10 minutes, work once and never appear in logs. The Verify API does this for you; on the DIY path, your module does.
- Per-IP limits, a country allow-list and a CAPTCHA protect the request endpoint, plus attempt limits, resend cooldowns and an hourly cap on the DIY path.
- The webhook verifies the
sha256=signature over the raw body and replies within 8 seconds. - Login still works when no webhook arrives.
- The SMS fallback uses a DLT-registered header and template and sits behind the same limits.
- Your peak, counting verify calls, fits within 100 API requests a minute per key, or you have spoken to support.
- You tested with a number other than your own business number, which returns
OWN_NUMBER. - The login screen says the code goes to WhatsApp on the user's phone.
- If your business is in India, check whether Meta requires your Messaging accounts to move to INR billing. For eligible businesses the deadline is 31 December 2026, and from 1 January 2027 Meta will not deliver messages from their non-INR accounts.
If you want to try this flow, create a PayPerWA account, connect your WhatsApp Business number, submit an Authentication template and generate an API key. There is no subscription and no DLT registration. The WhatsApp OTP API page sums up the Verify API, the OTP API reference lists its fields, the rest of the endpoints are in the API documentation, the fee breakdown is on the pricing page, and the help centre is there if you get stuck.
Frequently Asked Questions
Does PayPerWA generate or verify the OTP code?
POST /api/v1/otp/send generates and sends the code, and POST /api/v1/otp/verify checks it, free of charge. PayPerWA never returns the code. If you prefer to own the codes, generate and verify them on your server and send each one as variables[0] with /api/v1/messages/send.Can I customise the WhatsApp OTP message text?
Which language code should I send in the API request?
language. With /api/v1/messages/send, send the exact code the template was created with. That API defaults to en and passes your value straight to Meta, so a template created as en_US or hi must be sent with that code. GET /api/v1/templates?category=AUTHENTICATION shows each template's language.What do the Verify API's statuses mean?
approved means the code was right and is now used up. incorrect means a wrong code, with attempts_remaining. max_attempts (5 wrong codes), expired and not_found (no pending code, including one already used) all mean the user needs a new code.Why doesn't the code autofill on iPhone?
What happens to my wallet if an OTP is not delivered?
Can PayPerWA send the OTP by SMS if WhatsApp fails?
Create your account and send your first campaign today
Free account. No subscription — just ₹0.20 per message + Meta's rate (India marketing ₹0.86, utility ₹0.115).
PayPerWA Team
We build PayPerWA, a pay-per-message WhatsApp Cloud API platform. No subscriptions, no hidden fees — Meta's rate plus a flat ₹0.20 per message.