Tutorials

WhatsApp OTP in India: Developer Guide with an SMS OTP Backup

Send WhatsApp OTPs in India with Meta authentication templates and the StartMessaging API, then call our separate SMS OTP API when WhatsApp delivery fails.

StartMessaging Team Updated
WhatsApp OTP in India: Developer Guide with an SMS OTP Backup

Vikram sat back in his chair at 11:45 PM, staring at the metrics dashboard for RupeeFlow, a Bengaluru-based neo-banking startup. The sign-up funnel conversion chart was bleeding red. Over the last 30 days, the platform had lost nearly 14% of onboarding users at the mobile verification step. The cause wasn’t a broken frontend UI or slow database queries; it was SMS OTP latency. Jio and Airtel subscribers in tier-2 cities were regularly waiting up to 45 seconds for their 6-digit codes to arrive. Many just closed the app. Vikram knew they needed another channel, and WhatsApp OTP was the obvious candidate.

But switching authentication channels is never a drop-in replacement. As Vikram’s team soon discovered, moving to WhatsApp OTP in India means working within Meta’s fixed authentication template format, understanding how autofill works on Android and iOS, handling the users who do not have WhatsApp, and knowing how Meta prices authentication messages. This guide covers the decisions and the Node.js code for a production-grade WhatsApp OTP flow, plus how your backend can switch to StartMessaging’s separate SMS OTP API when a WhatsApp code can’t be delivered.


Why Indian Developers Are Moving to WhatsApp OTP in 2026

The shift from SMS OTP to WhatsApp verification in India comes down to three things: developer friction, delivery experience and cost. A reliable authentication system weighs these against real limits.

Bypassing the DLT Bureaucracy

For any developer who has set up SMS verification in India, the Distributed Ledger Technology (DLT) mandate is a source of constant friction. (See our step-by-step DLT registration guide or learn how to write compliant DLT template variables). Mandated by the Telecom Regulatory Authority of India (TRAI), DLT registration requires entity verification, header (sender ID) approvals, and strict content template scrubbing. Under the DLT system, if you want to tweak a single word in your SMS OTP text, you must submit it to the DLT portal (such as Jio, VIL, or BSNL DLT) and wait anywhere from 48 hours to 2 weeks for approval. If you send an OTP message that deviates by even a single whitespace character from the approved template, the telecom carriers scrub and block it at the gateway. The headers themselves are constrained by strict 6-character alphabetic rules (e.g., AD-RPFLOW), which are hard to reserve and manage.

SMS DLT Flow:
[Submit Template to DLT] ➔ [Wait 2-14 Days for Manual Review] ➔ [Approve Header] ➔ [TRAI Gatekeeper Scrubbing] ➔ [Strict Match Block on Handset]

WhatsApp Flow:
[Create authentication template in the dashboard] ➔ [Meta reviews it] ➔ [Approved] ➔ [Send with POST /api/v1/messages]

WhatsApp messages are not part of the DLT system. Authentication templates are sent over Meta’s WhatsApp Business Platform, so TRAI’s DLT registration, which applies to SMS, is not needed for them. There are no principal entity registrations (PE-ID) to link to headers. You still need the user’s consent and a Meta-approved authentication template. Meta reviews every template and sets the review time; because Meta writes the body text of an authentication template itself, there is little for you to get wrong.

The Cost Equation

For high-volume applications, cost is often the deciding factor, and the two channels are priced differently:

  • SMS OTP: a per-message price from your SMS provider. StartMessaging’s non-DLT SMS for India is a flat ₹0.25 per SMS.
  • WhatsApp OTP: Meta charges every delivered authentication template at its authentication rate, which varies by country, and bills it directly to your own WhatsApp Business Account using the payment method you add in WhatsApp Manager. StartMessaging adds nothing per message: you pay only for your StartMessaging plan.

Meta’s rates change from time to time, so check Meta’s official WhatsApp pricing for the current Indian authentication rate rather than relying on a number in a blog post. Each message you send through the API also carries a pricing field that shows the category Meta billed it under.

Billing factorSMS OTP (StartMessaging, India)WhatsApp OTP (StartMessaging)
Per-message charge₹0.25 per SMSMeta’s authentication rate, billed by Meta to your WABA
StartMessaging per-message feeIncluded in the ₹0.25None
Platform feeNoneYour StartMessaging plan (Developer Free, or from ₹100/month)
What Meta charges forNot applicableDelivered messages

Delivery and User Retention

SMS relies on cellular signal and SS7 signalling. In high-density apartments or basement offices across urban India, cellular signals degrade and SMS delivery can be delayed. WhatsApp runs over IP and delivers over mobile data and Wi-Fi, which helps where SMS filtering or congestion delays codes.

WhatsApp OTP is not a silver bullet, though. Some of your users, often in rural areas, older age groups or on basic phones, don’t use WhatsApp or are offline when they sign up. If your app relies only on WhatsApp, you lock them out of onboarding. Plan a second channel from the start (read our SMS deliverability checklist or the head-to-head comparison of SMS OTP vs WhatsApp OTP).


WhatsApp Authentication Templates: Format, Autofill, and Pitfalls

Unlike marketing or utility templates, authentication templates have a fixed format. You cannot add your own body text, URLs, media or promotional copy.

The Template Anatomy

You create an authentication template in the StartMessaging dashboard (Templates → Create template). Templates can’t be created through the API. Meta writes the message text itself, for example “123456 is your verification code”, and you choose:

  1. Language of the template.
  2. Security warning: Meta’s optional line, “For your security, do not share this code.”
  3. Code expiry: an optional line saying how long the code is valid.
  4. Button: a Copy code button, so nobody has to retype the code.

The template then goes to Meta for review. Only templates with the status APPROVED can be sent; anything else answers 422 TEMPLATE_NOT_APPROVED. The template.status_updated webhook tells your system the moment Meta decides.

Button Configurations: Copy Code vs. One-Tap Autofill

  1. Copy code (universal): a button that copies the code to the clipboard. The user switches back to your app and pastes it. This works on Android and iOS.
  2. One-tap autofill (Android): on supported Android apps, a single tap hands the code to your app and fills it in. Meta ties one-tap autofill to your Android app’s package name and signing key hash, so only your app receives the code.

On iOS, users tap the copy-code button and paste the code into your app.

The Authentication-International Rate

Meta prices WhatsApp messages by the recipient’s country and the message category. In some countries, Meta charges a higher authentication-international rate for authentication messages, depending on where Meta considers the sending business to be based. Check how this applies to you on Meta’s pricing page before you launch.

You can see what Meta actually did: each sent message’s pricing.category is authentication or authentication-international. Look up a message with GET /api/v1/messages/{id} after the first delivery receipt and check it on your first test sends.


Full Node.js Implementation: End-to-End

Let’s build a WhatsApp OTP verification flow with Node.js, Express and Redis. StartMessaging only delivers the message: there is no separate OTP or verify endpoint for WhatsApp, so you generate the code, store it and verify it yourself.

Prerequisites

Before writing code, make sure you have:

  • A StartMessaging workspace with your own WhatsApp Business Account connected (Meta’s Embedded Signup, on the Connect page).
  • A payment method added in WhatsApp Manager. Without it Meta refuses template sends and the API answers 402 META_PAYMENT_REQUIRED.
  • An approved authentication template, here named login_otp in en_US.
  • An API key from Developers → API keys (it starts with sm_live_). The Send only preset (messages:send) is enough to send.
  • A webhook endpoint for message.delivered and message.failed, added in Developers → Webhooks, with its signing secret.
  • A running Redis instance for temporary OTP storage.

Every plan includes the developer API. Developer Free sends up to 100 API messages a month; a paid Developer plan never stops authentication templates, even past its monthly allowance. See pricing for the sizes.

1. Generating a Secure OTP and Storing a Hash in Redis

Never use Math.random() to generate one-time passwords. Use Node’s crypto module, and store only a hash of the code.

// otpService.js
import crypto from 'node:crypto';
import { createClient } from 'redis';

const redisClient = createClient({ url: process.env.REDIS_URL || 'redis://localhost:6379' });
await redisClient.connect();

const OTP_EXPIRY_SECONDS = 600; // match the expiry shown in your template

/** Cryptographically secure 6-digit code, leading zeros allowed. */
function generateSecureOTP() {
  return String(crypto.randomInt(0, 1_000_000)).padStart(6, '0');
}

function sha256(value) {
  return crypto.createHash('sha256').update(value).digest('hex');
}

/**
 * Save a hash of the OTP against a login attempt id.
 * Never store plaintext codes or phone numbers in cache keys or logs.
 */
async function storeOTP(attemptId, phone, otp) {
  const redisKey = `otp:${attemptId}`;
  await redisClient.hSet(redisKey, {
    phoneHash: sha256(phone),
    codeHash: sha256(otp),
    attempts: '0',
    channel: 'whatsapp',
    createdAt: Date.now().toString(),
  });
  await redisClient.expire(redisKey, OTP_EXPIRY_SECONDS);
}

export { generateSecureOTP, storeOTP, sha256, redisClient };

2. Sending the WhatsApp Authentication Message

Send the template with POST /api/v1/messages. The code goes once, as the first entry of parameters: it fills the body, and StartMessaging puts the same code on the template’s copy-code button for you. Send the template’s Meta name and its language code as plain strings.

// whatsappClient.js
const WA_API_BASE = 'https://waba-api.startmessaging.com';

/**
 * Send the authentication template carrying the code.
 * Returns the message id; throws with the API's error code on failure.
 */
async function sendWhatsAppOTP(phone, otp, attemptId) {
  const res = await fetch(`${WA_API_BASE}/api/v1/messages`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.SM_API_KEY}`,
      'Content-Type': 'application/json',
      'Idempotency-Key': `otp-${attemptId}`,
    },
    body: JSON.stringify({
      to: phone, // E.164, e.g. +919876543210
      type: 'template',
      template: {
        name: 'login_otp',
        language: 'en_US',
        parameters: [{ text: otp }],
      },
      clientReference: `login-attempt-${attemptId}`,
    }),
  });

  const body = await res.json();
  if (!res.ok) {
    const err = new Error(`${body.error.code}: ${body.error.message}`);
    err.code = body.error.code; // branch on error.code, never on the message
    err.status = res.status;
    throw err;
  }
  return body.data.id; // status is "sent" once WhatsApp accepted it
}

export { sendWhatsAppOTP };

A 201 response looks like this:

{
  "data": {
    "id": "3f6c2a1e-8b1d-4c53-9e7a-2b5d8f0c4a11",
    "status": "sent",
    "conversationId": "8a1b9c2d-3e4f-4a5b-8c6d-7e8f9a0b1c2d",
    "contactId": "5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a",
    "to": "+919876543210",
    "type": "template",
    "clientReference": "login-attempt-7f3a2c",
    "createdAt": "2026-10-02T10:00:00.000Z"
  },
  "meta": {
    "requestId": "b7c1e0d2-5a4f-4c3b-9e8d-1f2a3b4c5d6e",
    "timestamp": "2026-10-02T10:00:00.000Z"
  }
}

The errors worth handling in an OTP flow:

HTTPerror.codeWhat it means for OTP
422MESSAGE_UNDELIVERABLEWhatsApp won’t deliver to this number (for example it isn’t on WhatsApp). Offer SMS.
422TEMPLATE_NOT_APPROVEDThe template isn’t approved yet; details.status shows its status.
422TEMPLATE_BUTTON_PARAM_REQUIREDparameters was empty: put the code in parameters.
404TEMPLATE_NOT_FOUNDCheck template.name (the Meta name) and template.language.
402META_PAYMENT_REQUIREDAdd or fix the payment method in WhatsApp Manager.
402PLAN_MESSAGE_LIMIT_REACHEDDeveloper Free or the trial has used its monthly API messages.
429RATE_LIMIT_EXCEEDEDWait for Retry-After seconds, then retry with the same Idempotency-Key.
502WABA_CONNECT_FAILEDTransient WhatsApp failure; retry with the same Idempotency-Key.

3. Server-Side Verification with a Lockout Counter

To prevent brute-force attacks, limit verification attempts. After 3 wrong codes, delete the attempt. (Read our guide on OTP failed attempt lockout strategies and see how to structure your OTP database schema.)

// verifyService.js
import crypto from 'node:crypto';
import { sha256, redisClient } from './otpService.js';

const MAX_VERIFICATION_ATTEMPTS = 3;

async function verifyUserOTP(attemptId, submittedOtp) {
  const redisKey = `otp:${attemptId}`;
  const state = await redisClient.hGetAll(redisKey);

  if (!state || !state.codeHash) {
    return { success: false, reason: 'OTP_EXPIRED_OR_NOT_FOUND' };
  }

  if (parseInt(state.attempts, 10) >= MAX_VERIFICATION_ATTEMPTS) {
    await redisClient.del(redisKey);
    return { success: false, reason: 'MAX_ATTEMPTS_EXCEEDED' };
  }

  // Compare hashes in constant time.
  const expected = Buffer.from(state.codeHash, 'hex');
  const actual = Buffer.from(sha256(String(submittedOtp)), 'hex');
  if (!crypto.timingSafeEqual(expected, actual)) {
    await redisClient.hIncrBy(redisKey, 'attempts', 1);
    return { success: false, reason: 'INVALID_OTP' };
  }

  // Used once: delete it so it can't be replayed.
  await redisClient.del(redisKey);
  return { success: true };
}

export { verifyUserOTP };

4. Receiving Delivery Webhooks

StartMessaging posts message.delivered and message.failed events to your endpoint. Each one is signed: compute HMAC-SHA256 of X-SM-Timestamp, a dot and the raw body, using the whole whsec_… secret as the key, and compare it with the v1= values in X-SM-Signature. Every status event carries the clientReference you sent, so you can find the login attempt without storing our message id.

// webhookHandler.js
import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifySignature(rawBody, headers, secret) {
  const timestamp = String(headers['x-sm-timestamp'] ?? '');
  if (!/^\d+$/.test(timestamp)) return false;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

  const expected = Buffer.from(
    createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest('hex'),
  );
  return String(headers['x-sm-signature'] ?? '')
    .split(',')
    .some((part) => {
      const signature = Buffer.from(part.trim().replace(/^v1=/, ''));
      return signature.length === expected.length && timingSafeEqual(signature, expected);
    });
}

export function webhookRouter(onStatus) {
  const router = express.Router();

  router.post('/startmessaging', express.raw({ type: 'application/json' }), (req, res) => {
    if (!verifySignature(req.body, req.headers, process.env.SM_WEBHOOK_SECRET)) {
      return res.sendStatus(401);
    }
    const event = JSON.parse(req.body.toString('utf8'));
    res.sendStatus(200); // answer fast, then do the work

    if (event.type === 'message.delivered' || event.type === 'message.failed') {
      const { clientReference, status, failureCode, failureReason } = event.data.message;
      onStatus({ eventId: event.id, clientReference, status, failureCode, failureReason });
    }
  });

  return router;
}

Deliveries are at least once and can arrive out of order, so deduplicate on the event’s id.


Adding SMS OTP as a Second Channel

StartMessaging’s WhatsApp API has no built-in SMS fallback. If the customer is not on WhatsApp, the send fails, and nothing is resent for you. In India, StartMessaging also offers a separate SMS OTP API. It is a different product with its own API key, base URL and pricing, and your backend decides when to call it.

Your backend:
[POST /api/v1/messages (WhatsApp)] ➔ 422 MESSAGE_UNDELIVERABLE or message.failed webhook ➔ [POST /otp/send (SMS OTP API)]

When to Switch to SMS

  1. The send is refused: the WhatsApp API answers 422 MESSAGE_UNDELIVERABLE, for example because the number is not on WhatsApp.
  2. Meta reports a failure later: a message.failed webhook arrives with Meta’s failureCode and failureReason.
  3. No delivery receipt: no message.delivered webhook within, say, 30 seconds. The phone may be offline. Rather than sending a second code automatically, show a “Get the code by SMS” button so the user chooses.

The SMS OTP API Call

The SMS OTP API lives at https://api.startmessaging.com, uses an X-API-Key header, and takes the code you generated in variables.otp. Send the same code, so the user can enter whichever message arrives first and your verification step stays the same.

// smsClient.js
async function sendSmsOTP(phone, otp) {
  const res = await fetch('https://api.startmessaging.com/otp/send', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-API-Key': process.env.SM_SMS_API_KEY, // the SMS product's key, not the WhatsApp key
    },
    body: JSON.stringify({
      phoneNumber: phone, // E.164, e.g. +919876543210
      variables: { otp, appName: 'RupeeFlow' },
      // templateId: 'YOUR_TEMPLATE_ID', // required only on the DLT route
    }),
  });
  const body = await res.json();
  if (!res.ok) throw new Error(`SMS OTP send failed: ${res.status}`);
  return body.data.messageId; // check status with GET /messages/:id
}

export { sendSmsOTP };

See the SMS & OTP API reference for the full request, status values and rate limits.

Wiring It Together

// app.js
import express from 'express';
import crypto from 'node:crypto';
import { generateSecureOTP, storeOTP } from './otpService.js';
import { sendWhatsAppOTP } from './whatsappClient.js';
import { sendSmsOTP } from './smsClient.js';
import { webhookRouter } from './webhookHandler.js';

const app = express();

// In production keep this in Redis or your database, not in memory.
const pending = new Map(); // clientReference -> { phone, otp, timer }

async function switchToSms(clientReference) {
  const attempt = pending.get(clientReference);
  if (!attempt) return; // already delivered, or already switched
  clearTimeout(attempt.timer);
  pending.delete(clientReference);
  await sendSmsOTP(attempt.phone, attempt.otp);
}

// Mount the webhook route before express.json(): it needs the raw body.
app.use('/webhooks', webhookRouter(({ clientReference, status }) => {
  if (!clientReference) return;
  if (status === 'delivered') {
    const attempt = pending.get(clientReference);
    if (attempt) {
      clearTimeout(attempt.timer);
      pending.delete(clientReference);
    }
  }
  if (status === 'failed') {
    switchToSms(clientReference).catch(console.error);
  }
}));

app.use(express.json());

app.post('/api/auth/send-otp', async (req, res) => {
  const { phone } = req.body; // E.164 with the +91 prefix
  if (!phone || !phone.startsWith('+91')) {
    return res.status(400).json({ error: 'INVALID_INDIAN_PHONE_NUMBER' });
  }

  const attemptId = crypto.randomUUID();
  const otp = generateSecureOTP();
  await storeOTP(attemptId, phone, otp);
  const clientReference = `login-attempt-${attemptId}`;

  try {
    await sendWhatsAppOTP(phone, otp, attemptId);
    // No delivery receipt in 30 seconds: let the app offer SMS.
    const timer = setTimeout(() => {
      const attempt = pending.get(clientReference);
      if (attempt) attempt.offerSms = true;
    }, 30_000);
    pending.set(clientReference, { phone, otp, timer });
    return res.json({ attemptId, channel: 'whatsapp' });
  } catch (err) {
    if (err.code === 'MESSAGE_UNDELIVERABLE') {
      await sendSmsOTP(phone, otp);
      return res.json({ attemptId, channel: 'sms' });
    }
    console.error('WhatsApp OTP send failed:', err.message);
    return res.status(502).json({ error: 'OTP_SEND_FAILED' });
  }
});

// Called when the user taps "Get the code by SMS".
app.post('/api/auth/resend-sms', async (req, res) => {
  const clientReference = `login-attempt-${req.body.attemptId}`;
  await switchToSms(clientReference);
  return res.json({ channel: 'sms' });
});

app.listen(3000, () => console.log('Authentication backend listening on port 3000'));

For production routing, see our guides on multi-region failover for OTP APIs and queue-based OTP delivery with BullMQ and Redis.

Adding a Third Channel

Some apps add a voice call as a last resort for users who receive neither WhatsApp nor SMS. StartMessaging doesn’t send voice OTPs, so this would be another provider your backend calls in the same way:

[1. WhatsApp OTP (StartMessaging WhatsApp API)]
      │
      ├─► (refused, failed, or user asks for SMS)
      ▼
[2. SMS OTP (StartMessaging SMS OTP API)]
      │
      ├─► (still not received)
      ▼
[3. Voice OTP (another provider)]

Security, Compliance, and Cost Planning

Implementing OTP infrastructure in India requires strict security practices and compliance with the Digital Personal Data Protection (DPDP) Act of 2023 (see our guide on DPDP Act compliance for OTP).

Mitigating OTP Pumping Attacks

OTP fraud and traffic pumping attacks don’t only target SMS. (See our guide on preventing SMS pumping fraud in India and our OTP rate limiting guide.) Bots hit OTP endpoints to run up message charges, and every delivered WhatsApp authentication template is billed to your WhatsApp Business Account. Secure your backend:

  1. IP-based rate limiting: limit OTP requests per IP address, for example 1 every 60 seconds.
  2. Per-number cooldowns: enforce a 60-second resend cooldown per phone number.
  3. Daily caps: restrict a single phone number to a handful of OTP requests per day.
  4. Bot checks: use a CAPTCHA or Cloudflare Turnstile on the client before your backend sends a code.

DPDP Act 2023 Compliance

Under India’s DPDP Act, phone numbers and login records are personal data. Your OTP flow should include:

  • Consent: tell users, where they enter their number, that they will receive verification codes on WhatsApp or SMS.
  • Data minimisation: never write raw OTP codes or plaintext phone numbers to debug logs, request logs or metrics. Use hashes and attempt ids, as in the code above.

Modelling Your Costs

Compare the two channels with your own numbers. For 500,000 OTPs a month, where a share of users (s) ends up on SMS:

SMS only:
500,000 × ₹0.25 (StartMessaging non-DLT SMS)

WhatsApp first, SMS when WhatsApp can't deliver:
WhatsApp: 500,000 × (1 − s) × Meta's authentication rate (billed by Meta to your WABA)
SMS:      500,000 × s × ₹0.25
Plus:     your StartMessaging WhatsApp plan

Take Meta’s current Indian authentication rate from Meta’s rate card, and measure s from your own MESSAGE_UNDELIVERABLE and message.failed counts during a pilot.


Frequently Asked Questions

Q: Does WhatsApp OTP require DLT registration in India?

A: No. WhatsApp authentication templates are sent over Meta’s WhatsApp Business Platform, so TRAI’s DLT registration, which applies to SMS, is not needed for them. You still need the user’s consent and a Meta-approved authentication template.

Q: Does StartMessaging have a WhatsApp OTP or verify endpoint?

A: No. Send an authentication template with POST /api/v1/messages, with the code in template.parameters. Generating, storing and verifying the code is up to your backend.

Q: Can WhatsApp OTP autofill like SMS Retriever on Android?

A: Yes, on supported Android apps. An authentication template with a one-tap autofill button hands the code to your app with one tap. Everywhere else, the copy-code button copies it for pasting.

Q: What happens if the user doesn’t have WhatsApp?

A: The send fails: usually the API answers 422 MESSAGE_UNDELIVERABLE, or a message.failed webhook arrives later. There is no automatic SMS fallback. In India, your backend can then call StartMessaging’s separate SMS OTP API (POST https://api.startmessaging.com/otp/send) with the same code.

Q: Does RBI accept WhatsApp OTP as a second factor for payments?

A: RBI’s authentication directions require an additional factor of authentication but do not limit it to SMS. Whether a WhatsApp OTP fits your payment flow is a decision for your compliance team; see our RBI authentication directions guide.

Q: Is WhatsApp OTP cheaper than SMS OTP in India?

A: It depends on Meta’s current authentication rate, which you can find on Meta’s rate card, compared with your SMS price (₹0.25 per SMS on StartMessaging’s non-DLT route). Meta charges only for delivered WhatsApp messages and bills them directly to your WhatsApp Business Account; StartMessaging charges no per-message fee for WhatsApp.


Ready to add WhatsApp OTP to your login flow? Start a 14-day free trial, connect your own WhatsApp Business Account and follow the WhatsApp OTP recipe in our docs. Meta needs a payment method on your WhatsApp Business Account before your first template send. For the SMS side, see the SMS OTP API.

S

StartMessaging Team

StartMessaging Team

Related posts