How to detect VoIP phone numbers
Line-type metadata flags numbers that sit in VoIP-designated ranges — useful before you dial or text, and honest about the hosted and ported VoIP it cannot see.
Contents
A VoIP number looks like any other number right up until you try to work it. It might refuse SMS, route voice to a softphone that never rings, or be the cheap, disposable line a fake signup reaches for. So before your reps dial or your platform texts, you want the VoIP rows in your list flagged. The good news: a lot of VoIP is detectable from number metadata alone, for free, before any live lookup runs. The honest news, which is most of this post: metadata catches the VoIP that lives in ranges designated for VoIP — not the VoIP that borrows an ordinary mobile or fixed-line range. Here is how to detect VoIP phone numbers with line-type metadata, and exactly where that detection stops.
Why flag VoIP before you dial
A VoIP line does not behave like the landline or mobile next to it in your file:
- VoIP has no fixed geography. It does not map to a physical location or a single carrier the way a geographic landline does.
- Delivery is unreliable. Some VoIP lines silently drop inbound SMS or forward voice into an app.
- Disposable VoIP is a signup-fraud favorite — a number that sails past a naive "is this even a number" check.
No percentages here — the benchmark has not published, and you do not need invented stats to know a landline and a burner softphone deserve different handling. Worth stating plainly: flagging VoIP is a segmentation and hygiene step, not a compliance step. It does not scrub the Do-Not-Call registry, and it is not legal advice — for DNC or TCPA obligations you use the official registries (such as the US National Do Not Call Registry) and your own counsel. Boundstone's job is the hygiene layer: drop the invalid, segment by line type, normalize to E.164.
Detect VoIP with line_type
The line_type field on a phone response comes from Google's libphonenumber metadata — the type a number's range is designated for in its national numbering plan. When a range is set aside for VoIP, libphonenumber reports it, and Boundstone returns line_type: "voip".
curl -X POST https://api.boundstone.io/v1/verify/phone \
-H "Authorization: Bearer bs_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"phone":"+16504472983"}'
The field to read is line_type:
{
"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"]
}
}
That number sits on a range North America shares between fixed line and mobile, so the plan cannot split it — more on fixed_line_or_mobile below. When a number instead sits in a range its numbering plan reserves for VoIP, the same call returns the identical shape with one field changed: line_type reads voip. That is the row to flag. You can try a single number with no signup on the keyless phone validator tool, and the field-by-field breakdown of every type value lives in what is phone line type.
Flag VoIP across a whole list
One number is a curl call; a lead file is a bulk job. POST /v1/bulk/phone takes a raw CSV body — one column of numbers — and returns a job you poll.
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. One credit is reserved per row and refunded for any row that errors, so a messy file does not quietly cost you. Free jobs cap at 250 rows; paid plans go to 10,000. Pull the results when the job finishes and filter on line_type:
curl https://api.boundstone.io/v1/bulk/JOB_ID/results.csv \
-H "Authorization: Bearer bs_live_YOUR_KEY" \
-o results.csv
import csv
voip, keep = [], []
with open("results.csv", newline="") as f:
for row in csv.DictReader(f):
if row["valid"] != "true":
continue # not a real number — drop it
if row["line_type"] == "voip":
voip.append(row) # designated VoIP — flag or hold
else:
keep.append(row) # mobile, fixed_line, and the rest
print(f"flagged {len(voip)} VoIP, kept {len(keep)}")
That gives you a VoIP bucket to suppress or hold for review, and a cleaner main list — though metadata still cannot promise any kept number will ring. The single-number, in-process version, without the CSV round-trip, is in validate a phone number in Python. Prefer no code at all? The dashboard runs the same job: upload a CSV or paste numbers one per line and download the results, same 250-row free cap and per-row accounting.
The ceiling: designated VoIP is not all VoIP
Here is the honest boundary, and it is the reason to trust the flag you do get. line_type reports the type a number's range is designated for. It catches VoIP that lives in ranges the numbering plan set aside for VoIP. It does not catch VoIP that runs on top of a range designated for mobile or fixed line. Two common ways a VoIP number hides from metadata:
- Hosted VoIP on a normal range. A provider hands you a number from an ordinary mobile or geographic block and runs it over VoIP. The range still reads "mobile," so
line_typereadsmobile. - Ported numbers. A number ported from a mobile carrier onto a VoIP service keeps its original range's designation. Metadata reflects where a number was born, not where it lives now.
To see through either, you need a live carrier lookup — an HLR or LRN query that asks the network who currently serves the number. Boundstone does not run that by default, and it says so in every response:
"checks": {
"performed": ["format", "region", "line_type_metadata"],
"not_performed": ["carrier_lookup", "ported_status", "hlr_liveness"]
}
carrier_lookup, ported_status, and hlr_liveness are all not_performed. That live layer is a paid opt-in (hlr:true, 5 credits, refunded on abstain), so this post claims no detection rate for the metadata pass — only that it catches designated-VoIP ranges, which is a real, useful subset, not the whole set.
One more field to read honestly: fixed_line_or_mobile. In North America especially, many ranges are shared between fixed line and mobile, so libphonenumber cannot tell them apart and returns fixed_line_or_mobile. That value is the API declining to guess — treat it as "unknown," not as "not VoIP." A VoIP number reusing such a range would land there too. And valid: true never means "live" — it means well-formed and correctly ranged, nothing about whether a phone will ring.
The short version
- Read
line_type.voipmeans the number sits in a range designated for VoIP — flag it before you dial or text. - Bulk-bucket a whole file. Run it through
POST /v1/bulk/phone, then bucket byline_typefrom results.csv. One credit per row, refunded on any row that errors. - Know the ceiling. Metadata catches designated VoIP, not hosted or ported VoIP that reuses a mobile or fixed-line range. Those need a live carrier lookup —
carrier_lookup,ported_statusandhlr_livenessarenot_performedunless you sendhlr:true. - Treat
fixed_line_or_mobileas "unknown," not "not VoIP" — it is the API refusing to guess on a shared range. - The free tier is 250 credits a month, no card, and credits never expire — enough to flag VoIP across a real list before you commit.