# How to clean a phone number list before an outbound campaign

_2026-08-10 · Boundstone (https://boundstone.io/blog/clean-phone-list-before-calling)_


You have a phone number list — a CRM export, a purchased lead file, a form dump — and before your reps start dialing, you want to clean it. To clean a phone number list means two concrete things: remove the rows that cannot be a real number, and sort the survivors by the kind of line they are, so you route SMS and voice correctly. Here is how to do both over one API call, and — just as important — where the cleaning stops.

## Do the free, local part first

Before an API touches your file, know what your own stack already does. Google's libphonenumber — the library behind most phone validation, with ports in Python (`phonenumbers`), JavaScript (`libphonenumber-js`), Java, Go, and more — parses and validates number format and region entirely offline. If all you need is to drop obviously malformed rows, that runs on your own machine for free. The in-process walkthrough lives in [validate a phone number in Python](/blog/validate-phone-python), and you can spot-check a single number with the keyless [phone validator tool](/tools/phone-validator) — no signup.

So why call an API at all? Two reasons. One contract: instead of maintaining a different libphonenumber build and metadata version in every service, you get one consistent response shape across every language your team writes in. And bulk: one request cleans the whole file, with per-row credit accounting, instead of you scripting the loop and rate-limiting yourself.

## Bulk-validate the list

`POST /v1/bulk/phone` takes a raw CSV body — one column of phone numbers — and returns a job you poll for results.

```bash
curl -X POST https://api.boundstone.io/v1/bulk/phone \
  -H "Authorization: Bearer bs_live_YOUR_KEY" \
  -H "Content-Type: text/csv" \
  --data-binary @leads.csv
```

You get back HTTP 202 and a `job_id`. Credits are reserved per row at submit and refunded for any row that errors, so a broken file does not quietly cost you. The free tier caps a job at 250 rows; paid plans go to 10,000. When the job finishes, pull the results:

```bash
curl https://api.boundstone.io/v1/bulk/JOB_ID/results.csv \
  -H "Authorization: Bearer bs_live_YOUR_KEY" \
  -o results.csv
```

Each row carries the same fields as the single `POST /v1/verify/phone` endpoint: `valid`, `e164`, `country`, `line_type`, and `national_format`.

## Filter results.csv by line_type

Now the actual cleaning. Two passes: drop what cannot be valid, then segment what remains by `line_type`.

```python
import csv

keep, drop = [], []
with open("results.csv", newline="") as f:
    for row in csv.DictReader(f):
        if row["valid"] != "true":
            drop.append(row)                  # impossible / unparseable — never dial
        elif row["line_type"] == "mobile":
            row["channel"] = "sms_or_call"
            keep.append(row)
        elif row["line_type"] == "fixed_line":
            row["channel"] = "call_only"       # don't text a landline
            keep.append(row)
        else:
            row["channel"] = "call_only"       # voip, toll_free, or ambiguous — call, verify by ear
            keep.append(row)

print(f"kept {len(keep)}, dropped {len(drop)}")
```

`e164` gives you a dialer-ready number; `line_type` lets you skip texting landlines and hold ambiguous lines for a live call. That is a cleaner, better-routed list than the file you started with.

## What this does not tell you

Here is the honest boundary, and it is the whole point of validating with Boundstone. A phone response tells you exactly what it checked:

```json
{
  "valid": true,
  "e164": "+16504472983",
  "country": "US",
  "line_type": "fixed_line_or_mobile",
  "national_format": "(650) 447-2983",
  "checks": {
    "performed": ["format", "region", "line_type_metadata"],
    "not_performed": ["carrier_lookup", "ported_status", "hlr_liveness"]
  }
}
```

`valid: true` means the number is well-formed and correctly ranged for its country. It does not mean the number is in service, currently assigned, or that a phone will ring. Boundstone does not perform carrier lookup, ported-status, or HLR liveness by default — you can see them listed plainly in `not_performed`. Send `hlr:true` and the dip runs for 5 credits, refunded when the network cannot answer. 

`line_type` is metadata-derived too. When a number's range is ambiguous, you get `fixed_line_or_mobile` — the API telling you it cannot distinguish, rather than guessing. Treat `line_type` as a strong hint for routing, not a guarantee, especially for numbers that may have ported between carriers.

## No code required

If you would rather not script it, the dashboard takes the same job. Upload a CSV or paste numbers one per line, run the batch, and download the results CSV — same 250-row free cap, same per-row credit accounting. That is good for a one-off list before a campaign; the API is for when cleaning becomes a step in your pipeline. For the wider workflow this fits into, see [outbound sales](/use-cases/outbound-sales).

## The short version

- Drop every row where `valid` is not `true` — those cannot be dialed. That pass alone removes the junk.
- Segment the rest by `line_type`: `mobile` can take SMS, `fixed_line` should be call-only, and `fixed_line_or_mobile` is the API admitting it does not know.
- **Understand the ceiling.** Valid means well-formed and correctly ranged, not "reachable." Carrier lookup, ported-status and HLR liveness are `not_performed` unless you send `hlr:true`.
- The free tier is 250 credits a month, no card, and credits never expire — enough to clean a small list or trial the bulk flow before you commit.
