The short answer
You do not need Google Maps to build a local B2B list. OpenStreetMap gives you the businesses in any city for free, each business's own website gives you the email and phone, and a DNS MX lookup tells you whether the address can receive mail. Measured on 8 Aug 2026: 55 dentists in Austin, 96% with a phone, 55% with an MX-verified email.
Why not Google Maps
Google Maps is the default answer, and for star ratings and review counts it is still the only answer. But it costs you three things:
- A proxy bill. Maps is aggressive about datacenter IPs, so most Maps scrapers need residential proxies, which is usually more expensive than the data.
- Terms-of-service exposure. Whatever your view of scraping public pages, using Google's own listing data is a policy question you have to answer to your client.
- A separate verification step. A Maps listing carries a phone and a website. It does not carry an email, so you end up bolting a crawler and then a verification API onto the pipeline, each with its own bill.
The alternative pipeline is three keyless steps: OpenStreetMap -> the business's own site -> MX check.
Step 1: discovery on OpenStreetMap
OpenStreetMap tags businesses by type (amenity=dentist, shop=hairdresser, office=lawyer, …). Overpass, the query API, is public and keyless. This is the part that surprises people who have only used Maps: for premises-based businesses the coverage is genuinely good.
It is also the part that fails honestly, and you should know the shape of the failure before you sell a list to anyone. Measured in the Austin bounding box on 8 Aug 2026:
| Category | Records in OpenStreetMap |
|---|---|
| dentist | 171 |
| plumber | 9 |
| electrician | 5 |
Premises get mapped; vans do not. If your niche is trades that drive to the customer, OpenStreetMap alone will not fill a list, and no amount of retrying changes that. Dentists, gyms, salons, law firms, restaurants, clinics, hotels, real-estate offices: dense. Plumbers, roofers, mobile locksmiths: thin.
Step 2: crawl the business's own site
The email is almost never in the map data. It is on the business's contact page. So the crawl is: fetch the homepage, follow the obvious contact/about links, pull mailto: addresses, tel: numbers, social profile links, and the meta generator tag that tells you the site is WordPress or Wix or Squarespace.
Two things worth building in from the start:
-
Whose mailbox is it? An address on the business's own domain is worth more than a
@gmail.com, and both are worth more than the address of the marketing agency that built the site — that one is deliverable and still never reaches the business. On the reference run, 12% of all harvested addresses were third-party (agency) addresses, but only 10% of the ones promoted to primary, because candidates get ranked by mailbox ownership before one is picked. -
A blocked site is not a missing business. Some sites refuse datacenter IPs: 8 of the 55 website-bearing rows in the reference run. Record that as
site_blockedrather than shipping an empty row that looks like "this business has no email".
Step 3: MX verification, which is a DNS lookup
You do not need a paid verification API to catch the majority of dead addresses. Resolve the domain's MX records (over DNS-over-HTTPS if you are running in a sandbox), and grade the address deliverable / risky / undeliverable. It will not catch a valid domain with a dead mailbox — nothing short of SMTP probing will, and that has its own problems — but it removes typo domains, parked domains and expired domains before you pay for the row.
Never promote a guessed address. info@<domain> is a guess. If you produce one, put it in its own column and never let it satisfy a "has email" filter. On the reference run, the full-enrichment preset produced 100 rows plus 23 guessed addresses — kept in email_guess, never billed as verified.
The whole pipeline as one input
I package the three steps as an Apify Actor, so this is the paste-and-run version:
{
"category": "dentist",
"location": "Austin, Texas",
"maxItems": 25,
"onlyWithWebsite": true
}
25 delivered rows at the free-plan rate of $0.003 each = $0.075 (a pricing record scheduled for 14 Sep 2026 raises that to $0.005 a row, so $0.125; the Pricing tab on the Actor page is authoritative). No API key, no proxy, no login; discovery, crawl, MX verification and a 0-100 lead score all happen inside that one run.
From the shell:
curl -X POST "https://api.apify.com/v2/acts/flash_scraper~local-business-leads/run-sync-get-dataset-items" \
-H "Authorization: Bearer $APIFY_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"category":"dentist","location":"Austin, Texas","maxItems":25,"onlyWithWebsite":true}'
Pass the token as a header, not a query parameter — query strings end up in server logs, proxy logs, stack traces and your own shell history. The Apify API accepts Authorization: Bearer on every endpoint.
From Python, keeping only the columns a CRM import wants:
import os
from apify_client import ApifyClient
client = ApifyClient(os.environ["APIFY_TOKEN"]) # never hardcode the token
run = client.actor("flash_scraper/local-business-leads").call(run_input={
"category": "dentist",
"location": "Austin, Texas",
"maxItems": 25,
"onlyWithWebsite": True,
"onlyVerifiedEmail": True,
"outputFields": ["name", "email", "email_status", "phone", "website", "lead_grade"],
})
rows = list(client.dataset(run["defaultDatasetId"]).iterate_items())
print(len(rows), "leads;", sum(r["email_status"] == "deliverable" for r in rows), "deliverable")
Already have a list of company websites and only need the contacts? Skip discovery entirely by passing websiteList — those rows come back marked source: "user_supplied".
A row looks like this:
{
"name": "Example Family Dental",
"category": "dentist",
"address": "123 Example St, Austin, TX",
"phone": "+1 512 555 0100",
"website": "https://example-dental.com",
"email": "hello@example-dental.com",
"email_status": "deliverable",
"email_type": "own_domain",
"website_platform": "WordPress",
"lead_score": 78,
"lead_grade": "B",
"osm_url": "https://www.openstreetmap.org/node/...",
"attribution": "© OpenStreetMap contributors (ODbL)"
}
The measured fill rates, including the ones I would rather not publish
All from dentist / Austin, Texas, 8 Aug 2026:
| Run | Result |
|---|---|
| Website required, cap 100 | 55 businesses, 100% contactable — 96% phone, 55% MX-verified email, 71% detected platform. Under the cap because OpenStreetMap holds 171 dentists in Austin and 55 of them are crawlable with a website |
Bare {}, no filters at all |
100 businesses, 50% contactable — 48% phone, 26% email, 34% platform |
| Cold-email preset | 31 rows, 100% with an MX-verified email, 94% with a phone |
| Call-list preset, cap 100 | 56 rows, 100% with a phone |
| Web-design-prospect preset, cap 100 | 7 rows — 68 Austin businesses met the low-score ceiling, but only 7 of them publish any contact channel at all |
The gap between line 1 and line 2 is the honest story of this kind of pipeline: filter for "has a website" and you get a contactable list; ask for everything mapped and roughly half the rows have nothing to contact. Both numbers are published, because a vendor who shows you only the first one is showing you a filter, not a capability.
One more sparse column worth naming: rating fills on about 7% of rows (4 of 55), and only where the business publishes a rating in its own site markup. It is never a Google rating. If you need Google's review data, scrape Google — this pipeline deliberately does not.
Making it a weekly new-business alert
Discovery is idempotent enough to schedule. Turn on only-new mode and the run remembers what this exact search already delivered (90-day memory, stored in your own account), so a weekly schedule delivers and bills only businesses that were not there last week:
{
"category": "dentist",
"location": "Austin, Texas",
"onlyNewBusinesses": true,
"webhookUrl": "https://hooks.slack.com/services/T000/B000/XXXX"
}
Set expectations honestly: new businesses appear at the speed of OpenStreetMap, which is the speed of volunteer mappers. Weekly or monthly is the right cadence; hourly will mostly report "nothing new" (and bill nothing for it).
Two legal points, not legal advice
-
OpenStreetMap data is ODbL. Attribution has to travel with the data — that is why every discovered row carries an
attributionstring, and why you should keep the column in your export rather than stripping it on import. - Cold email has rules that differ by country. In the US, CAN-SPAM makes commercial email opt-out rather than opt-in, but each message still needs a real physical postal address and a working unsubscribe. In the EU and UK, check the ePrivacy/GDPR position for B2B before you send. Verifying that an address exists is not the same as being allowed to mail it.
FAQ
Can I get local business emails without scraping Google Maps?
Yes. Use OpenStreetMap (public, keyless) for discovery, then crawl each business's own website for the address. You give up Google star ratings and review counts, and you gain emails, mailbox ownership, website platform and no proxy bill.
How much does 1,000 local business leads cost this way?
$3 at the Actor's free-plan rate of $0.003 per delivered row (read from the Apify Store on 5 Sep 2026), MX verification included — $5 from 14 Sep 2026, when a scheduled pricing record takes the rate to $0.005 per row. Read the Pricing tab rather than this sentence. Filtered rows are dropped before billing, so a run that finds nothing charges nothing.
Why did my category return so few rows?
Almost always OpenStreetMap density, not a scraping failure. Premises-based businesses are mapped; van-based trades are not. Compare 171 dentists with 9 plumbers in the same Austin bounding box (8 Aug 2026). A run that cannot reach your cap should tell you which of the two happened — a truncation or the whole city.
Is an MX check the same as email verification?
No. An MX check proves the domain can receive mail. It does not prove the specific mailbox exists. It removes typo, parked and expired domains cheaply, which is most of the dead addresses in a scraped list, and it is honest about the rest by grading them risky rather than deliverable.
Can I run this from an AI agent?
Yes, through the Apify MCP server. A minimal input measured locally on 29 Aug 2026 returned 10 rows in 17 seconds for about $0.03: { "category": "dentist", "location": "Austin, Texas", "maxItems": 10, "crawlEmails": false } (that particular input skips the email crawl, so use it for a smoke test, not for a list).
Disclosure: I build and maintain the Actor used in the examples (Local Business Leads Scraper, publisher flash_scraper on Apify). Every fill rate quoted here is published in its README with the date it was measured; the price was read from the public Apify Store API on 5 Sep 2026. The three-step pipeline itself is not proprietary — Overpass, a contact crawler and a DNS MX lookup will get you there yourself, and this post is meant to be enough to build it.
Top comments (0)