Account takeovers often begin with a change of contact details: the attacker adds their own 2FA number or swaps the recovery e-mail. Checking the new number and address at that moment gives your security logic facts to weigh: line type, live reachability, whether the mailbox exists and, where enabled, spam reputation. Risky changes get an extra step.
Where does contact-detail risk show up?
Attackers who get into an account usually try to keep it. That means changing where codes and reset links go. Warning signs include:
- A new 2FA number that is a VoIP line. Legitimate users have VoIP numbers too, but attackers often use them because they're cheap and easy to get. The carrier lookup reports
line_type. See VoIP number. - A new number that isn't assigned or can't be reached. The HLR lookup asks the home network whether the number is
reachable,unreachableorinvalid(not assigned). A 2FA number that can't receive codes is a problem whoever added it. - A recovery e-mail with no mailbox behind it. An address at a major webmail provider that doesn't exist can't receive recovery mail. That points to a typo or a throwaway. The e-mail mailbox check answers this.
- A number with fraud or spam reports. Where spam reputation is enabled (limited access), reasons such as
reason_unassignedorreason_regulatorare worth weighing. - A change of country. The carrier lookup's
countrycan differ from the account's usual country.
Each signal is weak on its own. A combination, together with your own session data such as a new device, a new IP range or a recent password reset, is what should trigger a step-up.
Why do contact changes deserve their own checks?
Most login defences look at the session: device, location, behaviour. A contact change is different because it has lasting effects. Once an attacker controls the 2FA number or recovery address, they can survive a password reset by the real owner. That is why guidance such as NIST SP 800-63B treats phone-based out-of-band authentication as a restricted method and expects verifiers to consider the risk of a number being changed or ported. The checks on this page don't replace stronger authenticators such as passkeys. They make the change itself harder to abuse.
We don't offer SIM-swap detection. For what SIM swaps look like and which signals exist elsewhere, see SIM swap.
How does the workflow look?
- The user submits a new phone number or e-mail address.
- Call
POST /v1/lookupwith the new identifiers andchecks: ["carrier", "spam", "email"]. Leave outspamif it isn't enabled for your account, and addhlrfor 2FA numbers. - Combine the results with your session risk and apply the table below.
- For a step-up, confirm through the old number or e-mail, or hold the change for a cooling-off period.
- Notify the old contact details that a change was made, whatever the outcome.
- Log the decision with
checked_at. Keep the raw response only as long as you need it.
The request is small (one number, one address), so it fits in the synchronous part of a settings change. Use a short wait, such as 5 seconds; anything still pending counts as unknown for the decision.
Which checks answer which question?
| Check | What it answers | When to run it | Link |
|---|---|---|---|
carrier | Mobile, VoIP or fixed line? Which country? | New or changed phone number | Carrier lookup |
hlr | Is the new mobile number assigned and reachable? Was it ported? | New 2FA number | HLR lookup |
email | Does the new mailbox exist at a major webmail provider? | New or changed recovery e-mail | E-mail mailbox check |
spam (limited access) | Is the number in spam or fraud reports? | New phone number, where enabled | Spam reputation |
What should you do with each result?
| Signals | Suggested action |
|---|---|
| Mobile line, mailbox exists, no reports | Allow the change |
line_type: voip and the session is otherwise normal | Allow, and notify the old contact details |
line_type: voip and a new device or location | Step up: confirm through the old number or e-mail |
HLR status: invalid for a new 2FA number | Refuse the number; it can't receive codes |
Mailbox registered: false | Ask the user to check the address; don't make it the recovery e-mail |
Spam risk_level: high or reason_unassigned: true | Step up, and hold the change for review |
Checks unknown | Decide on your other signals; unknown is not negative and is not charged |
What does a request look like?
Test mode, with a number that has no carrier data and no reports, and an address with an existing mailbox:
curl https://api.mobilevalidate.com/v1/lookup \
-H "Authorization: Bearer mv_test_publicSandboxn9ZgneuhR1B9CRfKG3fulym" \
-H "Content-Type: application/json" \
-d '{"numbers":["+447700900002"],"emails":["registered@test.mobilevalidate.com"],"checks":["carrier","spam","email"]}'// 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: ["+447700900002"],
emails: ["registered@test.mobilevalidate.com"],
checks: ["carrier", "spam", "email"],
});
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: ["+447700900002"],
emails: ["registered@test.mobilevalidate.com"],
checks: ["carrier", "spam", "email"],
}),
});
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=["+447700900002"],
emails=["registered@test.mobilevalidate.com"],
checks=["carrier", "spam", "email"],
)
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' => ['+447700900002'],
'emails' => ['registered@test.mobilevalidate.com'],
'checks' => ['carrier', 'spam', 'email'],
]),
]);
$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":["+447700900002"],"emails":["registered@test.mobilevalidate.com"],"checks":["carrier","spam","email"]}`)
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" => ["+447700900002"],
"emails" => ["registered@test.mobilevalidate.com"],
"checks" => ["carrier", "spam", "email"]
})
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 the phone row, then of the e-mail row):
[
{
"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
},
"number.spam": {
"service": "number.spam", "status": "completed", "registered": true,
"attributes": {
"risk_level": "no_reports", "risk_score": 0, "reason_regulator": false, "reason_government": false,
"reason_community": false, "reason_unassigned": false, "voip_range": false, "sources": 0
},
"confidence": "high", "confidence_score": 0.99, "checked_at": "2026-09-25T14:28:26.024Z",
"cached": false, "age_seconds": 0, "billed": false, "reason": null, "poll_after_ms": null
}
},
{
"email.valid": {
"service": "email.valid", "status": "completed", "registered": true, "attributes": null,
"confidence": "high", "confidence_score": 0.99, "checked_at": "2026-09-25T14:28:26.024Z",
"cached": false, "age_seconds": 0, "billed": false, "reason": null, "poll_after_ms": null
}
}
]The carrier answer is unknown (NO_DATA) and free. The rest of the decision rests on the spam level, the mailbox result and your own session signals.
How much does it cost?
Each check on each identifier is billed only when the answer is conclusive. You're not charged for inconclusive results (unknown, unsupported country, timeout, invalid, duplicate). Contact-detail changes are rare events, so this use case usually costs very little: a few real-time checks per change. The HLR lookup is billed per conclusive answer. Spam reputation bills every level, including no_reports, and is free for numbers from sanctioned countries and regions, which are never checked. See pricing for current rates.
What limits and rules apply?
The usual lookup limits apply: up to 100 identifiers and 20 checks per request, rate limits per key and a daily cap per account. Requests that look like sequential number ranges or generated e-mail lists are rejected. E-mail checks return only yes, no or unknown, never names or profiles.
What about consent and compliance?
Check only the contact details your users give you for their own accounts, and say in your privacy notice that you do so for security. Results support a step-up decision. They are not identity proof, and on their own they shouldn't lock people out of their accounts. Give every flagged user a way through, such as support review. Keep what you store small: the decision, the signals and checked_at. People can object through the opt-out form.
What are the common mistakes?
- Locking accounts on one signal. A VoIP number or a new country is common among genuine users. Step up instead.
- Checking only at sign-up. Takeovers happen later. The contact-change moment is when checks matter most.
- Not notifying the old contact. Telling the previous number or address about a change is the cheapest takeover alarm there is.
- Treating
no_reportsas safe. It means we hold no reports, not that the number is trustworthy. - Treating
unknownas negative. It is free and neutral; decide on your other signals.
Frequently asked questions
When should I run these checks?
At moments when attackers change contact details: a new phone number for two-factor authentication, a changed recovery e-mail, a password reset to a new destination or a first login from a new device.
Should I lock the account when a signal looks risky?
Usually not. Ask for an extra verification step instead, such as confirming on the old number or e-mail, a delay before the change takes effect, or a support review. Locking hurts genuine users who just changed phones.
Does the mailbox check read or send e-mail?
No. It only answers whether the mailbox exists, for major webmail providers. Nothing is sent to the address and no mailbox content is accessed.
Can I use these checks as identity verification?
No. They are risk signals about a number or an address, not proof of who someone is, and must not be used as KYC or for credit, employment, housing or insurance decisions.
Is spam reputation part of this?
It can be, where it is enabled. Spam reputation is in limited access (approved customers only) and accepts numbers from all countries except sanctioned ones.
Do you detect SIM swaps?
No. We don't offer SIM-swap detection. The HLR lookup tells you whether a new 2FA number is assigned and reachable and whether it was ported, which is a different question.
What does an unknown answer mean for a contact change?
No conclusive answer, for example after a timeout. It is free and neutral: decide on your other session signals rather than blocking the change.
Should I store the lookup response with the account?
Store the decision, the signals that drove it and checked_at. Keep the raw response only as long as you need it for review, and say in your privacy notice that you check contact changes for security.
Related
Guides on this topic
All articlesGuides
How to check if a phone number has an Apple ID (single and bulk)
How to check if a phone number has an Apple ID (Apple Account), one or in bulk: what yes, no and unknown mean, how it differs from iMessage, API examples.
7 min read
Fraud prevention
Account takeover via phone-number change: a checklist for the change-number flow
Attackers change an account's phone number before resetting everything else. A checklist to secure the change-number flow without locking out real users.
7 min read
Fraud prevention
How to build a phone number risk score from carrier, line type and reputation signals
Turn carrier, line type, network status and reputation signals into a phone number risk score with three outcomes, reason codes and calibrated thresholds.
8 min read


