OTP and sign-up guard
Check a phone number before sending a one-time code: fix invalid numbers, send on WhatsApp when registered, fall back to SMS, and add friction for VoIP numbers. Express and Next.js.
Last updated
View as MarkdownThis recipe checks a phone number once, before you send a one-time code. It tells you whether to ask the user to fix the number, send the code on WhatsApp, or send it by SMS. There are two variants with the same rules: an Express app and a Next.js route handler.
What does the guard decide?
| Result | Action |
|---|---|
number_status is not valid | Ask the user to fix the number and show the API's suggestion (HTTP 422) |
WhatsApp registered: true | Send the code on WhatsApp |
registered: false | Send the code by SMS |
registered: null (unknown) | Send the code by SMS, and record "unknown", not "no" |
Carrier line_type is voip, premium_rate, toll_free or shared_cost | Add extra verification, such as a CAPTCHA |
| Temporary error | Fail open: send by SMS |
| Other error | Return the error code and its suggestion |
What does the code look like?
The decision is a pure function, so you can unit-test it without the API:
export function decideOtpChannel(row) {
if (row.number_status !== "valid") {
return { action: "fix_number", suggestion: row.suggestion ?? "Enter your number with the country code." };
}
const lineType = row.checks?.["network.carrier"]?.attributes?.line_type;
const extraVerification = ["voip", "premium_rate", "toll_free", "shared_cost"].includes(lineType);
const wa = row.checks?.["whatsapp.registered"];
if (wa?.registered === true) return { action: "send_whatsapp", extraVerification };
if (wa?.registered === false) return { action: "send_sms", reason: "whatsapp_not_registered", extraVerification };
return { action: "send_sms", reason: "whatsapp_unknown", extraVerification }; // null is not "no"
}The Next.js route handler calls the API once and applies the decision:
// app/api/otp/route.ts
import { MobileValidate } from "mobilevalidate";
import { decideOnError, decideOtpChannel } from "../../../lib/otp-decision.ts";
const mv = process.env.MOBILEVALIDATE_API_KEY ? new MobileValidate() : new MobileValidate({ sandbox: true });
export async function POST(req: Request) {
const { phone, country } = await req.json();
const { data, error } = await mv.lookup(phone, {
checks: ["whatsapp", "carrier"], defaultCountry: country, wait: 5, waitTimeoutMs: 8_000,
});
const decision = error ? decideOnError(error) : decideOtpChannel(data.results[0]);
return Response.json(decision, { status: decision.action === "fix_number" ? 422 : 200 });
}Keep the key on the server. Never put it in a NEXT_PUBLIC_ variable or a browser bundle.
How do I test it?
Use the test values with the public sandbox key:
| Input | Decision |
|---|---|
+447700900001 | |
+447700900002 | SMS (not registered) |
+447700900003 | SMS (unknown) |
+447700900004 | WhatsApp, after about 5 seconds of pending |
7700 900001 with a personal test key | Fix the number (missing country code) |
The sandbox key refuses any other number with 403 sandbox_magic_only and a suggestion.
What should I log?
Log the decision, the requestId and a masked number such as +44••••••••01. Never log full phone numbers.
Frequently asked questions
What happens when the WhatsApp answer is unknown?
The guard sends the code by SMS. Unknown (registered: null) is never billed, and you should record it as unknown rather than as not registered.
Should a VoIP number be blocked?
No. A VoIP line type is a reason for extra verification, such as a CAPTCHA, not a block on its own. The carrier check is in beta.
What if the check fails?
The guard fails open for temporary errors (retryable errors, timeouts, network problems) and sends the code by SMS, so sign-ups are never blocked by an unavailable check.

