Deliverability

How to clean a phone number list in bulk, step by step

A step-by-step way to clean a phone list with the MobileValidate jobs API: free estimate, dedupe, invalid rows, capped cost, CSV download and data purge.

By Published 7 min read

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.

A lead list with a verdict per row and a summary bar of registered, not registered and unknown numbers.A lead list with a verdict per row and a summary bar of registered, not registered and unknown numbers.
One verdict per lead, plus a summary of the whole list.

What does a cleaning pass check?

A practical pass has four layers, from free to paid:

LayerWhat it findsHowCost
1. FormatImpossible numbers, typos, wrong countryConversion to E.164 against the numbering planFree (invalid_number)
2. DuplicatesThe same number in different formatsDeduplicate after conversionFree (duplicate)
3. Line typeLandlines, toll-free, premium-rate, VoIPCarrier lookup (carrier)Per conclusive answer
4. ChannelWhether an opted-in contact uses WhatsApp, Telegram and so onChannel checks, e.g. WhatsAppPer 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.

Text
crm_id,phone
C-1001,+44 7700 900001
C-1002,07700 900002
C-1003,+447700900002
C-1004,+447700900003
C-1005,12345

Two things to settle before you upload:

  • Country. National-format numbers such as 07700 900002 need default_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:

Shell
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"}'
JSON
{"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.

Shell
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:

JSON
{"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:

Text
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&registered=true for rows with a WhatsApp account, or ?service=carrier&status=unknown for rows to retry later.

What should you do with each row?

ResultAction
number_status: invalid_numberRemove, or ask the contact to correct it next time they log in
number_status: duplicateMerge the records. Keep the one with the best consent history
line_type: mobileKeep for SMS
line_type: fixed_lineMove to voice or e-mail. Don't send SMS
line_type: toll_free, premium_rate, shared_costRemove from SMS campaigns. These are rarely a person's own phone
line_type: voipKeep, but watch delivery. Some VoIP numbers can't receive SMS
Channel registered: true and the contact opted in to itEligible for that channel
Any check unknown or unsupported_countryKeep 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:

Shell
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/lookup instead 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_cost and an Idempotency-Key when you create a job.
  • Invalid, duplicate, suppressed and inconclusive rows are never charged. Don't delete unknown rows.
  • Act on line_type for 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

  1. Reassigned Numbers Database — Federal Communications Commission, 2023
  2. ITU-T Recommendation E.164: The international public telecommunication numbering plan — International Telecommunication Union, 2010
  3. 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}.

All articles

Know before you send.

Tell us about your use case. We review every request and set you up with test and live keys.