# How to detect VoIP phone numbers

_2026-07-18 · Boundstone (https://boundstone.io/blog/detect-voip-phone-numbers)_


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

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

```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"]
  }
}
```

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](/tools/phone-validator), and the field-by-field breakdown of every type value lives in [what is phone line type](/blog/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.

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

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

```python
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](/blog/validate-phone-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_type` reads `mobile`.
- **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:

```json
"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`.** `voip` means 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 by `line_type` from 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_status` and `hlr_liveness` are `not_performed` unless you send `hlr:true`.
- **Treat `fixed_line_or_mobile` as "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.
