Tutorial
How to Send WhatsApp OTP with Node.js Without Exposing API Keys
Sending OTP through WhatsApp is useful when SMS delivery is expensive, delayed, or unreliable for your audience. The safe pattern is simple: your Node.js backend generates the code, stores only a hashed value with a short expiry, sends the message through DNZ WhatsApp OTP, then verifies the user input in a separate endpoint. The browser never receives the DNZ API key.
The real problem: OTP is not only message delivery
A weak OTP implementation often focuses only on sending a six-digit code. In production, the harder parts are key secrecy, replay protection, expiry, throttling, auditability, and a clear recovery path when the user does not receive the code. If the frontend calls the OTP provider directly, the API key becomes public. If the app stores raw OTP codes, a database leak turns short-lived verification into a security incident.
DNZ WhatsApp OTP gives you the delivery layer through the real `/api/send-otp` engine route. Your application remains responsible for authentication state: generating the code, hashing it, expiring it, limiting retries, and deciding when the user becomes verified. This split keeps the messaging system reusable while letting each application enforce its own security rules.
Architecture for a production Node.js flow
Use two endpoints in your own backend. The first endpoint receives a phone number or user ID, checks whether the request is allowed, generates an OTP, stores a hash with an expiry time, and calls DNZ. The second endpoint receives the code typed by the user, compares it with the stored hash, checks expiry and attempt count, then marks the login or transaction as verified.
Keep `WHATSAPP_OTP_ENGINE_URL` and `WHATSAPP_OTP_API_KEY` in server environment variables. The DNZ engine expects `apiKey`, `number`, and `message` in the JSON body. The route returns `202` with `{ accepted: true, jobId }` when the message is queued. That means the API accepted the send job; it does not mean the user has already verified the code.
Implementation steps
Start by normalizing phone numbers to E.164 format where possible. Rate-limit by user ID, phone number, and IP address. Generate six random digits with a cryptographic random source, never with `Math.random`. Store a hash of the OTP, not the raw code. A five-minute expiry is common for login; payment confirmation can be shorter if your risk model requires it.
When verifying, compare hashes using a constant-time comparison where practical, increment failed attempts, and delete or invalidate the OTP after a successful verification. If you allow resend, create a resend cooldown so users cannot accidentally queue many WhatsApp messages or trigger platform abuse systems.
import crypto from "node:crypto";
const ENGINE_URL = process.env.WHATSAPP_OTP_ENGINE_URL;
const API_KEY = process.env.WHATSAPP_OTP_API_KEY;
function generateOtp() {
return String(crypto.randomInt(100000, 1000000));
}
export async function requestLoginOtp(userId: string, phone: string) {
const code = generateOtp();
const expiresAt = new Date(Date.now() + 5 * 60 * 1000);
await saveOtpHash(userId, await hashOtp(code), expiresAt);
const res = await fetch(`${ENGINE_URL}/api/send-otp`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
apiKey: API_KEY,
number: phone,
message: `Your DNZ verification code is ${code}. It expires in 5 minutes.`,
}),
});
if (!res.ok) {
throw new Error(`DNZ OTP send failed: ${res.status} ${await res.text()}`);
}
return { ok: true };
}Error handling and user experience
Handle DNZ errors explicitly. `400 missing_fields` means your backend sent an incomplete payload. `400 invalid_number` means the normalized number is too short. `401 invalid_api_key` means the API key is wrong or inactive. `429` means a rate or plan limit blocked the message. A `500 send_otp_failed` should be logged and shown to the user as a temporary delivery issue.
On the UI, do not tell users whether a phone number belongs to an existing account unless your product requires it. A neutral message such as 'If this number can receive verification, a code will be sent' reduces account enumeration risk. For logged-in payment confirmations, you can be more specific because the user is already authenticated.
Internal links and next steps
After the Node.js integration is working, review the API reference for exact request and response fields, then test one frontend flow such as React or Flutter through your own backend. Keep the DNZ dashboard open during testing so you can compare application logs with queued jobs and plan limits.
For production launch, connect your monitoring to request failures, OTP verification failures, and unusual resend volume. OTP is a security workflow, so treat delivery metrics and abuse metrics as part of the same system.
FAQ
Should Node.js call DNZ directly?
Yes, but only from your backend. Browser and mobile clients should call your own API first.
Can I store the OTP in plain text?
No. Store a hash with an expiry time and remove it after verification.
What does a 202 response mean?
It means the send job was accepted into the queue, not that the user entered the code.
Related content
Start sending real WhatsApp OTP
Open the WhatsApp OTP dashboard, create an account, connect WhatsApp, and generate your API key.
Open WhatsApp OTP