Nabda OTPNabda OTP

Language

Login
Developer April 20, 2026 7 min read

Top 5 WhatsApp OTP Mistakes Developers Make (and How to Fix Them)

From wrong phone number formats to missing webhook handlers — these are the most common pitfalls developers hit when integrating WhatsApp OTP with Nabda API, and exactly how to fix them.

WhatsApp OTP integration with Nabda is straightforward — but a few recurring mistakes can cause hours of debugging. Whether you're building a new integration or auditing an existing one, this guide covers the top 5 issues we see in support tickets and how to avoid them.

#1Wrong Phone Number Format

The most common mistake: sending a phone number without the country code, or with a leading zero instead of the country code. Nabda OTP requires E.164 format — meaning the number must start with a + followed by the country code.

Wrong
❌ avoid
// ❌ Wrong — missing country code
const phone = "07701234567";

await fetch("https://api.nabdaotp.com/api/v1/messages/send", {
  body: JSON.stringify({ phone, message: "Your OTP: 123456" })
});
Correct
✅ correct
// ✅ Correct — E.164 format
const phone = "+9647701234567"; // Iraq
// const phone = "+9639XXXXXXXX"; // Syria
// const phone = "+201XXXXXXXXX"; // Egypt

await fetch("https://api.nabdaotp.com/api/v1/messages/send", {
  body: JSON.stringify({ phone, message: "Your OTP: 123456" })
});
💡

Nabda OTP supports all MENA country codes. Iraq: +964, Syria: +963, Egypt: +20, Saudi: +966, UAE: +971. Always validate the format server-side before sending.


#2Using the Wrong API Token

Nabda OTP has two types of tokens: your global account token and an instance-scoped Bearer token. Sending messages requires the instance token — obtained by calling select-instance. Using your global token will always return 401 Unauthorized.

Wrong
❌ avoid
// ❌ Wrong — using global Nabda token, not instance token
const res = await fetch("https://api.nabdaotp.com/api/v1/messages/send", {
  headers: { "Authorization": `Bearer ${globalApiToken}` }
});
// Result: 401 Unauthorized
Correct
✅ correct
// ✅ Correct — select instance first, then use instance token
const selectRes = await fetch(
  "https://api.nabdaotp.com/api/v1/auth/select-instance",
  {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ instanceId: "your-instance-id" })
  }
);
const { accessToken } = await selectRes.json();

// Now use accessToken for messages
const res = await fetch("https://api.nabdaotp.com/api/v1/messages/send", {
  headers: { "Authorization": `Bearer ${accessToken}` }
});
💡

The instance token should be stored securely on your backend and refreshed after each select-instance call. Never expose it in frontend code or logs.


#3Storing Plain OTP on the Client

Storing an OTP in localStorage, a cookie, or sending it back in the API response is a critical security mistake. Anyone with access to the browser DevTools can see and reuse it. Always generate, hash, and verify OTPs entirely on the backend.

Wrong
❌ avoid
// ❌ Wrong — storing plain OTP and verifying on the client
const otp = "482917";
localStorage.setItem("otp", otp); // NEVER DO THIS

// Client-side verification — easily bypassed
if (userInput === localStorage.getItem("otp")) {
  allowLogin();
}
Correct
✅ correct
// ✅ Correct — hash on backend, verify on backend
import crypto from "crypto";

const SECRET = process.env.OTP_SECRET;
const otp    = generateOTP(); // e.g. "482917"
const hash   = crypto.createHash("sha256")
                 .update(otp + SECRET)
                 .digest("hex");

// Store hash + expiry in DB, never the plain OTP
await db.otpStore.set(userId, { hash, expiresAt: Date.now() + 5 * 60 * 1000 });

// On verification — backend only
function verifyOTP(input, storedHash) {
  const inputHash = crypto.createHash("sha256")
    .update(input + SECRET).digest("hex");
  return inputHash === storedHash;
}
💡

Use a cryptographic hash (SHA-256) combined with a server-side secret. Store the hash in your database — never the plain OTP. This way, even a DB leak exposes nothing usable.


#4No Expiry or Rate Limiting on OTP Verification

An OTP without an expiry time is permanently valid — allowing brute-force attacks. Without rate limiting, an attacker can try all 1,000,000 possible 6-digit codes in minutes. Both protections are essential.

Wrong
❌ avoid
// ❌ Wrong — no expiry, no rate limiting
// OTP stored forever, brute-forceable

app.post("/verify", (req, res) => {
  const { otp } = req.body;
  if (otp === storedOtp) { // stored without expiry
    res.json({ success: true });
  }
});
Correct
✅ correct
// ✅ Correct — expiry check + rate limiting
const MAX_ATTEMPTS = 5;
const OTP_TTL_MS   = 5 * 60 * 1000; // 5 minutes

app.post("/verify", rateLimiter({ max: MAX_ATTEMPTS, window: "15m" }), async (req, res) => {
  const record = await db.otpStore.get(userId);

  if (!record) return res.status(400).json({ error: "OTP expired or not found" });
  if (Date.now() > record.expiresAt) return res.status(400).json({ error: "OTP expired" });
  if (!verifyOTP(req.body.otp, record.hash)) return res.status(400).json({ error: "Invalid OTP" });

  await db.otpStore.delete(userId); // single-use
  res.json({ success: true });
});
💡

Set OTP expiry to 3–8 minutes. Limit verification attempts to 5 per 15-minute window per user. After 5 failed attempts, invalidate the OTP and require a new one.


#5Not Monitoring Instance Connection Status

A Nabda OTP instance requires a connected WhatsApp session. If the QR code expires or the phone disconnects, your instance goes offline — and OTPs stop delivering silently. Without a webhook, you won't know until users start complaining.

Wrong
❌ avoid
// ❌ Wrong — not handling instance disconnection
// Instance QR expires or WhatsApp disconnects — messages silently fail
// No alert, no fallback, users never receive their OTP
Correct
✅ correct
// ✅ Correct — configure webhook + monitor instance status
// 1. Set up webhook on Nabda dashboard or via API:
await fetch("https://api.nabdaotp.com/api/v1/instances/webhook", {
  method: "PATCH",
  headers: { "Authorization": `Bearer ${instanceToken}` },
  body: JSON.stringify({
    webhookUrl: "https://yourdomain.com/nabda/webhook",
    webhookEnabled: true
  })
});

// 2. Handle events in your webhook handler:
app.post("/nabda/webhook", (req, res) => {
  const { event, status } = req.body;

  if (event === "instance.disconnected") {
    // Alert your team — re-scan QR code required
    notifyTeam("Nabda instance disconnected — OTP delivery paused");
  }

  if (event === "message.failed") {
    // Log and retry or fallback to SMS
    handleDeliveryFailure(req.body);
  }

  res.sendStatus(200);
});
💡

Always configure a webhook on your Nabda instance. Listen for instance.disconnected events and alert your team immediately. Consider an automated monitor that checks instance health every few minutes.

Quick Checklist Before Going Live

  • Phone numbers use E.164 format with country code (+964, +963, +20…)
  • Instance token is fetched via select-instance — not the global account token
  • OTPs are hashed server-side, never stored as plain text or sent to the client
  • OTP expiry is set (3–8 min) and verification is rate-limited (max 5 attempts)
  • Webhook is configured to monitor instance status and message delivery events

Build it right from the start

Start your free Nabda OTP instance and follow these best practices from day one.

Create Free Instance
Hello, how can we help you?