OTP Security Best Practices for WhatsApp and SMS (2026)
The channel delivers the code, but expiry, attempt limits and abuse controls decide whether it is safe. A practical guide to OTP length, expiry, attempt limits, resend cooldowns, hashing, logging and SMS pumping defences, with what PayPerWA's Verify API handles for you and what stays your job.
Key Takeaways
- PayPerWA's Verify API applies cryptographic code generation, keyed-hash storage, expiry, single use, 5 wrong attempts per code, a 30-second resend cooldown, 5 sends per number per hour and no new code after 15 wrong codes in 24 hours; if you send your own codes with /api/v1/messages/send, all of that is your server's job.
- Generate numeric codes of 6 to 8 digits with a cryptographically secure random generator. That meets NIST's six-digit minimum and keeps WhatsApp codes eligible for keyboard autofill on iOS 26 and later.
- Expire codes within 10 minutes, accept each code once, overwrite the old code on resend, and never let a new code reset the failed-attempt count.
- Store only a keyed HMAC of the code bound to the phone number, purpose and session, and keep codes out of logs, URLs, analytics and API responses.
- WhatsApp delivery removes SMS pumping exposure and adds primary-device-only delivery, blocked forwarding and fixed message text, but bots can still request codes you pay for, so CAPTCHA, IP rate limits, a country allow-list and conversion monitoring stay with you.
OTP security: what the Verify API handles and what stays yours
Every OTP flow has two halves. Delivery puts a short code on the user's phone over SMS or WhatsApp. Verification decides whether the code typed back is genuine, still fresh, and coming from the person who asked for it. Most real OTP weaknesses sit in the second half, and that half runs either on your server or in a verification service you hand it to.
PayPerWA offers both models. With the Verify API, your server calls POST /api/v1/otp/send and POST /api/v1/otp/verify, and PayPerWA generates, stores, expires, limits and checks the code. With POST /api/v1/messages/send, your server generates the code, passes it as variables[0] and verifies it itself; PayPerWA places it in the body and the Copy Code button of your approved authentication template and delivers it. Either way you pay per OTP sent (in India, Meta ₹0.115 + PayPerWA ₹0.20, with no subscription), and some controls stay with you:
| Control | With the Verify API | With your own codes |
|---|---|---|
| Code from a cryptographically secure random generator | PayPerWA (numeric, 4 to 8 digits, 6 by default) | You |
| Code stored only as a keyed hash, never returned in an API response | PayPerWA | You |
| Expiry | PayPerWA (1 to 30 minutes, 10 by default) | You |
| Single use; a resend replaces the previous code | PayPerWA | You |
| Wrong-attempt limit | PayPerWA: 5 wrong attempts cancel a code, and after 15 wrong codes for a number in 24 hours no new code is sent to it | You |
| Resend cooldown and hourly cap per number | PayPerWA: 30 seconds between sends, at most 5 an hour, checked before any charge | You |
| CAPTCHA or bot check on your request form | You | You |
| Limits per IP address, device and number range | You | You |
| Country allow-list | You | You |
| Binding the code to purpose and session; identical responses for unknown numbers | You | You |
| Monitoring and spend alerts | You | You |
The threats you are designing against:
- Guessing: an attacker submits codes until one works.
- Account enumeration: different responses reveal which phone numbers have accounts.
- Interception and social engineering: the code is read from a lock screen, a malicious app or a hijacked number, or the user is talked into reading it out.
- Cost abuse: bots hit your “send code” endpoint to run up your messaging bill. On SMS the best-known form is SMS pumping, also called Artificially Inflated Traffic (AIT).
- Leakage: codes end up in logs, analytics, URLs or error trackers.
If you have not set up WhatsApp OTP yet, start with WhatsApp OTP API in India and the authentication templates guide; the Verify API's fields are in the OTP API reference.
Code length, entropy and format
Generate every code with a cryptographically secure random number generator: crypto.randomInt in Node.js or the secrets module in Python. Never use Math.random(), a timestamp or anything derived from the user's ID. The OWASP Multifactor Authentication Cheat Sheet calls for a cryptographically secure random source, and NIST SP 800-63B-4 requires out-of-band secrets to be random and at least six decimal digits.
A six-digit code has one million possible values. That is safe only because you also expire codes quickly and cap attempts. OWASP suggests considering 8-digit or longer codes where usability allows.
WhatsApp adds two practical limits. Meta caps the code in an authentication template at 15 characters. On iOS 26 and later, the keyboard can offer the code from the WhatsApp notification only when it is numeric and 3 to 8 digits long, and only if the user has WhatsApp notifications and Autofill Security Codes turned on. It does not trigger while WhatsApp is in the foreground.
| Code format | Possible values | On WhatsApp |
|---|---|---|
| 6 digits | 1,000,000 | Meets NIST's minimum; iOS 26+ keyboard suggestions can autofill it |
| 8 digits | 100,000,000 | Longest code iOS keyboard suggestions detect |
| 9 to 15 digits | 1,000,000,000 and up | Allowed by Meta; iPhone users must use Copy Code |
| Alphanumeric, up to 15 characters | Larger for the same length | Allowed by Meta; never autofilled on iOS; harder to type |
For most products, a numeric code of 6 to 8 digits is the practical range. The Copy Code button, which PayPerWA adds to every authentication template you create, works on Android and iPhone at any length up to Meta's 15-character limit. One-tap and zero-tap autofill are Android-only integrations that need app-side work and are not offered by PayPerWA. The Verify API's codes are numeric and 4 to 8 digits long, 6 by default. Keep code_length at 6 or more: 4 and 5 digits fall below NIST's minimum.
No subscription. ₹0.20/msg + Meta's rate (or $0.004/msg on USD wallets).
Create free account →Expiry and single use
A code should expire quickly and work exactly once. NIST treats an out-of-band authentication as invalid unless it is completed within 10 minutes, and accepts each secret only once. OWASP adds that a resend should generate a new code and overwrite the old record, so only the latest code works. Some services differ: Twilio Verify returns the same code if it is requested again within its 10-minute validity. Pick one behaviour on purpose and tell your support team which. PayPerWA's Verify API follows OWASP: each send replaces the previous code, and an approved code is deleted at once. A resend that is rejected immediately leaves the earlier code valid.
On WhatsApp, three timers should agree:
- The enforced expiry: your server's, or the Verify API's
expiry_minutes(1 to 30, default 10). This is the only one that enforces anything. - The template's expiry footer, “This code expires in N minutes”, which Meta allows from 1 to 90 minutes. In the PayPerWA template builder this is the “Code expires in (minutes)” field, which defaults to 15. Set it to the value you enforce, so users are never told a code is valid when it is not.
- Meta's time-to-live (TTL). Authentication templates created on or after 23 October 2024 default to a 10-minute TTL unless a custom one was set, and Meta drops messages it cannot deliver within the TTL. Meta recommends a TTL equal to or less than the code's expiry. PayPerWA does not set a custom TTL, so templates you create there use Meta's default.
A 10-minute server expiry with a matching footer is a reasonable default. If you choose a shorter expiry for sensitive actions such as payouts, a delayed message can arrive after its code has already expired. Show “This code has expired, request a new one” rather than “Wrong code” in that case, and invalidate the record the moment verification succeeds.
Attempt limits and lockouts
A numeric OTP is short, so unlimited guessing will eventually succeed. NIST makes rate limiting mandatory for any secret shorter than 64 bits, which covers every numeric OTP WhatsApp can carry, and caps failures at no more than 100 consecutive failed attempts on one account. OWASP asks for strict attempt limits without fixing a number. In practice services sit far below NIST's ceiling; Twilio Verify, for example, allows 5 check attempts per verification.
Two design details matter more than the exact number:
- Count failures per phone number and purpose, across resends. NIST says generating a new secret shall not reset the failed-attempt count. If every resend gives an attacker a fresh set of guesses, the limit does nothing.
- Lock temporarily, not permanently. A permanent lock lets anyone shut a real customer out by typing wrong codes against their number. A lock that lifts after a set period, with a clear “try again later” message and a support route, stops guessing without stranding people.
Compare codes in constant time, for example with crypto.timingSafeEqual on two hashes of equal length. And respond the same way, in the same time, whether or not a phone number has an account, which the OWASP Forgot Password Cheat Sheet recommends to prevent enumeration. “If this number is registered, we have sent a code” tells an attacker nothing. You do not have to send a message to unknown numbers, but the answer must look and take the same; one way is to hand every request to a background job and reply straight away.
On the Verify API, the 5th wrong code cancels it (status max_attempts) and the user needs a new one, and because single use is enforced in one atomic check, two parallel submissions of the right code cannot both be approved. The 5-attempt count is per code, so a new send starts a fresh count, but Verify also counts wrong codes per number across all its codes: once 15 have been entered in a rolling 24 hours, it sends that number no new code (429 OTP_TOO_MANY_FAILED_CHECKS) until older ones pass the 24-hour mark. Together with the 30-second cooldown and 5 sends an hour, that holds guessing to about 15 wrong codes per number a day, plus the attempts left on the code already sent, against a million values for a 6-digit code. The lock lifts by itself, but someone can trigger it against a real customer's number, so give users another way in, such as support or a different sign-in method. If you want NIST's stricter rule that a new code never resets the count, keep a failure counter per number and purpose in your app and stop requesting codes once it is reached.
Resend cooldowns and safe sending
The resend button is where most cost and abuse problems begin. Twilio's developer guidance suggests one verification request per 30 seconds per phone number with exponential backoff, and Twilio Verify returns an error after more than five sends that never complete verification. These are one vendor's defaults, not a standard, but they make a sound model:
- First resend after 30 seconds, then 60, then 120, doubling each time.
- A hard cap on codes per phone number and purpose within a window.
- A CAPTCHA or other bot check before the first send and before resends. MITRE ATT&CK lists CAPTCHA protection as a mitigation for SMS pumping.
- A visible countdown on the resend button, so honest users wait instead of tapping again.
What PayPerWA does here depends on the endpoint. The Verify API refuses a new code to the same number within 30 seconds (OTP_COOLDOWN), after 5 sends in an hour (OTP_TOO_MANY_SENDS) and after 15 wrong codes in 24 hours (OTP_TOO_MANY_FAILED_CHECKS), all as 429 with retry_after_seconds and a Retry-After header for your countdown, and all checked before anything is charged. The number is normalised first, so +91 98765-43210 and 0091 98765 43210 share one set of limits. It uses a flat 30 seconds rather than a doubling backoff, and its limits are per number, so CAPTCHA and limits per IP address and device are still yours. Every call on a key, Verify API checks included, also counts toward a limit of 100 requests per minute by default, returned as 429 without a Retry-After header; with /api/v1/messages/send it is the only limit. That limit protects the platform, not your users, so on that path only your code stops one number from receiving many codes in a minute.
Handle timeouts carefully too. Neither send endpoint has an idempotency key. With /api/v1/messages/send, retrying a request that timed out can send, and charge, twice; with the Verify API, a retry within 30 seconds is refused without charge, but a later one sends a new, charged code that replaces the first. If the call times out, assume the code may have arrived, keep the cooldown running and let the user ask for a new code. Here is a sender for your own codes that handles these cases:
const PAYPERWA_API_KEY = process.env.PAYPERWA_API_KEY; // ppw_live_sk_...
export async function sendWhatsAppOtp(phone: string, code: string): Promise<string | null> {
let res: Response;
try {
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, // digits only with country code, e.g. '919876543210'
template_name: 'login_otp', // your APPROVED Authentication template
language: 'en', // must match the template's language code
variables: [code], // fills the body and the Copy Code button
}),
signal: AbortSignal.timeout(15_000),
});
} catch {
// Timeout or network error: the code may already be on its way.
// No idempotency key, so do not retry automatically.
throw new Error('OTP_SEND_UNCERTAIN');
}
const data = await res.json().catch(() => null);
if (!data) throw new Error('OTP_SEND_UNCERTAIN'); // non-JSON reply, e.g. a proxy error page: outcome unknown
if (!res.ok || !data.success) {
// 400 validation or OWN_NUMBER, 401 bad key, 402 INSUFFICIENT_WALLET_BALANCE,
// 403 missing messages:send permission, 404 template not found or not approved,
// 409 META_API_OFF, 429 key rate limit, or Meta's error in data.details.
// Log the status and error, never the request body: it contains the code.
console.error('OTP send failed', res.status, data.error);
throw new Error('OTP_SEND_FAILED');
}
if (data.data.delivery_warning) {
// Meta accepted the message, but reports the WhatsApp account is blocked from starting conversations.
console.warn('WhatsApp account issue:', data.data.delivery_warning.message);
}
return data.data.message_id; // wamid (null if Meta returned none); matches data.wamid in webhook events
}
Keep the returned message_id with your send log so you can match delivery and failure webhooks later; the Verify API returns one too, next to its verification_id. Full request and response details are in the API documentation.
Hash codes at rest and bind them to phone, purpose and session
Your OTP store is a short-lived credential store. OWASP says OTPs should not sit in long-term plaintext and recommends hashing for short-term protection. A plain SHA-256 of a six-digit code is weak, because anyone holding the hash can try all one million values almost instantly. Use an HMAC keyed with a server-side secret (a pepper) that is not stored alongside the records, so a leaked database or Redis dump is useless without the key.
Put context into what you hash. NIST requires the secret to be carried from the phone back into the session where the user is signing in, rather than approved on the phone, to show that the user is actively taking part. OWASP requires tokens to be linked to a single user. Following from both, a code should be valid only for the phone number it was sent to, the purpose it was issued for (login, password reset, number change, payout) and the session that requested it. A login code then cannot confirm a payout, and a code requested in one browser cannot be completed in another.
The Verify API stores only a keyed hash of each code, never the code, and never returns the code in a response. It ties each code to your account and the phone number; binding it to purpose and browser session is still your job, for example by keeping the number that requested the code in the server-side session and only ever verifying that number.
If you keep your own codes, this sketch combines the controls from the sections above. It uses an abstract key-value store, which in production would usually be Redis or your database.
import { randomInt, createHmac, timingSafeEqual } from 'node:crypto';
const CODE_DIGITS = 6; // NIST minimum; up to 8 still autofills on iOS 26+
const CODE_TTL_MS = 10 * 60_000; // match the template's 'expires in N minutes' footer
const MAX_FAILED = 5; // wrong guesses per phone + purpose, across resends
const MAX_SENDS = 5; // codes per phone + purpose per window
const RESEND_BASE_MS = 30_000; // 30 s, then 60 s, 120 s ...
const WINDOW_MS = 60 * 60_000; // record (and any lockout) lives 1 hour after last activity
const PEPPER = process.env.OTP_PEPPER!; // long random secret, never stored with the records
type OtpRecord = { codeHash: string; expiresAt: number; failed: number; sends: number; lastSentAt: number };
interface OtpStore {
get(key: string): Promise<OtpRecord | null>;
set(key: string, rec: OtpRecord, ttlMs: number): Promise<void>;
del(key: string): Promise<void>;
}
// Binds the code to the phone, the purpose and the requesting session.
const hashCode = (phone: string, purpose: string, sessionId: string, code: string): string =>
createHmac('sha256', PEPPER).update(`${phone}|${purpose}|${sessionId}|${code}`).digest('hex');
export async function issueOtp(store: OtpStore, phone: string, purpose: string, sessionId: string): Promise<string> {
const key = `otp:${purpose}:${phone}`;
const prev = await store.get(key);
const now = Date.now();
if (prev) {
if (prev.failed >= MAX_FAILED || prev.sends >= MAX_SENDS) throw new Error('LOCKED');
const wait = RESEND_BASE_MS * 2 ** (prev.sends - 1);
if (now - prev.lastSentAt < wait) throw new Error('RESEND_COOLDOWN');
}
const code = randomInt(0, 10 ** CODE_DIGITS).toString().padStart(CODE_DIGITS, '0'); // CSPRNG
await store.set(key, {
codeHash: hashCode(phone, purpose, sessionId, code), // overwrites: older codes stop working
expiresAt: now + CODE_TTL_MS,
failed: prev?.failed ?? 0, // a new code never resets failures
sends: (prev?.sends ?? 0) + 1,
lastSentAt: now,
}, WINDOW_MS);
return code; // pass straight to sendWhatsAppOtp(); never log it or return it to the browser
}
export async function verifyOtp(
store: OtpStore, phone: string, purpose: string, sessionId: string, input: string,
): Promise<'ok' | 'invalid' | 'expired' | 'locked'> {
const key = `otp:${purpose}:${phone}`;
const rec = await store.get(key);
if (!rec) return 'invalid';
if (rec.failed >= MAX_FAILED) return 'locked';
if (Date.now() > rec.expiresAt) return 'expired';
const expected = Buffer.from(rec.codeHash, 'hex');
const candidate = Buffer.from(hashCode(phone, purpose, sessionId, input.trim()), 'hex');
if (timingSafeEqual(expected, candidate)) { // constant time; both are 32 bytes
await store.del(key); // single use
return 'ok';
}
rec.failed += 1;
await store.set(key, rec, WINDOW_MS);
return rec.failed >= MAX_FAILED ? 'locked' : 'invalid';
}
In production, make the read-check-write in both functions atomic, for example with a Redis transaction or Lua script. Otherwise two parallel requests can both slip past the cooldown, parallel guesses can exceed the attempt limit, and one correct code can be accepted twice. This sketch also forgets failures an hour after the last activity, so keep a longer-lived failure count per account as well if you want to stay inside NIST's 100-attempt ceiling. For the HTTP endpoints around these functions, see WhatsApp OTP login code examples.
Never log, return or link the code
The OWASP MFA Cheat Sheet says implementations should not log OTP values, and the OWASP Logging Cheat Sheet lists access tokens, authentication passwords and other primary secrets among the data that usually should not be recorded. Codes leak in more places than people expect:
- Outgoing request logs. With
/api/v1/messages/send, your call carries the code invariables, so redact that field or skip body logging for that call. With the Verify API the send request carries no code, but your verify request carries what the user typed. - Error trackers and APM tools that attach request payloads to exceptions, including the code a user submits to your verify endpoint.
- Debug lines such as
console.log(phone, code)left in after testing. - URLs and analytics. Never put a code in a link, a query string or an analytics event.
- API responses. The endpoint that sends a code should return “sent”, never the code. PayPerWA's Verify API never returns it either.
Log what monitoring needs instead: a masked phone number, the purpose, the WhatsApp message ID, the outcome and the error code on failure.
SMS pumping, AIT and OTP form abuse
SMS pumping, also called SMS toll fraud or Artificially Inflated Traffic, uses a phone-number field, usually an OTP or signup form, to trigger large volumes of messages to number ranges on a mobile operator that shares the termination revenue with the fraudster. The business pays for every message. Warning signs include bursts to adjacent or sequential numbers on one operator, traffic from countries you do not serve, and codes that are sent but never verified. OWASP covers the pattern under API4:2023, Unrestricted Resource Consumption.
A WhatsApp code does not travel over an operator's SMS network, so there is no SMS termination fee for a fraudster to share, and SMS pumping itself has nothing to pump. That does not take away the cost to you: bots can still request codes you pay for. In India, Meta's list rate for an authentication message is ₹0.115, and PayPerWA's fee is ₹0.20. If a bot gets 10,000 unwanted codes delivered, that is about ₹1,150 in Meta fees plus ₹2,000 in PayPerWA fees, and 10,000 people receiving codes they never asked for. For recipients in other countries, Meta charges that country's rate, listed on the country rates page, and the PayPerWA fee stays the same (₹0.20, or $0.004 on a USD wallet).
Meta bills its fee to the payment method on your WhatsApp Business account. PayPerWA's ₹0.20 comes from your prepaid wallet, charged before the send and refunded automatically if Meta rejects the message or later reports it as failed. Every send needs that fee in the wallet, so an empty wallet stops API sends with a 402 error. That puts a ceiling on a runaway bill, but it also stops your genuine codes, and Meta's charges arrive separately, so watch both. The Verify API's per-number limits are checked before any charge, which stops repeated sends to one number from costing you; they do nothing against one code each to many numbers.
Layer your defences; Twilio's own fraud documentation notes that no provider-side solution can guarantee full protection. The controls recommended by Twilio, OWASP and MITRE come down to:
- Bot checks: CAPTCHA or equivalent on send and resend.
- Rate limits on several keys: per phone number, IP address, device and number range.
- A country allow-list: turn off countries you do not serve.
- Reputation checks: extra friction for IP addresses with a poor reputation, including VPN and TOR exits.
- Spending limits and alerts on every paid messaging integration, as OWASP recommends.
- For SMS: line-type lookups so codes go only to mobile numbers, and blocking carriers that send abusive traffic.
Here is a simple gate to run after the CAPTCHA check and before issueOtp. The limits are examples; set yours from your normal traffic.
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);
const ALLOWED_COUNTRY_CODES = ['91']; // only the countries you actually serve
// Fixed-window counter: false once `limit` is exceeded within `windowSec`.
async function allow(key: string, limit: number, windowSec: number): Promise<boolean> {
const count = await redis.incr(key);
if (count === 1) await redis.expire(key, windowSec);
return count <= limit;
}
export async function otpSendBlockedReason(phone: string, ip: string): Promise<string | null> {
if (!/^\d{10,15}$/.test(phone)) return 'invalid_number';
// Prefix check is fine for '91'; use a phone-number library if you serve several countries.
if (!ALLOWED_COUNTRY_CODES.some((cc) => phone.startsWith(cc))) return 'country_not_served';
const range = phone.slice(0, -4); // number range: all but the last 4 digits
const limits: Array<[string, number, number]> = [
[`otp:rl:ip:${ip}`, 10, 3600], // per IP per hour
[`otp:rl:range:${range}`, 20, 3600], // per 10,000-number block per hour
['otp:rl:global', 2000, 3600], // whole-site hourly budget
];
for (const [key, limit, windowSec] of limits) {
if (!(await allow(key, limit, windowSec))) return 'rate_limited';
}
return null; // per-number cooldowns and caps: issueOtp(), or the Verify API
}What WhatsApp delivery changes, and what it does not
WhatsApp authentication templates come with protections that SMS lacks. None of them replaces the server-side controls above.
| Risk | What WhatsApp authentication templates do | Still your job |
|---|---|---|
| Code seen on a shared or linked computer | On Cloud API, authentication messages go only to the user's primary WhatsApp device; linked devices show a masked prompt. On by default. | Tell users to check their phone. |
| Code forwarded to a scammer | WhatsApp disables forwarding of authentication messages. | Users can still read a code aloud or screenshot it; keep the security line on. |
| Phishing inside the message | Meta sets the body text; URLs, media and emojis are not allowed. The optional “For your security, do not share this code.” line is on by default in the PayPerWA builder. | Tell users a genuine code message from you never contains a link. |
| Codes arriving late | Undelivered messages are dropped when the TTL runs out. | Enforce expiry on your server, or let the Verify API do it. |
| Guessing and enumeration | No protection from WhatsApp itself; PayPerWA's Verify API cancels a code after 5 wrong attempts and sends a number no new code after 15 wrong codes in 24 hours. | Identical responses for unknown numbers, plus attempt limits and lockouts if you keep your own codes. |
| Bot abuse and cost | Each delivered message is billed, but no SMS is sent, so there is no SMS pumping. The Verify API's per-number limits apply before any charge. | CAPTCHA, IP rate limits, a country allow-list, spend alerts. |
| Number takeover or recycled numbers | No built-in protection: a recycled number can still carry the previous owner's WhatsApp account. | Use SIM-change and porting signals where available; add a stronger factor for recovery. |
Meta's best-practice guidance makes the last row explicit: for account recovery, do not treat a phone number and its WhatsApp account as interchangeable, because a recycled number can still carry the previous owner's WhatsApp. Meta also asks you to confirm the user's WhatsApp number before sending and to say clearly that the code is going to WhatsApp.
SMS has its own weak spots. The OWASP MFA Cheat Sheet lists SIM swap, phishing, lock-screen previews, malicious apps reading messages and per-message cost, and advises against SMS for high-value or PII-handling applications. NIST classes OTP over the phone network as a restricted authenticator and asks you to offer a non-restricted alternative. In India, the RBI has not dropped SMS OTP either: announcing its 2025 framework for authenticating digital payments, it said the framework does not call for discontinuing SMS-based OTP as a factor. Under TRAI's rules, an OTP sent within 30 minutes of a customer-initiated transaction is a Transactional message and is not blocked by DND, but adding promotional content makes the whole message promotional, so keep SMS OTP templates free of offers.
For payouts, number changes and account recovery, add a stronger factor such as an authenticator app or a passkey, whichever channel carries the code. Meta suggests offering a choice of channels such as WhatsApp, email and SMS; PayPerWA sends WhatsApp only, so any fallback needs a separate provider. Opt-in still applies, with no exemption for authentication; PayPerWA's own opt-out block covers only Marketing and Utility templates, so a contact marked as opted out still receives the codes they request. Meta accepts a general opt-in that is not specific to WhatsApp, as long as it names your business, makes clear the person is agreeing to hear from you, and complies with local law. For a full comparison, read WhatsApp OTP vs SMS OTP in India.
Monitoring and alerting
Controls fail quietly unless you watch them. Track these and alert on sudden changes:
- OTP conversion rate: codes verified divided by codes sent (on the Verify API,
approvedresults against successful sends), by country, number prefix and hour. Twilio recommends alerting when it drops; falling conversion with rising sends is a common sign of bot abuse. - Send volume per hour against your normal baseline, and how often each rate limit fires.
- Lockouts and CAPTCHA failures (on the Verify API,
max_attemptsresults), which rise during guessing or scripted attacks. - Delivery failures: subscribe to PayPerWA's
message.failedwebhook. For API sends,data.wamidmatches themessage_idfrom the send response, anddata.errorCodeanddata.errorMessageexplain the failure. - Provider errors:
429means you hit the per-key rate limit (verify calls count too), from growth or abuse;402means the wallet is empty and codes have stopped going out. On the Verify API, a rise inOTP_COOLDOWNorOTP_TOO_MANY_SENDSresponses shows numbers being hammered, andOTP_TOO_MANY_FAILED_CHECKSshows codes being guessed. - The
delivery_warningfield in a successful send response. It appears when Meta accepted the message but reports that your WhatsApp Business account is blocked from starting conversations, for example because of a payment problem, so the code will not be delivered until you fix it. Treat it as urgent. - Spend on both sides: your PayPerWA wallet balance and the Meta charges on your WhatsApp Business account.
Two webhook details matter. Each delivery carries an X-PayPerWA-Signature header, sha256= plus the hex HMAC-SHA256 of the raw body using your webhook secret, so verify it before trusting the event. And each delivery is attempted once, with an 8-second timeout and no retry, so acknowledge quickly and reconcile from the dashboard if your endpoint was down.
OTP security checklist
Use this list when you build or review an OTP flow on WhatsApp or SMS. Items marked (Verify API) are handled for you if you use PayPerWA's Verify API; everything else is yours on either path.
Codes
- Generated with a cryptographically secure random generator. (Verify API)
- Numeric, 6 to 8 digits (WhatsApp allows up to 15 characters; iOS autofill needs 3 to 8 digits).
- Valid for 10 minutes or less, with the same expiry shown in the template footer. (Verify API enforces the expiry; the footer is yours.)
- Single use, and a resend overwrites the previous code. (Verify API)
Verification
- Failures counted per phone number and purpose; resends do not reset the count. (Verify API: 5 per code, plus 15 wrong codes per number in 24 hours across resends; purpose is yours.)
- Temporary lockouts with a support route for genuine users. (Verify API: no new code for up to 24 hours after 15 wrong codes; the support route is yours.)
- Constant-time comparison and identical responses whether or not an account exists.
Storage and logging
- Only a keyed HMAC stored, bound to phone, purpose and session, with the key kept elsewhere. (Verify API stores a keyed hash per number; purpose and session binding stay yours.)
- No codes in logs, error trackers, analytics, URLs or API responses.
Abuse and cost
- CAPTCHA on send and resend.
- A resend cooldown per number, ideally with exponential backoff. (Verify API: a flat 30 seconds and at most 5 sends an hour.)
- Rate limits per IP, device and number range, plus a global budget.
- Country allow-list limited to the markets you serve.
- Timed-out sends are not retried automatically.
- Spend alerts on the PayPerWA wallet and on Meta billing.
WhatsApp specifics
- Approved Authentication template with the security recommendation on.
- The
languagein each request matches the template's language code. - Users have opted in, and the screen says the code is going to WhatsApp.
- Account recovery does not rely on the phone number alone.
message.failedwebhooks verified and monitored.
If you want to send OTPs over WhatsApp, you can create a PayPerWA account, set up an Authentication template under Dashboard → Templates, and create an API key under Settings → API. There is no subscription and no DLT registration. In India each OTP costs Meta's ₹0.115 list rate, billed by Meta to your WhatsApp Business account, plus PayPerWA's ₹0.20 fee from your wallet, and verifying a code is free. The WhatsApp OTP API page sums up the Verify API and the OTP API reference has every field. See pricing for details, or the help centre if you get stuck.
Frequently Asked Questions
How long should an OTP code be?
How long should an OTP stay valid?
Does PayPerWA generate or verify OTP codes?
/api/v1/messages/send, your server does all of it.Does sending OTPs over WhatsApp stop SMS pumping?
How many wrong attempts should I allow?
Should I store OTP codes in my database?
Do I need user opt-in to send WhatsApp OTPs?
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.