# 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.

Canonical: https://mobilevalidate.com/blog/how-to-clean-a-phone-number-list-in-bulk · Last updated: 2026-09-25

![Cover: How to clean a phone number list in bulk, step by step](https://mobilevalidate.com/og/blog/how-to-clean-a-phone-number-list-in-bulk.png)


By MobileValidate team (https://mobilevalidate.com/about) · Published: 2026-09-25 · Category: Deliverability · Tags: List cleaning, Bulk jobs, Data quality, Deliverability, CSV

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](https://www.fcc.gov/reassigned-numbers-database)). 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](https://www.itu.int/rec/T-REC-E.164/en) 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](/services/carrier-lookup) (`carrier`) | Per conclusive answer |
| 4. Channel | Whether an opted-in contact uses WhatsApp, Telegram and so on | Channel checks, e.g. [WhatsApp](/services/whatsapp-number-check) | Per conclusive answer |

For US and Canadian lists, the bulk-only [US/CA carrier lookup](/services/us-carrier-lookup) (`network.carrier_us`) adds current-carrier data where porting between landline, mobile and VoIP is common. Live reachability through the [HLR lookup](/services/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](/blog/e164-phone-number-format-guide-for-developers) 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](https://eur-lex.europa.eu/eli/reg/2016/679/oj)). 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:

```bash
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.

```bash
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 file=@leads.csv -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](/docs/webhooks) 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?

| 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](/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](/use-cases/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:

```bash
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](/opt-out), 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](/blog/otp-fraud-prevention-checks-before-sending-a-code).

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](https://www.fcc.gov/reassigned-numbers-database) — Federal Communications Commission, 2023
2. [ITU-T Recommendation E.164: The international public telecommunication numbering plan](https://www.itu.int/rec/T-REC-E.164/en) — International Telecommunication Union, 2010
3. [General Data Protection Regulation (EU) 2016/679](https://eur-lex.europa.eu/eli/reg/2016/679/oj) — 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}.
