On this page
Cleaning a phone list means removing numbers you can't or shouldn't contact before you pay to message them: malformed entries, duplicates, lines that can't take SMS, and numbers with no sign of use. With the MobileValidate jobs API the process is: estimate for free, run a job with a cost cap, download a CSV, act on the columns, and purge the data. This guide walks through each step with real test-mode output.
Why do phone lists go bad?
Every list decays. People change numbers and operators recycle the old ones. In the US, the FCC runs a Reassigned Numbers Database so callers can check whether a number was permanently disconnected after they got consent. It held over 305.9 million numbers by February 2023 (FCC, 2023). Lists also pick up typos from web forms, numbers typed in five different formats, and the same person imported twice from two systems.
Each bad row costs you something: an SMS fee for a message that can't arrive, a dialler minute, a bounce that drags down delivery statistics, or a message reaching someone who never agreed to it. Cleaning doesn't make a list consented. That comes from how you collected it. It does make sure you only spend money and attention on rows that can work.
What does a cleaning pass check?
A practical pass has four layers, from free to paid:
| Layer | What it finds | How | Cost |
|---|---|---|---|
| 1. Format | Impossible numbers, typos, wrong country | Conversion to E.164 against the numbering plan | Free (invalid_number) |
| 2. Duplicates | The same number in different formats | Deduplicate after conversion | Free (duplicate) |
| 3. Line type | Landlines, toll-free, premium-rate, VoIP | Carrier lookup (carrier) | Per conclusive answer |
| 4. Channel | Whether an opted-in contact uses WhatsApp, Telegram and so on | Channel checks, e.g. WhatsApp | Per conclusive answer |
For US and Canadian lists, the bulk-only US/CA carrier lookup (network.carrier_us) adds current-carrier data where porting between landline, mobile and VoIP is common. Live reachability through the HLR lookup is coming soon. Layers 1 and 2 run automatically on every job. You choose layers 3 and 4 with checks.
Step 1: How do you prepare the file?
Put one number per row in a column named phone or number. Keep your own ID column so you can join the results back. The download keeps your original row order, so the row number works as a join key too.
crm_id,phone
C-1001,+44 7700 900001
C-1002,07700 900002
C-1003,+447700900002
C-1004,+447700900003
C-1005,12345Two things to settle before you upload:
- Country. National-format numbers such as
07700 900002needdefault_country. It applies to the whole job, so split a mixed-country list by country, or convert to international format first. The E.164 guide covers the traps. - Purpose. Only check numbers you have a lawful reason to process, such as customers and people who asked to be contacted. Under the GDPR you also need to keep what you process to what the purpose requires (GDPR Art. 5(1)(c), 2016). Don't add checks you won't act on.
Step 2: What does the free estimate tell you?
POST /v1/jobs/estimate takes the same body as a job and checks nothing. It validates, deduplicates and counts, and it returns the most the job could cost:
curl https://api.mobilevalidate.com/v1/jobs/estimate \
-H "Authorization: Bearer $MOBILEVALIDATE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"numbers": ["+447700900001", "07700 900002", "+44 7700 900002", "+447700900003",
"+447700900005", "12345", "+447700900010", "+447700900011"],
"checks": ["carrier", "whatsapp"], "default_country": "GB"}'{"object": "estimate", "total": 8, "valid": 6, "invalid": 1, "duplicate": 1, "cached": 0,
"unsupported": 0, "suppressed": 0, "checks": ["network.carrier", "whatsapp.registered"],
"checks_total": 16, "billable_max": 0, "max_cost": {"amount": "0", "currency": "USD"}}total, valid, invalid, duplicate, unsupported and suppressed count rows. checks_total, cached and billable_max count checks (rows × services). Test keys always estimate zero. With a live key, max_cost is the ceiling.
Read the estimate before going further. A high invalid share usually means a wrong default_country or a broken export, not a bad list. Fix it and estimate again. It costs nothing.
Step 3: How do you create the job safely?
Create the job with the same body plus two safety settings: max_cost set to the estimate's amount, and an Idempotency-Key header, so a network retry can't create a second job.
curl https://api.mobilevalidate.com/v1/jobs \
-H "Authorization: Bearer $MOBILEVALIDATE_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: list-clean-2026-09-25-001" \
-d '{"numbers": ["+447700900001", "07700 900002", "+44 7700 900002", "+447700900003",
"+447700900005", "12345", "+447700900010", "+447700900011"],
"checks": ["carrier", "whatsapp"], "default_country": "GB",
"max_cost": {"amount": "0", "currency": "USD"}}'With a CSV file, send multipart/form-data instead: -F [email protected] -F checks=carrier,whatsapp -F default_country=GB -F max_cost=0.00. The API answers 201 with the job object and a Location header.
Limits to plan around: up to 50,000 numbers and e-mails per job, 20 checks per request, and at most 100,000 identifier × check combinations per job. Five checks on 50,000 numbers is 250,000 and will be refused, so split the list or drop checks. Requests that look like sequential number ranges are refused as suspected_enumeration. That rule stops people from checking made-up ranges. A real customer list rarely contains 20 or more numerically consecutive numbers.
Step 4: How do you know when it's done?
Poll with a long-poll so you aren't hammering the API. GET /v1/jobs/{id}?wait=30 returns as soon as the status changes, or after 30 seconds:
{"object": "job", "id": "job_0VWFUGFpEfab2Ucnj33Z", "status": "completed", "livemode": false,
"checks": ["network.carrier", "whatsapp.registered"],
"progress": {"total": 8, "checks_total": 16, "done": 16, "conclusive": 7, "non_billable": 16},
"cost": {"estimated_max": {"amount": "0", "currency": "USD"}, "reserved": {"amount": "0", "currency": "USD"},
"charged": {"amount": "0", "currency": "USD"}, "released": {"amount": "0", "currency": "USD"}},
"retention_days": 30}progress.total counts rows. The other progress fields count checks. cost shows what was reserved at the start, what was charged for conclusive answers, and what was released for everything else. For large jobs, register a webhook for job.completed and job.failed instead of polling. Job webhooks carry a summary and a results URL, never the numbers.
Step 5: How do you read the results?
Download the whole file with GET /v1/jobs/{id}/download?format=csv. It has one line per input row, in your original order, with one group of columns per check. Real output from the test job above:
row_no,input_masked,e164,country,number_status,…,network.carrier.status,network.carrier.line_type,…,whatsapp.registered.status,whatsapp.registered.registered,whatsapp.registered.billed
1,"'+44770*****01",+447700900001,GB,valid,…,completed,mobile,…,completed,true,false
2,"'+44770*****02",+447700900002,GB,valid,…,unknown,,…,completed,false,false
3,"'+44770*****02",+447700900002,GB,duplicate,…,,,…,,,
4,"'+44770*****03",+447700900003,GB,valid,…,unknown,,…,unknown,,false
5,"'+44770*****05",+447700900005,GB,valid,…,unsupported_country,,…,unsupported_country,,false
6,***,,,invalid_number,…,,,…,,,The input is masked, and a value a spreadsheet might read as a formula gets a leading apostrophe. To work with one slice through the API instead, page through GET /v1/jobs/{id}/results with filters, for example ?service=whatsapp®istered=true for rows with a WhatsApp account, or ?service=carrier&status=unknown for rows to retry later.
What should you do with each row?
| Result | Action |
|---|---|
number_status: invalid_number | Remove, or ask the contact to correct it next time they log in |
number_status: duplicate | Merge the records. Keep the one with the best consent history |
line_type: mobile | Keep for SMS |
line_type: fixed_line | Move to voice or e-mail. Don't send SMS |
line_type: toll_free, premium_rate, shared_cost | Remove from SMS campaigns. These are rarely a person's own phone |
line_type: voip | Keep, but watch delivery. Some VoIP numbers can't receive SMS |
Channel registered: true and the contact opted in to it | Eligible for that channel |
Any check unknown or unsupported_country | Keep the row. Retry later. It wasn't charged |
The last row matters most. unknown is missing data, not a bad number. Deleting unknown rows throws away good contacts, and you paid nothing for them.
What does a cleaning pass cost?
You pay per check, at the bulk price, and only for conclusive answers. You're not charged for inconclusive results (unknown, unsupported country, timeout, invalid, duplicate), and suppressed rows are never checked. See pricing for current rates.
To size a run: billable_max from the estimate × the bulk price of each check is your worst case, and max_cost makes it a hard ceiling. The real bill is usually lower because of the free rows. Checks of the same number and service within the freshness window come from your account's cache for free, so re-running an updated list doesn't bill unchanged numbers twice. Compare the total with what you'd waste otherwise: SMS to invalid, duplicate and fixed-line rows, and SMS you can move to a cheaper channel the contact opted into. The SMS cost reduction page shows the arithmetic.
Step 6: What happens to the data afterwards?
Results stay available for your account's retention period (30 days by default) and are then deleted automatically. Once you've imported the columns you need into your CRM, you can purge earlier:
curl -X DELETE https://api.mobilevalidate.com/v1/jobs/job_0VWFUGFpEfab2Ucnj33Z \
-H "Authorization: Bearer $MOBILEVALIDATE_API_KEY"The job object remains with purged_at set, so your records show the job ran. Its per-row results and downloads are gone. In your own system, keep the decision and checked_at rather than the whole response. People can object to checks through our opt-out form, and suppressed numbers come back as suppressed in later jobs.
How often should you re-clean?
It depends on how fast your list changes and what failure costs you:
- Before each large send to a list that hasn't been checked for a while, run layers 1 to 3.
- Monthly or quarterly for an active customer base, add channel checks if you route by channel.
- At the point of capture for new sign-ups, use a real-time
POST /v1/lookupinstead of waiting for the next batch. See OTP fraud prevention.
Store checked_at per number and re-check the oldest rows first.
What are the key takeaways?
- Estimate first. It's free and catches wrong-country and broken-export problems before you spend anything.
- Always pass
max_costand anIdempotency-Keywhen you create a job. - Invalid, duplicate, suppressed and inconclusive rows are never charged. Don't delete
unknownrows. - Act on
line_typefor SMS and on channel results only for contacts who opted in. - Purge job data when you're done, and keep only the decision and
checked_at.
Sources
- Reassigned Numbers Database — Federal Communications Commission, 2023
- ITU-T Recommendation E.164: The international public telecommunication numbering plan — International Telecommunication Union, 2010
- General Data Protection Regulation (EU) 2016/679 — European Union, 2016
Frequently asked questions
How many numbers can one bulk job hold?
Up to 50,000 numbers and e-mail addresses in total, with up to 20 checks per request and at most 100,000 identifier × check combinations per job. Split bigger lists into several jobs.
Am I charged for invalid or duplicate rows?
No. Invalid, duplicate and suppressed rows are never checked or charged, and neither are inconclusive answers such as unknown, unsupported country or timeouts. The free estimate shows these counts before you start.
Should I delete numbers that come back unknown?
No. Unknown means we couldn't get a conclusive answer, not that the number is bad. Keep the row, don't pay for it, and check it again later.
How long are job results kept?
For your account's retention period, 30 days by default. You can purge a finished job's results earlier with DELETE /v1/jobs/{id}.
Related services and guides
More from the blog
All articlesDevelopers
E.164 phone number format: a practical guide for developers
How to normalize phone numbers to E.164 in JavaScript and Python, the pitfalls that break deduplication, and how the MobileValidate API normalizes input.
8 min read
Guides
How to check if a phone number is active (and what "active" really means)
Valid, active, reachable and registered mean different things. The methods for checking each one, what they cost and where every method falls short.
7 min read
Research
How often do phone numbering plans change? 2019–2026 data
We counted 182 phone-metadata releases from 2019 to 2026: a median of 14 days apart, and 230 of 245 regions changed. Why validation goes stale.
8 min read


