DEV Community

Zakaria Blefkih
Zakaria Blefkih

Posted on

How to scrape local business leads with MX-verified emails in Python

Building a table of local business leads usually means chaining several tools: one to discover the businesses in a city, one to find each business's website, one to pull an email off that site, and a verifier so you are not loading dead mailboxes into your CRM. Each category tends to need its own scraper, each scraper needs a proxy budget, and the output columns shift every time one link in the chain changes.

This post walks through one Apify actor, Local Business Leads Scraper by flash_scraper, that collapses that stack into a single actor call. It discovers businesses on OpenStreetMap, crawls each business's own public website, MX-verifies the email it finds and scores every row 0-100, with no API key, no proxy and no login. Used as a business email finder it returns not just the address but whose mailbox that address is. Everything below comes from the actor's README, its dataset schema and one real throwaway run; nothing is estimated.

What you get

One row per business, 73 stable columns wide, exported as CSV, JSON or Excel. The dataset schema describes a row as "identity, location, phone, MX-verified email, socials, website platform and marketing tech, a 0-100 lead score with an A-F grade, and the OpenStreetMap provenance of the row." It covers 95 curated categories (220+ terms) in any city on earth. The eight columns you will read first:

Column Title What the schema says it holds
name Name Business name
phone Phone Primary phone — the OSM tag, and only the OSM tag; never filled from the site
email Email Primary contact email, chosen by mailbox ownership then deliverability
email_status Email status deliverable / risky / undeliverable when verification is on and a candidate exists; missing when no email was found
email_type Mailbox owner own_domain / free_mail / third_party / unknown — third_party means the address belongs to the business's marketing agency or web designer
website_platform Website platform CMS / site builder: WordPress, Wix, Shopify, Squarespace, Webflow, GoDaddy, Weebly, Duda and more
lead_score Lead score 0-100 completeness/reachability score
lead_grade Grade A ≥80, B ≥65, C ≥50, D ≥35, else F

Two of those deserve a note. email_type decides whether a lead is usable at all: the schema says a third_party address "is deliverable but does not reach the business, and it is scored at half weight." email_status is the result of four checks that run over DNS and never over SMTP: syntax, a mail-server lookup on the domain, a role-address check and a disposable-domain list. No mailbox is ever probed, which is what keeps verification proxy-free and included in the price.

Every row also carries google_maps_url (a constructed search link; nothing is scraped from Google to build it), osm_url pointing at the source OpenStreetMap element, and attribution, the ODbL string the licence requires you to keep with an exported CSV.

A real run

A throwaway run from 2026-09-21, minimal input, no filters:

input:  category 'dentist', location 'Austin, Texas', maxItems 10
run:    SUCCEEDED 16.9s
STATUS MESSAGE: Done — 10 leads delivered for dentists in Austin, Texas. 1 with email (10%), 2 with phone (20%), 2 contactable (20%). 8 of these 10 row(s) have no phone, no email and no social profile — add requireAnyContact: true to drop them before they are charged.
Enter fullscreen mode Exit fullscreen mode

The status message is the honest part. A bare API call keeps every mapped location, including the ones with nothing to contact, and the actor says so and names the switch that fixes it. The README's filter-off benchmark reports the same at larger scale: on a bare {} run of 100 Austin dentists, "50 of the 52 rows with no website had no phone, no email and no social profile either." The Console form pre-fills onlyWithWebsite and pre-ticks requireAnyContact; API calls and schedules leave both off so existing inputs are unchanged.

With onlyWithWebsite: true, the README's reference run (dentist / Austin, Texas, 2026-08-08, n=55) measured a phone on 96% of rows, an MX-verified email on 55%, a detected website platform on 71% and a website on 100%. With every filter off (n=100) the same city measured 48% phone, 26% email and 34% platform.

The Python call

Copied from the README's "Smallest useful call"; the same JSON works in the Console, the REST API and n8n/Make/Zapier:

from apify_client import ApifyClient
client = ApifyClient("<APIFY_TOKEN>")
run = client.actor("flash_scraper/local-business-leads").call(run_input={"category": "dentist", "location": "Austin, Texas", "maxItems": 10, "crawlEmails": False})
rows = client.dataset(run["defaultDatasetId"]).list_items().items
Enter fullscreen mode Exit fullscreen mode

The sample sets crawlEmails: False, which the README measured locally on 2026-08-29 at 10 rows in 17 s for $0.03 at the free-plan rate. For a normal run, remove that key or set it to True as the README's fuller input examples do; the defaults are website crawling on, email verification on and 3 pages per website. An API call that sends no maxItems gets 100; the Console form starts at 25.

From there the pipeline is filters. onlyWithWebsite, onlyWithoutWebsite, excludeKeywords, excludeChains and skipClosed are decided from the map data alone, before any website is crawled; onlyWithEmail, onlyVerifiedEmail, requirePhone, requireSocial and requireAnyContact are applied as sites are crawled. Every one drops the row before it is pushed and before it is charged, so maxItems means "this many rows I can use", and the run log names each filter and how many rows it removed. outputFields narrows the columns; name and attribution are always included.

What it costs

From the README's pricing section: "$5 per 1,000 delivered leads, which is $0.005 per lead on the free plan and less on paid plans." Read from the live pricing on 2026-09-14: $0.005 per delivered lead on the free plan and $0.0045 (Bronze) down to $0.0025 (Diamond) on paid plans. You are charged for delivered businesses only, not for API calls or compute, there is no subscription, and the Pricing tab is authoritative. MX verification, mailbox-ownership classification and lead scoring are included in the single per-lead rate; filtered rows are dropped before billing; a run that finds nothing charges nothing.

The README's own arithmetic: the Console form as it opens (cap 25) costs at most $0.125 in leads, and 500 delivered rows cost $2.50; on the website-filtered reference run about 55% carried an email, so 500 rows is roughly 275 with an email. Since filters run before billing, onlyWithEmail: true keeps the bill to rows that carry an email.

What it does not do

Copied from the README's own limits section:

  • It does not scrape Google Maps. Listings come from OpenStreetMap, so there are no Google star ratings and no Google review counts.
  • The rating and review_count columns are sparse. They exist only when a business publishes a rating in its own website markup, measured at about 7% of rows (4 of 55 on the 2026-08-08 reference run), and they are never a Google rating. Set minRating or minReviewCount and rows with no rating are dropped, not kept.
  • Van-based trades are thinly mapped. OpenStreetMap held 171 dentists in the Austin bounding box but 9 plumbers and 5 electricians. A metro of a million people can return single digits for those categories.
  • A guessed email is never sold as a verified one. A pattern-guessed address stays in email_guess and is never promoted into email.

Three more constraints to script around. A multi-search run is capped at 25 category x location combinations; above it the run fails before any network request and before any charge. Verification never contacts a mail server, so read deliverable as "the domain accepts mail and the address is not a known-bad shape": a catch-all domain, or a mailbox deleted while the domain kept its mail servers, can still grade deliverable. And the listings are ODbL data, so keep the source and attribution columns if you redistribute or publish the rows.

Try it

One quick way to see the columns is the README's "Try it" example task, capped at 25 leads since 2026-09-01: Find local business leads with emails by category. The actor itself, with its Pricing tab, is at https://apify.com/flash_scraper/local-business-leads. Start at the form's cap of 25, read the status message, then raise maxItems once the first run looks right.

Top comments (0)