Bulk Phone Number Validator & Cleaner — E.164, $0.50/1k
Pricing
from $0.50 / 1,000 phone validateds
Bulk Phone Number Validator & Cleaner — E.164, $0.50/1k
Clean a phone list end to end: parse and validate with libphonenumber, format to E.164, international and national, detect line type, region, carrier and timezone offline, deduplicate on E.164 and export segmented CSV. Reads numbers buried in free text. No external API calls.
Pricing
from $0.50 / 1,000 phone validateds
Rating
0.0
(0)
Developer
Daniele Giovane
Maintained by CommunityActor stats
0
Bookmarked
2
Total users
1
Monthly active users
2 days ago
Last modified
Categories
Share
$0.50 per 1,000 numbers, the same price on every plan, and duplicates are free. Everything runs inside the Actor container with Google's libphonenumber metadata: no HLR lookup, no third-party API, no number ever leaves the platform. That is also why it is this cheap — there is no per-lookup cost to pass on to you.
Upload the CSV your CRM exported and get back every number parsed, validated, deduplicated on E.164 and formatted, with line type, region, carrier and time zones — plus valid.csv, possible.csv, invalid.csv and mobile.csv ready to import, and a report that tells you what was actually wrong with your list.
Why this Actor
- Deduplication that understands phone numbers.
+39 333 1234567,0039 333 1234567,(333) 123-4567and333 1234567are one contact, not four rows. A spreadsheet's "Remove duplicates" compares strings; this compares E.164. Extensions are kept apart, so two desks behind one switchboard stay two contacts. - Files in, files out. CSV, TSV, XLSX, JSON, JSON Lines and plain-text lists — up to 20 files merged and deduplicated together. The phone column is auto-detected and always reported, wide CRM exports are read to the end, and
include_source_rowgives you back every other column, so the cleaned list is still a contact list. - It finds numbers buried in text.
call me on 333 1234567 after 6pm,+39 333 1234567 (home): a notes column is not a dead end. Validators that only accept one clean number per row return "invalid" for every one of those rows — and bill you for them. - It tells you when your run is pointless before charging you. A national list with no
default_regionset, a column of order ids, a name column: the run stops with a diagnosis (including which region would have validated your numbers) and you pay for the sample, not for the file. - Segments you can act on.
valid/possible/invalid, exported as ready-to-import CSV files, plus a cross-cuttingmobile.csv— the numbers an SMS or WhatsApp campaign can actually use. - Honest about what validation can prove. See What "valid" means.
Input
Provide inline numbers, files, or both — they are merged and deduplicated together.
{"phones": ["+39 333 1234567", "0039 333 1234567", "(212) 555-0123"],"sources": [{ "url": "https://example.com/exports/contacts.csv" },{ "key": "hubspot-export.xlsx", "store_id": "abcDEF123" },{ "base64": "bmFtZSxwaG9uZQo...", "filename": "list.csv" }],"default_region": "IT","phone_column": "","dedup": true,"output_formats": ["e164", "international", "national", "rfc3966"],"strict": false,"detect_type": true,"carrier_lookup": true,"timezone_lookup": true,"geo_lookup": true,"extract_from_text": true,"output_segments": true,"include_source_row": false}
| Option | Values | Default | What it does |
|---|---|---|---|
phones | array of strings | – | Inline numbers |
sources | array of {url} / {base64} / {key,store_id} | – | Up to 20 files, CSV/TSV/XLSX/JSON/JSONL/TXT |
default_region | ISO-3166-1 alpha-2 (US, GB, IT…) | – | Country assumed for numbers written without a country code |
phone_column | string | auto | Header name of the phone column; empty = auto-detect |
dedup | boolean | true | true: one record per unique number (matched on E.164). false: one record per input row, duplicates flagged |
output_formats | e164, international, national, rfc3966 | all four | Which formats to write. E.164 is always produced (it is the dedup key) |
strict | boolean | false | Send possible-but-not-valid numbers to invalid instead of possible |
detect_type | boolean | true | Mobile / fixed line / VoIP / toll-free / premium rate / … |
carrier_lookup | boolean | true | Carrier from the bundled offline metadata (mobile ranges only) |
timezone_lookup | boolean | true | IANA time zones for the number |
geo_lookup | boolean | true | Area description (Milan, New York, NY) for geographic numbers |
extract_from_text | boolean | true | Read the number out of a cell that also contains text |
output_segments | boolean | true | Write valid.csv, possible.csv, invalid.csv, mobile.csv to the run's key-value store |
include_source_row | boolean | false | Copy the original row into source_row (max 30 columns, 500 chars per value, 1000 chars per row) |
ignore_low_validity | boolean | false | Process the whole file even when the first 1000 numbers contain nothing valid |
max_numbers | 1–200000 | 200000 | Safety cap on rows processed and charged |
max_file_size_mb | 1–100 | 50 | Bigger files are skipped with a warning |
default_region: the one setting that matters
A number written +39 333 1234567 carries its country; a number written 333 1234567 does not, and no validator on earth can check it without knowing the country. So:
- numbers starting with
+or00are validated whatever you set; - for the others,
default_regionis the country assumed (two letters:US,GB,DE,IT,IN,BR, …); - if your list is national and you leave
default_regionempty, the run stops before validating anything, tells you which region would have validated it (checked against all 245 regions libphonenumber knows) and charges you only the run start. It never applies that guess for you — stamping 200,000 numbers with a country nobody confirmed is exactly the kind of confident mistake that makes a validated list worthless.
How the phone column is chosen (and how you know)
phone_column, if you set it.- Otherwise an unambiguous header name (
phone,mobile,telephone,cell,msisdn,telefono,cellulare,téléphone,telefonnummer,numer telefonu…). Ambiguous ones (number,contact,fax,line) are only considered after those. - Whatever the name says, the data has the last word. The chosen column is sampled and actually parsed with libphonenumber: if fewer than 30% of its values are valid numbers and fewer than 30% are even phone-shaped, the column whose values really are numbers is used instead — a
Phone,Company,Mobilefile wherePhoneholds ticket ids is read fromMobile. If no column qualifies, nothing is read and nothing is charged. - The result is always in the log and in the run report:
Phone column: 'Mobile' (index 2), chosen by content (most values are valid phone numbers); 100% of the 200 sampled value(s) are valid phone numbers and 100% look like one. - A cell that is not a number but contains one has the number extracted, so a
notescolumn yields contacts instead of 1,200 invalid rows. - Auto-detection is strict on purpose, but
phone_columnis an instruction: a column you name is read even when only a few of its values look like numbers (the rest come back as invalid), as long as it holds at least one.
Malformed files are recovered where possible: a single unbalanced " (the classic way a CSV loses 99% of its rows in silence) is caught by comparing the numbers extracted with the numbers visible in the raw text, then re-parsed ignoring quotes — and the alternative parse is kept only if it yields more real numbers, never because it yields more rows. A valid file with multi-line quoted fields (notes, postal addresses — what Excel, Sheets and HubSpot export every day) is therefore never shredded.
Output
One dataset record per number (or per row, with dedup: false):
{"phone_original": "call me on 333 1234567 after 6pm","e164": "+393331234567","international": "+39 333 123 4567","national": "333 123 4567","rfc3966": "tel:+39-333-123-4567","is_valid": true,"is_possible": true,"validation_reason": "is_possible","region_code": "IT","country_code": 39,"national_number": "3331234567","extension": null,"number_type": "mobile","location": null,"carrier": "TIM","carrier_may_have_changed": true,"timezones": ["Europe/Rome", "Europe/Vatican"],"is_duplicate": false,"duplicate_of": null,"occurrences": 3,"score": 100,"segment": "valid","reasons": ["note:number_extracted_from_surrounding_text"],"source": "contacts.csv","row_number": 42,"source_row": null,"summary_json": null,"warnings": []}
phone_original is always the cell as it was in your file — including the surrounding text when the number was extracted from it. reasons is the audit trail: scoring rules (possible_but_not_a_valid_number, premium_rate_number, not_a_possible_number:too_short) plus every cleaning step applied, prefixed with note: (note:number_extracted_from_surrounding_text, note:label_prefix_stripped, note:additional_numbers_in_cell_ignored:1). Nothing is changed silently.
number_type is one of mobile, fixed_line, fixed_line_or_mobile, toll_free, premium_rate, shared_cost, voip, personal_number, pager, uan, voicemail, unknown. unknown means the metadata cannot tell the ranges apart in that country — it is not a defect of the number.
Files written to the run's key-value store:
| Key | Content |
|---|---|
valid.csv / possible.csv / invalid.csv | The three segments, ready to import |
mobile.csv | Cross-cutting: every valid number whose type is mobile or fixed_line_or_mobile — the SMS-reachable list |
PHONE_REPORT | The full report as JSON |
Segments larger than one record are split into valid-2.csv, valid-3.csv, … — never silently truncated. Key-value-store files are used rather than named datasets so a daily scheduled run does not slowly fill your account with storages.
The last dataset record is the report (phone_original: "_summary"), carrying the same columns as every other record plus summary_json — a JSON string, so exporting the dataset to CSV gains one column, not one per report field:
{"input_rows": 12480, "unique_numbers": 10233, "duplicates_found": 2247,"duplicate_rate_pct": 18.0, "stopped_reason": null,"charged_numbers": 10180, "free_records": 53,"estimated_cost_usd_at_list_price": 5.095,"segments": { "valid": 8410, "possible": 1102, "invalid": 721 },"by_number_type": { "mobile": 6120, "fixed_line": 2100, "toll_free": 90 },"by_region": [{ "region_code": "IT", "count": 7400 }],"by_country_code": [{ "country_code": "39", "count": 7400 }],"top_carriers": [{ "carrier": "TIM", "count": 2210 }],"not_valid_reasons": { "possible_but_not_valid": 1102, "too_short": 410, "not_a_phone_number": 311 },"mobile_numbers": 6120, "numbers_with_extension": 24, "extracted_from_text": 87,"suggested_default_region": null,"segment_files": { "valid.csv": 8410, "mobile.csv": 6120 },"options": { "...": "the options this run actually used" },"warnings": ["..."], "seconds": 9.4}
Scoring and segments
Deterministic and auditable — you can recompute any record by hand from this table.
| Situation | Score | Segment |
|---|---|---|
| Valid number (right length and an assigned range in that country) | 100 | valid |
| Possible but not valid (plausible length, no assigned range) | 60 | possible (invalid with strict) |
| Possible only as a local number (area code missing) | 55 | possible (invalid with strict) |
| Not possible: too short, too long, invalid length, unknown country code | 0 | invalid |
| No phone number could be read from the value at all | 0 | invalid (and free) |
Valid numbers whose type is worth knowing before you dial keep their score and get a reason instead: premium_rate_number, toll_free_number, shared_cost_number, voip_number, pager_number, voicemail_number, personal_number, universal_access_number.
What "valid" means (and what it does not)
libphonenumber checks a number against Google's metadata of national numbering plans. A valid verdict means:
this number is correctly formed and falls inside a range that country has assigned to an operator.
It does not mean the number is in service, that a human answers it, or that it belongs to the person in the row. Nothing offline can tell you that: only a network (HLR) lookup or an actual call can, and both cost real money per number — around 100× what this Actor charges. What this Actor removes, before you pay for anything like that, is the 20-40% of a typical list that is junk: broken numbers, wrong lengths, unassigned ranges, duplicates written four different ways, and cells that were never phone numbers.
Two more limits worth stating plainly:
- Carrier data is allocation data. It is the operator the range was assigned to. In every country with mobile number portability the subscriber may have moved: the record says so with
carrier_may_have_changed: true. Carrier metadata only covers mobile (and pager) ranges — a landline getscarrier: nullbecause there is no reliable mapping, not because the lookup failed. locationis an area description, available for geographic numbers (mostly landlines). Mobile numbers are not tied to a place, so they getnull.
Pricing (pay per event)
| Event | Price | When it is charged |
|---|---|---|
apify-actor-start | $0.005 | Charged automatically by the Apify platform when a run starts. Apify covers the compute cost of the first 5 seconds of every run. Never charged from the Actor code |
phone-validated | $0.0005 | Once per unique number that got a real verdict — $0.50 per 1,000 |
The $0.005 start fee is charged by the Apify platform on every run, whatever the input turns out to contain — it is what pays for the first 5 seconds of compute, and the Actor never charges it itself. What a run can make free is the work: duplicate rows (charged once, for the first occurrence only), blank cells, values that are not phone numbers at all (N/A, ask Marco, Weekly digest 12 — they still get a record with the reason), rows with no phone column, files that could not be downloaded, rows dropped by max_numbers, and everything after the run's charge limit is reached — the run stops instead of working for free. A run that reads nothing at all costs the $0.005 start fee and not one number.
And if the file is wrong, you pay cents, not for every broken row:
- a column that holds no numbers → nothing is read, $0.005;
- a national list with no
default_region→ stopped before validating anything, $0.005, with the region to set; - a column of order ids, names or dates → stopped after the first 1,000 values (1,500 when the file is full of duplicates), under $0.76, with a diagnosis;
- a list that is simply old and rotten — disconnected numbers, wrong lengths, malformed entries that still look like phone numbers — is not an accident: that is the job, and it is processed to the last row (as long as at least some of it validates;
ignore_low_validity: trueremoves even that condition).
Examples:
- 10,000 rows with 18% duplicates:
$0.005 + 8200 × $0.0005= $4.11 (≈ $0.41 per 1,000 uploaded rows) - 50,000 unique numbers:
$0.005 + 50000 × $0.0005= $25.01 - A daily 500-row incremental check:
$0.005 + 500 × $0.0005= $0.26 - 20,000 rows of junk (wrong column): stopped after the sample, ≤ $0.76
Compute is a rounding error: parsing, validating, formatting and deduplicating 100,000 numbers takes a few seconds on the default 1 GB memory, because every lookup is a dictionary lookup inside the container. There is no DNS, no SMTP, no HTTP: the per-number price is essentially all you pay.
Honest comparison
| Alternative | Honest trade-off |
|---|---|
| HLR / network-lookup APIs (Twilio Lookup, Vonage, …) | They ask the mobile network whether the number is live and where it has been ported: strictly more information than any offline check. They also cost roughly $0.005-0.01 per number — 10-20× this Actor — and they charge for every duplicate and every piece of junk you send them. Use this Actor to clean the list first, then pay for lookups on what survives. |
| Per-number validator Actors (one number per input item) | If you already have one clean number and want one answer, they are simpler. They do not take a CSV, do not deduplicate on E.164, do not detect the phone column, do not extract numbers from text and do not segment — which is most of the work when the input is a real CRM export. |
=IF(ISNUMBER(...)) in a spreadsheet | Free, and how most people do it. It cannot tell a valid mobile from a nine-digit typo, cannot format to E.164, and its "Remove duplicates" will not notice that 0039 333 1234567 is the row above. |
| Running libphonenumber yourself | Same engine, same metadata — this Actor is the glue: file parsing, column detection and validation, text extraction, dedup keys, segment exports, the report, and the caps that stop a 200 MB spreadsheet from eating your afternoon. If you have a developer and a day, do it yourself; it is Apache-2.0. |
Your data
- You are the data controller. A contact list is personal data. You decide why it is processed; this Actor is a tool you run inside Apify, and Apify is your processor under its Data Processing Addendum. The Actor author is not a party to that processing and never sees your data.
- No number ever leaves the container. Validation, line type, carrier, area and time zones all come from metadata files bundled in the image. There is no third-party API, no enrichment vendor, no LLM and no HLR lookup, and not a single query of any kind is made about your numbers — the only outbound traffic in the whole Actor is downloading the source URLs you provide (which does resolve that URL's host, and nothing else). The offline test suite fails on any attempt to open a socket, so this cannot regress unnoticed.
- Processing is in memory; the output is stored. Rows are parsed in the container and never written anywhere else — but the dataset records and the CSV files this run produces persist in your Apify account under your account's data-retention settings. If your retention policy for contact data is shorter, delete the run's storages when you are done.
- No number content in the logs, no telemetry. Logs contain counts, warnings and column names.
- SSRF-guarded fetching. Source URLs that resolve to private, loopback, link-local, multicast, reserved, carrier-grade-NAT (
100.64.0.0/10) or IPv4-mapped-IPv6 addresses are refused, including across redirects.
Limits and failure behaviour
Nothing here fails a run: every limit produces a warning in the log and in the report. Even an empty or unreadable input ends with a SUCCEEDED run and an explicit status message rather than a failure.
| Limit | Value |
|---|---|
| Numbers per run | 200,000 (max_numbers) |
| Files per run | 20 |
| File size | 50 MB default, 100 MB max |
| Columns per row | 1,024 (only the head of a file is held in memory in full) |
| Characters per cell | 2,000 (a longer cell is truncated with a warning) |
| Dataset record | 8 MB — source_row is dropped (with a warning) before that is reached |
| Segment CSV | split into numbered parts, 200,000 rows per segment |
| Circuit breaker | ≥60% not-valid in the first 1,000 unique numbers and either no number readable at all, or not one valid number → the run stops with a diagnosis. ignore_low_validity: true disables it |
Malformed CSV, unbalanced quotes, broken ZIP/XLSX, PDFs and images (even renamed to .csv), unreachable URLs, UTF-16/CP1252/Latin-1 encodings, spreadsheet formulas with no cached value, and files with no phone column all produce a warning and let the rest of the run continue.
FAQ
My numbers have no country code. What happens?
Set default_region to the country of the list. If you forget, the run stops before charging for any number and tells you which region would have validated it — you re-run with one setting changed.
My list mixes several countries.
Numbers written with + are each validated against their own country, whatever default_region says. Set default_region to the country of the national numbers (the ones without a prefix); the summary's by_region breakdown shows what you actually got.
Numbers are inside a notes column, mixed with text.
Point phone_column at it (or let auto-detection find it): each number is extracted, validated and deduplicated like any other, and phone_original keeps the full original cell. Only numbers that come out valid are taken from text, so order 12345678 of 2024 is not turned into a contact. If a cell holds several numbers, the first is used and the record says how many were ignored.
Does it check whether the phone is switched on? No, and nothing offline can. See What "valid" means.
What is the difference between valid and possible?
possible = the length is plausible for that country; valid = it is also inside a range that country actually assigned. A number that is only possible is usually a typo or a very new range. Use strict: true to treat those as invalid.
Why is carrier empty for my landlines?
Because the metadata only maps mobile ranges. An empty carrier is "not available", never "no carrier".
Can I get only the mobile numbers?
Download mobile.csv from the run's key-value store, or filter the dataset on number_type.
How do I merge several lists?
Put them all in sources: they are read in order, deduplicated across files, and source + row_number in each record tell you where each number came from.
Does it send an SMS or place a call? No. It never contacts a number, a network or an API. The only network traffic is downloading the files you point it at.
Attribution. Validation and metadata come from python-phonenumbers, the Python port of Google's libphonenumber, Apache-2.0 licensed; the licence travels with the library inside the image.
You may also need
Same author, same principles: you pay for work that was actually done, nothing is stored outside your own run, and no third-party API ever sees your data.
- PDF, Word & Excel to Markdown — turn PDFs, Word, Excel and scans into clean Markdown/JSON with cell-level tables and RAG chunks.
- Email List Cleaner — deduplicate, verify and segment a whole email list from CSV or XLSX.