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.
#1 — Wrong 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 — 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 — 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.
#2 — Using 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 — 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 — 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.
#3 — Storing 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 — 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 — 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.
#4 — No 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 — 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 — 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.
#5 — Not 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 — not handling instance disconnection
// Instance QR expires or WhatsApp disconnects — messages silently fail
// No alert, no fallback, users never receive their OTP// ✅ 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