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 Markdown

This 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?

ResultAction
number_status is not validAsk the user to fix the number and show the API's suggestion (HTTP 422)
WhatsApp registered: trueSend the code on WhatsApp
registered: falseSend 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_costAdd extra verification, such as a CAPTCHA
Temporary errorFail open: send by SMS
Other errorReturn 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:

JavaScript
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:

TypeScript
// 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:

InputDecision
+447700900001WhatsApp
+447700900002SMS (not registered)
+447700900003SMS (unknown)
+447700900004WhatsApp, after about 5 seconds of pending
7700 900001 with a personal test keyFix 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.