OTP providers, verification platforms and SMS resellers check a number before routing it so they don't pay for codes and messages that can't arrive. One request returns live network status (HLR), the network a ported number is on now (MNP), the line type, and whether the number is registered on WhatsApp or Telegram for a channel fallback. Checks run in real time per message or in bulk per list, and unknown answers are free.
Why check numbers before you route traffic?
A provider in the middle of the message path pays for every attempt, whether or not the code is entered. Four things eat margin:
- Routing by the original prefix. After a number is ported, its prefix still points at the old network. A message routed that way may take a more expensive path, be rejected or arrive late. The MNP lookup returns the current
mcc_mnc, so you route by the network that serves the number today. See how to check if a number is ported. - Numbers that aren't live. Unassigned numbers can never enter a code, and a switched-off phone receives it after it has expired. The HLR lookup answers
reachable,unreachableorinvalidbefore you send. - Lines that can't receive a text. Fixed lines, some VoIP ranges and premium-rate numbers. The carrier lookup returns
line_type, so you can switch to a voice code or refuse the request. - Artificial traffic. In SMS pumping, attackers make sign-up forms send codes to number ranges they profit from. The traffic lands on the provider first. Our SMS pumping by country guide covers where it concentrates.
A check costs a fraction of an international SMS to many destinations. Our OTP cost by country post compares channel costs per destination, so you can see where a check pays for itself.
How does the workflow look?
Real time, per request (OTP and transactional traffic):
- Normalize the number to E.164. Use
default_countryfor numbers your clients send in national format. - Call
POST /v1/lookupwithchecks: ["carrier"]for every first code to a new number, and addhlrfor destinations where a failed code is costly. Use a shortwait, such as 5 seconds. - Route by the HLR
mcc_mncwhenportedistrue. If you need routing only, ask formnpinstead ofhlr. - If the line is fixed, send a voice code. If the phone is
unreachableand the number has a WhatsApp or Telegram account, offer app delivery where your client's end user agreed to it. - On
pendingorunknown, route as you normally would. Never hold a code because a check timed out.
Bulk, per list (routing tables and campaign traffic):
- Run the free
POST /v1/jobs/estimateon the list. It counts valid, invalid, duplicate, cached and unsupported rows and returns the maximum cost. - Create the job with
POST /v1/jobsand pass the estimate asmax_cost. Group lists by country where you can: per-country minimums apply (see below). - Wait with
GET /v1/jobs/{id}?wait=30or ajob.completedwebhook, then download CSV or NDJSON. Each line keeps itsrow_no, so you join results back to your own file by row number. - Refresh porting data on a schedule. Cached answers inside the freshness window are free.
Rate limits on your own sending endpoints still apply. Checks add a signal; they don't replace per-number and per-country limits.
Which checks answer which question?
| Check | What it answers | When to run it | Link |
|---|---|---|---|
carrier | Mobile, fixed line, VoIP, premium rate or toll free? | First code to a new number | Carrier lookup |
mnp | Was the number ported? Which network serves it now? | Least-cost routing, routing-table refresh | MNP lookup |
hlr | Is it assigned and reachable now? Plus porting and current network | Costly destinations, old lists, retries | HLR lookup |
whatsapp, telegram | Is the number registered on the app? | Channel fallback for consented app delivery | WhatsApp, Telegram |
viber, rcs (bulk only) | Viber account, or an RCS-capable number? | Planning channels for opted-in lists | Viber, RCS |
To translate an mcc_mnc code into a country and network name, use the free MCC/MNC lookup. The routing with MNP and HLR use case goes deeper on route selection.
What should you do with each result?
| Result | Suggested action |
|---|---|
line_type: mobile, HLR reachable | Send the SMS on the normal route |
HLR ported: true or MNP porting: ported | Route by the returned mcc_mnc, not the prefix |
HLR status: unreachable | Offer a voice code or a consented app channel now; retry SMS later |
HLR status: invalid | Refuse; the number isn't assigned |
line_type: fixed_line | Send a voice code instead of SMS |
line_type: voip | Pass to your client's risk rules; many genuine users have VoIP numbers |
line_type: premium_rate or shared_cost | Refuse; a consumer is very unlikely to receive a code there |
WhatsApp or Telegram registered: true | Eligible for app delivery if the end user agreed to it |
Any check unknown or pending | Route as normal; unknown is free |
These are starting points. Tune them per destination with your own delivery receipts and conversion data.
What does a request look like?
This request uses the public sandbox key from the test values page, so you can run it as is. +447700900006 is a ported test number, and +447700900002 is a phone that is switched off.
curl https://api.mobilevalidate.com/v1/lookup \
-H "Authorization: Bearer mv_test_publicSandboxn9ZgneuhR1B9CRfKG3fulym" \
-H "Content-Type: application/json" \
-d '{"numbers":["+447700900006","+447700900002"],"checks":["hlr","carrier","whatsapp"],"wait":10}'// 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: ["+447700900006", "+447700900002"],
checks: ["hlr", "carrier", "whatsapp"],
wait: 10,
});
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: ["+447700900006", "+447700900002"],
checks: ["hlr", "carrier", "whatsapp"],
wait: 10,
}),
});
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=["+447700900006", "+447700900002"],
checks=["hlr", "carrier", "whatsapp"],
wait=10,
)
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' => ['+447700900006', '+447700900002'],
'checks' => ['hlr', 'carrier', 'whatsapp'],
'wait' => 10,
]),
]);
$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":["+447700900006","+447700900002"],"checks":["hlr","carrier","whatsapp"],"wait":10}`)
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" => ["+447700900006", "+447700900002"],
"checks" => ["hlr", "carrier", "whatsapp"],
"wait" => 10
})
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):
[
{
"number.hlr": {
"service": "number.hlr", "status": "completed", "registered": true,
"attributes": {"status": "reachable", "ported": true, "roaming": false, "network": "Test Mobile Two", "mcc_mnc": "23410", "country": "GB"},
"confidence": "high", "confidence_score": 0.99, "checked_at": "2026-11-08T02:49:40.617Z",
"cached": false, "age_seconds": 0, "billed": false, "reason": null, "poll_after_ms": null
},
"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-11-08T02:49:40.617Z",
"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-11-08T02:49:40.617Z",
"cached": false, "age_seconds": 0, "billed": false, "reason": null, "poll_after_ms": null
}
},
{
"number.hlr": {
"service": "number.hlr", "status": "completed", "registered": true,
"attributes": {"status": "unreachable", "ported": false, "roaming": false, "network": "Test Mobile", "mcc_mnc": "23415", "country": "GB"},
"confidence": "high", "confidence_score": 0.99, "checked_at": "2026-11-08T02:49:40.617Z",
"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": "NO_DATA", "poll_after_ms": null
},
"whatsapp.registered": {
"service": "whatsapp.registered", "status": "completed", "registered": false, "attributes": null,
"confidence": "high", "confidence_score": 0.99, "checked_at": "2026-11-08T02:49:40.617Z",
"cached": false, "age_seconds": 0, "billed": false, "reason": null, "poll_after_ms": null
}
}
]The first number was ported to the network 23410, so route the code there, not by its original prefix. The second is assigned but switched off, has no WhatsApp account and no carrier data: offer a voice code or hold the SMS for a retry. The unknown carrier answer is free.
For a list, the free estimate shows what a routing refresh would bill. Five rows with two duplicates (the same number once in national format, with default_country: "GB"):
curl https://api.mobilevalidate.com/v1/jobs/estimate \
-H "Authorization: Bearer mv_test_publicSandboxn9ZgneuhR1B9CRfKG3fulym" \
-H "Content-Type: application/json" \
-d '{"numbers":["+447700900001","+447700900002","+447700900006","+447700900006","07700 900001"],"checks":["hlr","mnp"],"default_country":"GB"}'// 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.jobs.estimate({
numbers: [
"+447700900001",
"+447700900002",
"+447700900006",
"+447700900006",
"07700 900001",
],
checks: ["hlr", "mnp"],
defaultCountry: "GB",
});
if (error) console.error(error.code, error.message);
else console.dir(data, { 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/jobs/estimate", {
method: "POST",
headers: {
Authorization: "Bearer mv_test_publicSandboxn9ZgneuhR1B9CRfKG3fulym",
"Content-Type": "application/json",
},
body: JSON.stringify({
numbers: [
"+447700900001",
"+447700900002",
"+447700900006",
"+447700900006",
"07700 900001",
],
checks: ["hlr", "mnp"],
default_country: "GB",
}),
});
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.jobs.estimate(
numbers=[
"+447700900001",
"+447700900002",
"+447700900006",
"+447700900006",
"07700 900001",
],
checks=["hlr", "mnp"],
default_country="GB",
)
print(result)<?php
$ch = curl_init('https://api.mobilevalidate.com/v1/jobs/estimate');
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',
'+447700900002',
'+447700900006',
'+447700900006',
'07700 900001',
],
'checks' => ['hlr', 'mnp'],
'default_country' => 'GB',
]),
]);
$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","+447700900002","+447700900006","+447700900006","07700 900001"],"checks":["hlr","mnp"],"default_country":"GB"}`)
req, err := http.NewRequest("POST", "https://api.mobilevalidate.com/v1/jobs/estimate", 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/jobs/estimate")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer mv_test_publicSandboxn9ZgneuhR1B9CRfKG3fulym"
req["Content-Type"] = "application/json"
req.body = JSON.generate({
"numbers" => [
"+447700900001",
"+447700900002",
"+447700900006",
"+447700900006",
"07700 900001"
],
"checks" => ["hlr", "mnp"],
"default_country" => "GB"
})
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.
{"object": "estimate", "total": 5, "valid": 3, "invalid": 0, "duplicate": 2, "cached": 0, "unsupported": 0,
"suppressed": 0, "checks": ["number.hlr", "number.mnp"], "checks_total": 10, "billable_max": 0,
"max_cost": {"amount": "0", "currency": "USD"},
"breakdown": [
{"check": "number.hlr", "price_mode": "batch", "reason": null, "checks": 3,
"unit_price": {"amount": "0.005", "currency": "USD"}, "max_cost": {"amount": "0", "currency": "USD"}},
{"check": "number.mnp", "price_mode": "batch", "reason": null, "checks": 3,
"unit_price": {"amount": "0.002", "currency": "USD"}, "max_cost": {"amount": "0", "currency": "USD"}}],
"unsupported_checks": []}Sandbox keys are never billed, so max_cost is 0 here. Only the three unique numbers would be checked. In a real job you would pick hlr or mnp, not both: the HLR answer already carries porting.
How much does it cost?
As of 8 November 2026, the live catalog lists these prices per conclusive answer:
| Check | Real time | Bulk |
|---|---|---|
Carrier lookup (carrier) | $0.003 | $0.0015 |
MNP lookup (mnp) | $0.002 | $0.002 |
HLR lookup (hlr) | $0.006 | $0.005 |
WhatsApp (whatsapp) | $0.004 | $0.00025 |
Telegram (telegram) | $0.001 | $0.0003 |
| Viber, RCS | bulk only | $0.00015, $0.0005 |
Current prices are always on the pricing page. Unknown, unsupported-country, timeout, invalid-format and duplicate results are free, and repeat checks inside the freshness window come from your account's cache for free. max_cost caps any single request or job.
Bulk jobs have two per-country rules. WhatsApp checks get the bulk price for groups of at least 132 numbers from one country; smaller groups are priced at the real-time price. Bulk-only checks, such as RCS, are billed at least the price of 500 checks for each country that returns an answer. Group your lists by destination to pay the bulk price per number. The estimate lists both cases as small_batch and country_minimum lines, as described in bulk jobs.
To try it on real numbers, every account can run 20 phone numbers and 10 e-mail addresses through the real-time checks for free on the dashboard's Try it page. A free test key answers the documented test values for integration work.
Can you resell checks or pass results to your clients?
Running checks to route, price and protect the traffic you carry is internal business use. Making results or the service available to third parties is different. Section 18.2 of the Terms says you may not resell, license or publish results, or make the service available to others, without our written agreement. That covers, for example, returning line type or HLR status to your clients through your own API, or adding number checks to a verification product you sell.
There is no self-serve reseller program. If you want to offer checks to your own customers, e-mail sales@mobilevalidate.com with your use case, the checks you need and your expected monthly volume. Committed-volume pricing is available on request.
What limits and rules apply?
- A real-time lookup takes up to 100 numbers and 20 checks, and numbers × checks can't exceed 2,000.
- A bulk job takes up to 50,000 numbers and e-mails, and rows × checks can't exceed 100,000.
- Requests that look like sequential number ranges are rejected. Twenty or more consecutive numbers in one request return
suspected_enumeration. Check the numbers your clients' users entered, not generated ranges. - Each account has a daily cap on numbers, which
GET /v1/limitsreports. Ask sales if your traffic needs a higher cap. - Numbers from sanctioned countries are never checked and answer
unsupported_country, free.
What about consent and compliance?
You check numbers on behalf of your clients, so agree the purpose with them in your contract and data processing terms. The numbers should be ones end users gave to receive a code or a message they expect. Checks are signals about a number, not identity proof. The HLR lookup returns roaming as true or false only and never a location, SIM identifier or network switch address. Messaging-app registration is not consent: deliver codes on an app only where the end user chose that channel. Our acceptable use policy forbids unsolicited bulk messaging and enumeration. People can object through the opt-out form. For code wording, the free OTP message template generator and SMS character counter help keep codes to one segment.
What are the common mistakes?
- Paying for HLR and MNP on the same number. The HLR answer includes porting. Use MNP alone when routing is all you need.
- Routing ported numbers by prefix after the check. Use the returned
mcc_mnc; that is the point of the check. - Treating
unreachableasinvalid. Unreachable numbers exist; the phone is off or out of coverage. Retry or switch channel, don't delete. - Sending small country groups in WhatsApp bulk jobs. Below 132 numbers per country, the real-time price applies. Batch per destination.
- Running HLR on fixed lines. HLR queries exist only for mobile numbers. Filter by line type first on mixed lists.
- Exposing results to clients without an agreement. Talk to sales first; section 18.2 of the Terms applies.
Frequently asked questions
Which check should an OTP provider run before every code?
Usually the carrier lookup for line type, because it is cheap and catches fixed lines, VoIP and premium-rate numbers. Add the HLR lookup where a code that never arrives is expensive, and a WhatsApp or Telegram check where you offer app delivery as a fallback.
HLR or MNP for least-cost routing?
For routing alone, the MNP lookup is enough and costs less: it returns porting and the current mcc_mnc. The HLR lookup returns the same network fields plus live status, so use it when you also want to stop sending to unassigned or unreachable numbers. Don't pay for both on one number.
Can I pass the check results on to my own clients?
Using checks to route and protect traffic you carry is internal use. Showing or selling the results to your clients, or building them into a verification product you offer, needs a written agreement with us under section 18.2 of the Terms. E-mail sales@mobilevalidate.com to set one up.
Do you have a reseller program?
There is no self-serve reseller or affiliate program. Providers who want to offer checks to their own customers sign a written agreement with us. Contact sales with your use case and expected volume.
What are the per-country minimums in bulk jobs?
WhatsApp checks get the bulk price for groups of at least 132 numbers from one country; smaller groups are priced at the real-time price. Bulk-only checks such as RCS or iMessage are billed at least the price of 500 checks per country that returns an answer. The free estimate shows both before a job starts.
Is an unknown or timed-out check charged?
No. Unknown, unsupported-country, timeout, invalid-format and duplicate results are never charged. Route the number as you normally would.
Does the HLR lookup return the subscriber's location or SIM identifiers?
No. It returns status, ported, roaming as true or false, network, mcc_mnc and country. SIM identifiers, switch addresses and cell data are never returned.
Related
- ServiceHLR lookup API: live mobile network status
- ServiceMNP lookup API: has a mobile number been ported?
- ServicePhone carrier lookup by number: carrier and line type API
- ServiceCheck if a phone number is registered on WhatsApp
- ServiceCheck if a phone number is registered on Telegram
- Use caseSMS routing with MNP and HLR lookups

