Back to Blog
Guide

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.

PayPerWA Team20 min read

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:

ControlWith the Verify APIWith your own codes
Code from a cryptographically secure random generatorPayPerWA (numeric, 4 to 8 digits, 6 by default)You
Code stored only as a keyed hash, never returned in an API responsePayPerWAYou
ExpiryPayPerWA (1 to 30 minutes, 10 by default)You
Single use; a resend replaces the previous codePayPerWAYou
Wrong-attempt limitPayPerWA: 5 wrong attempts cancel a code, and after 15 wrong codes for a number in 24 hours no new code is sent to itYou
Resend cooldown and hourly cap per numberPayPerWA: 30 seconds between sends, at most 5 an hour, checked before any chargeYou
CAPTCHA or bot check on your request formYouYou
Limits per IP address, device and number rangeYouYou
Country allow-listYouYou
Binding the code to purpose and session; identical responses for unknown numbersYouYou
Monitoring and spend alertsYouYou

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 formatPossible valuesOn WhatsApp
6 digits1,000,000Meets NIST's minimum; iOS 26+ keyboard suggestions can autofill it
8 digits100,000,000Longest code iOS keyboard suggestions detect
9 to 15 digits1,000,000,000 and upAllowed by Meta; iPhone users must use Copy Code
Alphanumeric, up to 15 charactersLarger for the same lengthAllowed 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:

  1. 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.
  2. 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.
  3. 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 in variables, 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.

RiskWhat WhatsApp authentication templates doStill your job
Code seen on a shared or linked computerOn 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 scammerWhatsApp disables forwarding of authentication messages.Users can still read a code aloud or screenshot it; keep the security line on.
Phishing inside the messageMeta 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 lateUndelivered messages are dropped when the TTL runs out.Enforce expiry on your server, or let the Verify API do it.
Guessing and enumerationNo 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 costEach 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 numbersNo 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, approved results 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_attempts results), which rise during guessing or scripted attacks.
  • Delivery failures: subscribe to PayPerWA's message.failed webhook. For API sends, data.wamid matches the message_id from the send response, and data.errorCode and data.errorMessage explain the failure.
  • Provider errors: 429 means you hit the per-key rate limit (verify calls count too), from growth or abuse; 402 means the wallet is empty and codes have stopped going out. On the Verify API, a rise in OTP_COOLDOWN or OTP_TOO_MANY_SENDS responses shows numbers being hammered, and OTP_TOO_MANY_FAILED_CHECKS shows codes being guessed.
  • The delivery_warning field 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 language in 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.failed webhooks 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?
At least six digits, which is NIST's minimum, and OWASP suggests considering eight or more where usability allows. For WhatsApp, numeric codes of 6 to 8 digits work best: Meta allows up to 15 characters, but iOS keyboard autofill only detects numeric codes of 3 to 8 digits.
How long should an OTP stay valid?
NIST treats an out-of-band OTP as invalid unless it is used within 10 minutes, and each code should work only once. On WhatsApp, set the template's expiry footer to the same value you enforce. The PayPerWA builder's footer defaults to 15 minutes while the Verify API's expiry defaults to 10, so set the footer to 10 if you keep that default.
Does PayPerWA generate or verify OTP codes?
Yes, with the Verify API. It generates codes with a cryptographic random number generator, stores only a keyed hash, expires them, accepts each once, cancels a code after 5 wrong attempts, limits each number to one send per 30 seconds and 5 an hour, and sends no new code to a number after 15 wrong codes in 24 hours. CAPTCHA, IP limits and a country allow-list stay yours. If you send your own codes with /api/v1/messages/send, your server does all of it.
Does sending OTPs over WhatsApp stop SMS pumping?
It removes SMS pumping itself, because no SMS is sent and there is no carrier revenue for a fraudster to share. It does not stop bot abuse: bots can still trigger codes you pay for, in India Meta's ₹0.115 list rate plus PayPerWA's ₹0.20 fee per OTP. Keep CAPTCHA, layered rate limits, a country allow-list and conversion monitoring in place.
How many wrong attempts should I allow?
There is no universal number. NIST requires rate limiting and sets a ceiling of no more than 100 consecutive failures, while Twilio Verify allows 5 checks per verification and PayPerWA's Verify API cancels a code after 5 wrong attempts and stops sending a number new codes after 15 wrong codes in 24 hours. If you keep your own codes, count failures across resends so a new code never resets the counter.
Should I store OTP codes in my database?
Store only a keyed HMAC of the code, bound to the phone number, purpose and session, and delete it once it has been used. Never store or log the plain code.
Do I need user opt-in to send WhatsApp OTPs?
Yes. Meta requires opt-in before messaging anyone on WhatsApp and gives no exemption for authentication messages. Since November 2024 a general opt-in is enough if it names your business, makes clear the person is agreeing to hear from you, and complies with local law.

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).

Share this article

P

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.

Try PayPerWA — Just 20 Paisa Per Message

No subscription. No monthly fee. Just ₹0.20 platform fee + Meta's standard API charges.

Create free account

Related Articles

Comparison

WhatsApp OTP vs SMS OTP in India: An Honest Comparison (2026)

WhatsApp and SMS can both deliver OTPs in India, but they differ on cost, setup, autofill, reach and fraud risk. WhatsApp skips DLT registration and, on PayPerWA, needs no subscription, while SMS can cost less per message. Here is a sourced side-by-side comparison and a practical way to use both.

9 Nov 202618 min read
Guide

WhatsApp OTP API in India (2026): How It Works, Costs and Setup

How WhatsApp one-time passwords work in India on Meta's Cloud API, what each code costs (Meta ₹0.115 + PayPerWA ₹0.20 per OTP, with no subscription or DLT registration), how to send and verify codes with PayPerWA's two-call Verify API or with your own code, and when to keep SMS as a backup.

9 Nov 202622 min read
Chat with us