Checking a number before you send a one-time passcode (OTP) tells you whether it looks like a real, reachable mobile line or a likely source of abuse. One request returns line type, live network status and whether the number has a messenger account. Your flow then sends, asks for more verification or refuses, before you pay for a message.
Why check a number before sending a code?
Every OTP you send costs money, and fraudsters know it. In SMS pumping, also called artificially inflated traffic, attackers make your sign-up form send large numbers of codes to number ranges they profit from. Fake sign-ups use throwaway numbers to farm promotions or open accounts in bulk. See SMS pumping and our guide to how SMS pumping works.
A pre-send check gives your risk engine facts to work with:
- Line type from the carrier lookup:
mobile,fixed_line,voip,premium_rate,toll_freeand other types. A sign-up with a premium-rate number, or an SMS code sent to a fixed line, is worth a second look. - Live network status from the HLR lookup:
reachable,unreachableorinvalid(not assigned). A code sent to an unassigned number can never be entered; one sent to a switched-off phone arrives too late. - Messenger presence, for example a WhatsApp or Telegram account. A number with an account passed that platform's own sign-up verification at some point, which makes it more likely to be in real use.
- Spam reputation (optional, limited access): reports and regulator actions against the number.
None of these proves identity. Together they help you decide where extra verification is worth its cost.
How does the workflow look?
The check runs between "user entered a number" and "we send a code":
- Normalize the number to E.164. The API does this for you and uses
default_countryfor numbers typed in national format. - Call
POST /v1/lookupwithchecks: ["carrier", "whatsapp"]and a shortwait, for example 5 seconds. Addhlrwhere undelivered codes are costly. - Apply the decision table below to the results.
- Log the decision and the
checked_attime, not the raw response. Keep what you store to a minimum. - If a check is
pendingorunknown, carry on with your default path. Don't block a user because a check timed out.
Your per-IP and per-number rate limits on the code-sending endpoint still apply. The check adds to those defences and doesn't replace them. The OTP sign-up guard recipe shows the whole flow in code.
Which checks answer which question?
| Check | What it answers | When to run it | Link |
|---|---|---|---|
carrier | Mobile, VoIP, fixed line, premium rate or toll free? | Before every first code to a new number | Carrier lookup |
hlr | Is the number assigned and reachable now? | Before codes where a failed delivery is costly | HLR lookup |
whatsapp, telegram | Is the number registered on this app? | Sign-up, as an "in use" signal or to offer app delivery | WhatsApp, Telegram |
spam (limited access) | Is the number in spam or fraud reports? | Sign-up, where enabled | Spam reputation |
What should you do with each result?
| Signal | Suggested action |
|---|---|
line_type is mobile and a messenger account exists | Send the code as usual |
line_type is mobile and no messenger account | Send the code. Consider a lower send limit for this number |
line_type is voip | Review: ask for a second factor or use a different verification method |
line_type is premium_rate or shared_cost | Block: a consumer is very unlikely to receive a sign-up code on these numbers |
line_type is toll_free | Review: some toll-free numbers, for example in the US and Canada, can receive texts, but consumers rarely sign up with one |
line_type is fixed_line | Offer a voice call instead of SMS |
HLR status: invalid | Refuse the number; it isn't assigned |
HLR status: unreachable | Offer another channel now, such as e-mail or an app the user chose |
Spam risk_level is high (if enabled) | Review or block, depending on your risk appetite |
Any check unknown or pending | Fall back to your normal flow; don't block on missing data |
These are starting points. Tune them with your own fraud data and review the results regularly.
How much does it cost?
You pay per check, and only for conclusive answers. You're not charged for inconclusive results (unknown, unsupported country, timeout, invalid, duplicate). A request with two checks on one number can bill two checks. Real-time prices apply because sign-up needs an answer straight away. See pricing for current rates, including the HLR lookup.
Two things help keep the cost down. Repeat checks of the same number inside the freshness window come from your account's cache for free, which helps when a user taps "resend code". And max_cost puts a hard ceiling on what any single request can cost. Compare the cost of a check with the cost of an SMS to the destinations you serve. The check pays for itself when it stops messages that would have been wasted or abused.
What does a request look like?
This test-mode request checks two numbers. +447700900001 answers with data and +447700900003 simulates a timeout, so you can see both paths.
curl https://api.mobilevalidate.com/v1/lookup \
-H "Authorization: Bearer mv_test_publicSandboxn9ZgneuhR1B9CRfKG3fulym" \
-H "Content-Type: application/json" \
-d '{"numbers":["+447700900001","+447700900003"],"checks":["carrier","whatsapp"],"wait":5}'// npm install mobilevalidate · Node.js 20+ · save as check.mjs and run: node check.mjs
import { MobileValidate } from "mobilevalidate";
// Omit apiKey to read MOBILEVALIDATE_API_KEY from the environment.
const mv = new MobileValidate({ apiKey: "mv_test_publicSandboxn9ZgneuhR1B9CRfKG3fulym" });
const { data, error } = await mv.lookup({
numbers: ["+447700900001", "+447700900003"],
checks: ["carrier", "whatsapp"],
wait: 5,
});
if (error) console.error(error.code, error.message);
else console.dir(data.results, { depth: null });// Node.js 18+, Deno, Bun or the browser console (ES module: save as .mjs or use "type": "module").
const res = await fetch("https://api.mobilevalidate.com/v1/lookup", {
method: "POST",
headers: {
Authorization: "Bearer mv_test_publicSandboxn9ZgneuhR1B9CRfKG3fulym",
"Content-Type": "application/json",
},
body: JSON.stringify({
numbers: ["+447700900001", "+447700900003"],
checks: ["carrier", "whatsapp"],
wait: 5,
}),
});
console.log(res.status, res.headers.get("x-request-id"));
console.dir(await res.json(), { depth: null });# pip install mobilevalidate-sdk
from mobilevalidate import MobileValidate
# Omit api_key to read MOBILEVALIDATE_API_KEY from the environment.
mv = MobileValidate(api_key="mv_test_publicSandboxn9ZgneuhR1B9CRfKG3fulym")
result = mv.lookup(
numbers=["+447700900001", "+447700900003"],
checks=["carrier", "whatsapp"],
wait=5,
)
print(result)<?php
$ch = curl_init('https://api.mobilevalidate.com/v1/lookup');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer mv_test_publicSandboxn9ZgneuhR1B9CRfKG3fulym',
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'numbers' => ['+447700900001', '+447700900003'],
'checks' => ['carrier', 'whatsapp'],
'wait' => 5,
]),
]);
$response = curl_exec($ch);
echo curl_getinfo($ch, CURLINFO_RESPONSE_CODE), PHP_EOL, $response, PHP_EOL;package main
import (
"fmt"
"io"
"net/http"
"strings"
)
func main() {
body := strings.NewReader(`{"numbers":["+447700900001","+447700900003"],"checks":["carrier","whatsapp"],"wait":5}`)
req, err := http.NewRequest("POST", "https://api.mobilevalidate.com/v1/lookup", body)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer mv_test_publicSandboxn9ZgneuhR1B9CRfKG3fulym")
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
out, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(out))
}require "net/http"
require "json"
uri = URI("https://api.mobilevalidate.com/v1/lookup")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer mv_test_publicSandboxn9ZgneuhR1B9CRfKG3fulym"
req["Content-Type"] = "application/json"
req.body = JSON.generate({
"numbers" => ["+447700900001", "+447700900003"],
"checks" => ["carrier", "whatsapp"],
"wait" => 5
})
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
puts res.code, res.bodyRuns as pasted with the public sandbox key, which answers the test values only. In the SDKs, omit the key to use MOBILEVALIDATE_API_KEY.
Response (excerpt, test mode: the checks of both items in results):
[
{
"network.carrier": {
"service": "network.carrier", "status": "completed", "registered": true,
"attributes": {"line_type": "mobile", "carrier": "Test Carrier", "country": "GB"},
"confidence": "high", "confidence_score": 0.99, "checked_at": "2026-09-25T14:28:22.282Z",
"cached": false, "age_seconds": 0, "billed": false, "reason": null, "poll_after_ms": null
},
"whatsapp.registered": {
"service": "whatsapp.registered", "status": "completed", "registered": true, "attributes": null,
"confidence": "high", "confidence_score": 0.99, "checked_at": "2026-09-25T14:28:22.282Z",
"cached": false, "age_seconds": 0, "billed": false, "reason": null, "poll_after_ms": null
}
},
{
"network.carrier": {
"service": "network.carrier", "status": "unknown", "registered": null, "attributes": null,
"confidence": null, "confidence_score": null, "checked_at": null,
"cached": false, "age_seconds": null, "billed": false, "reason": "UPSTREAM_TIMEOUT", "poll_after_ms": null
},
"whatsapp.registered": {
"service": "whatsapp.registered", "status": "unknown", "registered": null, "attributes": null,
"confidence": null, "confidence_score": null, "checked_at": null,
"cached": false, "age_seconds": null, "billed": false, "reason": "UPSTREAM_TIMEOUT", "poll_after_ms": null
}
}
]The first number gets its code as usual. The second falls back to your default path, and neither unknown answer is charged.
What limits and rules apply?
A lookup takes up to 100 numbers and up to 20 checks. The total of numbers × checks can't exceed 2,000. Requests that look like sequential number ranges or generated e-mail lists are rejected. Twenty or more consecutive numbers in one request return suspected_enumeration. Each account also has a daily cap on numbers, which GET /v1/limits reports.
What about consent and compliance?
Use the checks only on numbers people give you to sign up or verify. Tell users in your privacy notice that you verify phone numbers to prevent fraud. The results are risk signals, not identity proof, and must not be used for credit, employment, housing or insurance decisions. SMS codes themselves are a restricted authenticator in NIST SP 800-63B, so offer stronger options such as passkeys where you can. People whose numbers were checked can object through the opt-out form.
What are the common mistakes?
- Blocking on
unknown. A timeout is not fraud. Fall back to your normal flow. - Dropping rate limits because checks exist. Checks and rate limits cover different attacks. Keep both.
- Blocking all VoIP. Review or step up instead; genuine users have VoIP numbers.
- Re-checking on every resend. The cache serves repeats for free inside the freshness window; don't force
max_age: 0except for HLR checks where freshness matters. - Ignoring destination countries. If you never have users in a country, a country allow-list stops pumping there more cheaply than any check.
Frequently asked questions
Should I block every VoIP number at sign-up?
Usually not. Many genuine users have VoIP numbers. Treat line_type voip as a reason for extra review or a different verification step, not as a block on its own.
How fast is the check in a sign-up flow?
Real-time checks run on POST /v1/lookup with a wait of up to 30 seconds (default 10). If an answer is not back in time the check returns pending, and your flow can continue with its default path.
What happens when a check returns unknown?
Unknown means no conclusive answer, for example a timeout. It has registered set to null, it is not charged, and your flow should fall back to its normal behaviour instead of blocking the user.
Does this replace rate limiting on my OTP endpoint?
No. Number checks add a signal before you send, but you still need per-IP, per-number and per-country rate limits on the endpoint that sends codes.
Can I use the spam reputation check here?
Spam reputation is in limited access for now (approved customers only) and accepts numbers from all countries except sanctioned ones. Where it is enabled for your account you can add it as an extra signal.
When is the HLR lookup worth it before an OTP?
When a code that doesn't arrive is costly. The HLR lookup says whether the number is assigned and reachable now, so you can offer another verification channel at once for unreachable phones and refuse unassigned numbers. Send max_age 0 for a fresh answer.
What is SMS pumping?
It is fraud in which attackers make your sign-up or login form send large numbers of codes to number ranges they profit from. It is also called artificially inflated traffic. Pre-send checks, rate limits and country controls work together against it.
Does a messenger account prove the user is real?
No. It shows the number passed that platform's own sign-up at some point, which makes it more likely to be in use. It says nothing about who holds the number.
Related
Guides on this topic
All articlesResearch
OTP cost by country: WhatsApp authentication rates, SMS cost drivers and the cost of a wasted code
WhatsApp authentication rates for 44 markets (Meta, October 2026), why SMS OTP prices differ by country, and when a number check costs less than a wasted code.
14 min read
Fraud prevention
SMS pumping by country: which destinations to allow, gate or block
No public list ranks SMS pumping countries. How to sort destinations into allow, gate and block from published sources and your data, and which ranges to block.
12 min read
Fraud prevention
Silent authentication and flash call, explained
How silent network authentication (CAMARA Number Verification) and flash-call verification work, their coverage limits, and where pre-checks still help.
6 min read


