Phone enrichment vendors compete on one number: coverage. None of them publish how much of that coverage survives validation. So I built an open-source scorecard that runs a contact list through several providers, validates every number returned, and prices the difference — per market, per vendor, per waterfall order.
This is not a dashboard screenshot. It is a working pipeline — data in, validation, scoring, report out — reproducible with one command on any laptop.
Effective coverage is the only number that maps to conversations booked. I define it, compute it, and show where it inverts the vendor ranking.
Google's rule set, per country: length, prefix, region and mobile-vs-landline. Objective, not anecdotal.
Which vendors duplicate each other, and how much the waterfall order alone moves the invoice.
Validation proves plausible, not correct. The report says so, and shows how to close that gap with dialer data.
The dashed outline is what each vendor returned. The solid bar is what passed validation: a real mobile, right country, correct length. The distance between them is what you pay for and cannot use.
Effective coverage per country. Colour shows the bad-data rate behind it: clean shaky poor. Vendors do not degrade evenly — a stack that works at home can collapse the week you open a new market.
| Provider | AT | CH | DE | ES | FR | GB |
|---|---|---|---|---|---|---|
| AlphaData | 15.3% | 25.6% | 18.6% | 40.7% | 57.7% | 39.4% |
| BetaReach | 38.0% | 30.8% | 40.3% | 40.0% | 56.3% | 41.9% |
| DeltaPrime | 32.7% | 27.3% | 35.3% | 27.6% | 38.0% | 42.4% |
| GammaDACH | 56.7% | 48.8% | 58.4% | 19.7% | 19.8% | 19.7% |
When two vendors return the same number for the same person, one is redundant. Redundancy here is the share of vendor B's hits that A already had and agreed on. This is usually the fastest saving in an enrichment stack, and almost nobody measures it.
| Pair | Both | Only A | Only B | Agreement | Redundancy |
|---|---|---|---|---|---|
| AlphaData + BetaReach | 623 | 276 | 659 | 55.5% | 27.0% · keep both |
| BetaReach + DeltaPrime | 481 | 801 | 245 | 62.0% | 41.0% · review |
| BetaReach + GammaDACH | 435 | 847 | 286 | 53.8% | 32.5% · review |
| AlphaData + DeltaPrime | 346 | 553 | 380 | 80.9% | 38.6% · review |
| AlphaData + GammaDACH | 277 | 622 | 444 | 65.7% | 25.2% · keep both |
| GammaDACH + DeltaPrime | 271 | 450 | 455 | 82.7% | 30.9% · review |
A waterfall stops at the first vendor that returns anything — including junk. So the order you configure changes both your valid coverage and your invoice. Same vendors, same contacts, different sequence.
| Order | Raw coverage | Valid | Total cost | Cost / valid |
|---|---|---|---|---|
| BetaReach → AlphaData → GammaDACH | 87.8% | 66.6% | €167.66 | €0.126 |
| BetaReach → GammaDACH → AlphaData | 87.8% | 66.8% | €173.82 | €0.130 |
| BetaReach → AlphaData → DeltaPrime | 84.7% | 64.0% | €167.84 | €0.131 |
| AlphaData → BetaReach → GammaDACH | 87.8% | 71.4% | €196.05 | €0.137 |
| AlphaData → BetaReach → DeltaPrime | 84.7% | 68.8% | €196.23 | €0.143 |
| BetaReach → DeltaPrime → AlphaData | 84.7% | 64.8% | €185.44 | €0.143 |
| BetaReach → GammaDACH → DeltaPrime | 85.5% | 65.1% | €190.74 | €0.146 |
| AlphaData → GammaDACH → BetaReach | 87.8% | 74.9% | €223.59 | €0.149 |
| BetaReach → DeltaPrime → GammaDACH | 85.5% | 65.6% | €200.10 | €0.153 |
| AlphaData → DeltaPrime → BetaReach | 84.7% | 72.0% | €246.33 | €0.171 |
| AlphaData → GammaDACH → DeltaPrime | 78.3% | 71.2% | €254.96 | €0.179 |
| AlphaData → DeltaPrime → GammaDACH | 78.3% | 71.5% | €269.00 | €0.188 |
Five stages, each isolated so any one can be swapped without touching the others. That separation is the whole point: replacing simulated vendors with live APIs changes exactly one file.
The entire argument rests on one function. If validation is sloppy, every number in this report is noise. So the rule lives in one place, written in business terms, where anyone can challenge it.
# validate.py — three checks, in order parsed = phonenumbers.parse(phone, expected_country) # 1. A real, dialable number in that plan? is_valid = phonenumbers.is_valid_number(parsed) # 2. Does it belong to the market we expected? region = phonenumbers.region_code_for_number(parsed) country_matches = region == expected_country # 3. A mobile, not a switchboard? is_mobile = phonenumbers.number_type(parsed) in ( PhoneNumberType.MOBILE, PhoneNumberType.FIXED_LINE_OR_MOBILE, ) # The business rule, in one obvious place def is_usable(r): return r.is_valid and r.country_matches and r.is_mobile
A vendor returning the company reception number for every contact scores 100% on a length-and-format check and burns an SDR's entire week. Line type is the check that catches it.
If your team happily dials landlines, delete one condition and rerun. Writing the definition down settles the argument instead of burying it inside a query.
UK
where the ISO code is GB. That inflated every vendor's bad-data rate by
several points. Ground truth now generates and re-verifies until each number is genuinely
valid. A measurement tool that cannot measure itself is worth nothing.
Everything above runs on synthetic data, so it is safe to publish and free to run. Four changes take it to production, in roughly this order.
contact_id, full_name, company, country, segment. Country must be an ISO
code — GB, not UK.providers.py
changes. Every provider returns the same shape:
ProviderResult(contact_id, phone, provider). Nothing downstream cares where
the number came from, which is what makes an A/B of a new vendor an afternoon's work.Coverage was never the right number. The work was defining effective coverage, not visualising what everyone already tracked.
One command reproduces every number here. A conclusion nobody can rerun is an opinion with a chart attached.
The definition of "usable" is six lines in one file. Anyone can disagree, change it and rerun — which is how ops decisions should work.
Validation is a floor, not a guarantee. Saying so out loud is what makes the rest of the report trustworthy.