# 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.

Canonical: https://mobilevalidate.com/docs/recipes/otp-signup-guard · Last updated: 2026-09-25

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](https://github.com/brntech/mobilevalidate-sdk/tree/main/examples/otp-signup-guard/express) and a [Next.js route handler](https://github.com/brntech/mobilevalidate-sdk/tree/main/examples/otp-signup-guard/nextjs).

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

```js
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:

```ts
// 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](/docs/test-values) with the public sandbox key:

| Input | Decision |
|---|---|
| `+447700900001` | WhatsApp |
| `+447700900002` | SMS (not registered) |
| `+447700900003` | SMS (unknown) |
| `+447700900004` | WhatsApp, after about 5 seconds of pending |
| `7700 900001` with a [personal test key](/get-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.
