How to clean a phone number list before an outbound campaign
Bulk-validate the file, drop the numbers that cannot be real, and route the rest by line type — with a clear line on what validation does and does not tell you.
Contents
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, and you can spot-check a single number with the keyless phone validator tool — 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.
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:
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.
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:
{
"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.
The short version
- Drop every row where
validis nottrue— those cannot be dialed. That pass alone removes the junk. - Segment the rest by
line_type:mobilecan take SMS,fixed_lineshould be call-only, andfixed_line_or_mobileis 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_performedunless you sendhlr: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.