CRM data hygiene means re-checking the phone numbers and e-mail addresses you already hold, because contact data decays. Numbers are disconnected and reassigned, mailboxes close. Run a bulk job before each campaign and on a schedule: an HLR lookup for numbers, the mailbox check for e-mails. Then suppress dead contacts and fix the rest.
Why does contact data decay?
A contact record is a snapshot of the day it was collected. After that, life moves on:
- Numbers are disconnected and reassigned. People switch operators, drop second SIMs and leave numbers unused until the operator reclaims them. In the United States alone, the FCC estimated that about 35 million numbers are disconnected and made available for reassignment every year (FCC 18-177, 2018). A reassigned number in your CRM reaches a stranger who never gave you consent. See reassigned numbers explained.
- Numbers are ported. The number still works but sits on a different network, so any stored carrier field is now wrong. See mobile number portability.
- Mailboxes close. People abandon personal addresses and lose work addresses when they change jobs. Mail to a closed mailbox bounces, and repeated hard bounces damage your sender reputation.
- Data entry errors pile up. Typos, numbers without country codes, and the same person imported twice in different formats.
Decay is invisible until you send. Then it shows up as failed SMS, bounced e-mail, wrong-person complaints and skewed campaign metrics. Checking before sending moves that discovery to a point where it costs a check, not a message and a complaint.
How does it work?
- Export the segment. Take the contacts you are about to message, or the slice of the database due for its scheduled check. Include the CRM record ID so you can match results back.
- Estimate for free. Call
POST /v1/jobs/estimatewith the numbers, e-mails and checks. It counts valid, invalid, duplicate, cached, unsupported and suppressed rows and gives the maximum cost, without checking or charging anything. - Run a bulk job. Create it with
POST /v1/jobs(up to 50,000 numbers and e-mails per job) and pass the estimate's amount asmax_cost. Add adefault_countryif your CRM stores national formats. - Wait for completion. Poll
GET /v1/jobs/{id}?wait=30or subscribe to thejob.completedwebhook. - Download and import. Get the CSV (
GET /v1/jobs/{id}/download?format=csv). It keeps your row order and adds a column per check. - Apply the rules below. Suppress conclusive negatives, flag temporary problems, correct formats, and write
checked_atto each record. - Schedule the next run. Re-check contacts whose last check is older than your chosen interval, and always the segment before a campaign.
The CSV list cleaner recipe shows the same flow in code.
Which checks should you use?
| Check | What it answers | When to use it | Service |
|---|---|---|---|
hlr | Is the mobile number assigned and reachable now? Ported? Current network? | Before SMS campaigns and on a schedule for mobile numbers | HLR lookup |
carrier | Mobile, fixed line, VoIP or toll-free? Current carrier? | To separate SMS-capable numbers from landlines before a text campaign | Carrier lookup |
mnp | Was the number ported, and which network is it on now? | To refresh a stored network field cheaply | MNP lookup |
email | Does the mailbox exist at a major webmail provider? | Before e-mail campaigns, to avoid hard bounces | E-mail mailbox check |
network.carrier_us | Current carrier for US and Canadian numbers | Bulk jobs on North American records | US/CA carrier lookup |
You don't need every check on every record. A text campaign needs hlr (or carrier if you only need the line type). An e-mail campaign needs email. A full database refresh might run both.
How often should you re-validate?
There is no single right interval, because decay depends on your audience and on how often you use the data. A practical approach has three layers:
- Before every campaign, check the segment you are about to message. This is the check that saves the most, because it runs right before you pay for sends.
- On a schedule for the rest of the database, for example every quarter. Prioritize records you haven't messaged or heard from in a long time: silence is often the first sign a contact went stale.
- On events, check again when a delivery fails, an e-mail bounces or a customer says "that's not my number". One failure is worth a fresh check with
max_age: 0, not an immediate deletion.
Store the last checked_at per record. Then "re-check anything older than N days" becomes a simple CRM filter.
Example request
First, a free estimate. The list holds three test numbers, one of which appears twice in different formats, plus two addresses:
curl https://api.mobilevalidate.com/v1/jobs/estimate \
-H "Authorization: Bearer mv_test_publicSandboxn9ZgneuhR1B9CRfKG3fulym" \
-H "Content-Type: application/json" \
-d '{"numbers":["+447700900001","+447700900002","07700 900002","+447700900003"],"emails":["[email protected]","[email protected]"],"default_country":"GB","checks":["hlr","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.jobs.estimate({
numbers: [
"+447700900001",
"+447700900002",
"07700 900002",
"+447700900003",
],
emails: [
"[email protected]",
"[email protected]",
],
defaultCountry: "GB",
checks: ["hlr", "email"],
});
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",
"07700 900002",
"+447700900003",
],
emails: [
"[email protected]",
"[email protected]",
],
default_country: "GB",
checks: ["hlr", "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.jobs.estimate(
numbers=[
"+447700900001",
"+447700900002",
"07700 900002",
"+447700900003",
],
emails=[
"[email protected]",
"[email protected]",
],
default_country="GB",
checks=["hlr", "email"],
)
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',
'07700 900002',
'+447700900003',
],
'emails' => [
'[email protected]',
'[email protected]',
],
'default_country' => 'GB',
'checks' => ['hlr', '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":["+447700900001","+447700900002","07700 900002","+447700900003"],"emails":["[email protected]","[email protected]"],"default_country":"GB","checks":["hlr","email"]}`)
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",
"07700 900002",
"+447700900003"
],
"emails" => [
"[email protected]",
"[email protected]"
],
"default_country" => "GB",
"checks" => ["hlr", "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):
{
"object": "estimate",
"total": 6,
"valid": 5,
"invalid": 0,
"duplicate": 1,
"cached": 0,
"unsupported": 0,
"suppressed": 0,
"checks": ["number.hlr", "email.valid"],
"checks_total": 6,
"billable_max": 0,
"max_cost": {"amount": "0", "currency": "USD"}
}07700 900002 is the same number as +447700900002 once normalized to E.164, so it's marked duplicate and never checked. Test keys are never charged, so the cost is zero here. For a handful of records, for example when a sales rep opens an old contact, the same checks run in real time:
curl https://api.mobilevalidate.com/v1/lookup \
-H "Authorization: Bearer mv_test_publicSandboxn9ZgneuhR1B9CRfKG3fulym" \
-H "Content-Type: application/json" \
-d '{"numbers":["+447700900002"],"emails":["[email protected]"],"checks":["hlr","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: ["[email protected]"],
checks: ["hlr", "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: ["[email protected]"],
checks: ["hlr", "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=["[email protected]"],
checks=["hlr", "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' => ['[email protected]'],
'checks' => ['hlr', '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":["[email protected]"],"checks":["hlr","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" => ["[email protected]"],
"checks" => ["hlr", "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):
[
{
"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-09-28T22:42:26.188Z",
"cached": false, "age_seconds": 0, "billed": false, "reason": null, "poll_after_ms": null
}
},
{
"email.valid": {
"service": "email.valid", "status": "completed", "registered": false, "attributes": null,
"confidence": "high", "confidence_score": 0.99, "checked_at": "2026-09-28T22:42:26.188Z",
"cached": false, "age_seconds": 0, "billed": false, "reason": null, "poll_after_ms": null
}
}
]The number exists but can't be reached right now: flag it and check again later. The mailbox doesn't exist: stop e-mailing this address and ask for a new one at the next contact.
What should you do with each result?
| Result | CRM action |
|---|---|
number_status: invalid_number | Fix the format (often a missing country code) or clear the field |
number_status: duplicate | Merge the records |
HLR status: reachable | Keep. Store checked_at |
HLR status: unreachable | Flag and re-check before the next send. Don't delete on one answer |
HLR status: invalid | Suppress SMS and calls. The number is not assigned |
line_type: fixed_line | Mark as "no SMS"; use calls or e-mail |
Mailbox registered: true | Keep |
Mailbox registered: false | Suppress e-mail to this address; ask for an update at the next contact |
Any check unknown | Keep the record as it is. Unknown is not negative and is free |
How much does it cost?
The HLR lookup is $0.005 per number and the MNP lookup $0.001 per number. Other checks, including the mailbox check, have separate real-time and bulk prices on the pricing page. You're not charged for inconclusive results (unknown, unsupported country, timeout, invalid, duplicate). The free estimate shows the maximum cost before you commit.
Two things keep hygiene cheap. Check only the segment you're about to use, not the whole database every time. And re-running an updated list inside each service's freshness window serves repeat numbers from your account's cache, free.
What about consent and compliance?
Re-validation is data accuracy, which privacy law expects of you: under the GDPR, personal data must be "accurate and, where necessary, kept up to date" (GDPR Art. 5(1)(d)). Mention contact verification in your privacy notice.
Checks don't create consent. Message only people who opted in, and treat a number that may have been reassigned as a reason to re-confirm consent, not to send. Our acceptable use policy forbids unsolicited bulk messaging and list building, and checks never return names or profiles. People can object through the opt-out form; suppressed contacts are never checked or charged.
What are common mistakes?
- Checking the whole database once and never again. Decay is continuous. Check on a schedule and before each campaign.
- Deleting on one unreachable answer. A switched-off phone isn't a dead number. Re-check first.
- Treating unknown as bad. Unknown means no answer this time, and it's free. Keep the record.
- Skipping normalization. Without a country, national numbers can't be checked. Set
default_countryor store E.164. - Overwriting the CRM with raw responses. Store the decision and
checked_at, not the full JSON. - Cleaning only one channel. A record with a dead mailbox often has a stale phone too. When one field fails, re-check the other before the next campaign on either channel.
- Ignoring suppression on import. Numbers and addresses that people asked you to stop using must stay suppressed after a clean-up. Re-importing a cleaned file must not bring them back into campaigns.
- Assuming a reachable number still belongs to your contact. Reachable means assigned and live, not "same person". Re-confirm consent when a record is old.
Frequently asked questions
Why does CRM contact data go bad?
People change numbers, close mailboxes and leave jobs, and numbers that were given up are reassigned to new people. A record that was right when it was collected can point to nobody, or to someone else, a year later.
How often should I re-validate contacts?
Before every campaign for the contacts you are about to message, and on a regular schedule for the rest of the database. Contacts you message rarely are the ones most likely to have gone stale. Pick an interval that matches how often you use the data.
Which checks should I run on CRM records?
The HLR lookup for mobile numbers (assigned and reachable, and the current network), the carrier lookup for line type, and the e-mail mailbox check for addresses at major webmail providers. Start with the free estimate to find invalid and duplicate rows.
Should I delete a contact when a check says unreachable?
No. Unreachable means the phone couldn't be reached at that moment, for example because it was switched off. Flag it and check again later. Delete or suppress only on repeated conclusive negative answers, such as an unassigned number or a mailbox that doesn't exist.
Can re-validation tell me whether a number now belongs to someone else?
Not by itself. A check tells you whether a number is assigned and reachable, not who holds it. In the US, the FCC's reassigned numbers database answers whether a number was disconnected after a given date. Re-confirm consent with the person when in doubt.
Do I pay for duplicates and badly formatted numbers?
No. Duplicates and invalid entries are flagged per row and never checked or charged, and neither are unknown answers. The free estimate counts them before you run anything.
Can I use re-validation to make an old purchased list usable?
No. Checks clean data you already have a lawful basis to use. They don't create consent, and the acceptable use policy forbids unsolicited bulk messaging.


