Skip to main content
Thirdwatchthirdwatch
Business & local data

Cut Your Cost per Contactable Lead in Local Prospecting

Work out what a usable local lead actually costs in each vertical, and tune the run settings that buy real contacts instead of billing you for empty rows.

Jul 28, 2026 · 8 min read · 1,755 words
See the scraper →

Cost per contactable lead, not cost per row, is the number that decides whether local prospecting pays. The Google Maps Lead Enrichment Actor charges per enriched business row, so every row without an email, phone or social profile is dead weight in your denominator. Two settings move that ratio more than anything else: onlyWithContact, which stops billing you for empty rows, and your choice of vertical, where email hit rates range from around 55 percent to around 90 percent. This post shows founders how to price a campaign before running it.

Why cost per contactable lead is the number that matters

Agency owners and bootstrapped founders do not buy lead rows. They buy conversations, and a row you cannot email or call never becomes one. The distinction gets expensive quickly at local scale: the US Small Business Administration counts over 30 million small businesses in the United States alone, and a meaningful share of them publish a phone number and a JavaScript contact form and nothing else.

So the arithmetic that decides your campaign is not the price per row. It is:

cost per contactable lead = (rows billed / rows you can actually reach) x price per row

That first term is the multiplier nobody quotes you. If 73 rows out of every 100 carry an email address, you are paying about 1.37 times the sticker rate for every lead you can put into a sequence. In US home services, where email hit rates sit around 55 to 60 percent, the multiplier is closer to 1.75. In wellness and design verticals it drops to about 1.15.

Nothing in your outbound tooling exposes this. You have to compute it before you run, from hit rates that are honest rather than aspirational, and then configure the run so you stop paying for the rows you were never going to use. The Google Maps Lead Enrichment Actor is built with exactly those knobs exposed.

How does this compare to the alternatives?

Every path to a local lead list has the same two costs: acquiring the map listings, then opening each business website to find the contact details. What differs is who absorbs the failure cases. A DIY crawler bills you engineer-hours for every dead domain, obfuscated email and edge-protected host you have to handle. A generic scraping API bills you per request regardless of whether the page contained a contact channel, which pushes your empty-row multiplier straight onto your invoice.

Approach What you pay for Reliability Setup time Ongoing maintenance
DIY Python crawler Engineer time, hosting, bandwidth You own every block, redirect and obfuscation case 2-5 days to a first list Constant: contact-page patterns and blocks drift
Generic scraping API Every request, hit or miss Fetches pages but does not extract contacts Half a day plus your own parser You still maintain the extraction layer
Thirdwatch Actor Transparent pay-per-result, with the option to skip contactless rows Extraction, obfuscated-email decoding and domain validation included Minutes None on your side

The practical difference is that the Actor lets you move the denominator. onlyWithContact is a billing control, not a convenience filter.

How to cut your cost per contactable lead in 4 steps

How do I stop paying for rows with no contact channel?

Set onlyWithContact to true. The Actor charges per enriched row it writes to the dataset, and this flag suppresses businesses where no email, phone or social profile was found anywhere across the pages it read. In a typical mixed run that removes about five percent of rows outright, and far more when your input list contains dead domains or businesses whose "website" is a Facebook page.

from apify_client import ApifyClient

client = ApifyClient("<YOUR_APIFY_TOKEN>")

run = client.actor("thirdwatch/google-maps-lead-enrichment").call(
    run_input={
        "queries": ["dentists in Portland OR"],
        "maxResults": 200,
        "onlyWithContact": True,      # do not bill rows with zero contact channels
        "verifyEmailDomains": True,   # flag placeholder domains before they hit your CRM
    }
)

items = list(client.dataset(run["defaultDatasetId"]).iterate_items())
emailable = [i for i in items if i.get("primary_email") and i.get("email_domain_mx_ok")]
print(f"billed rows: {len(items)}  emailable: {len(emailable)}")
print(f"multiplier: {len(items) / max(len(emailable), 1):.2f}x")

Run that once per vertical and you have your real multiplier, measured rather than assumed.

Which vertical should I run first?

Run the vertical with the highest published email rate first, because vertical choice moves your unit economics more than any setting. Measured across real small-business websites in the US, UK, India and Australia, the spread is wide and consistent:

Vertical Email hit rate Rows billed per 100 emailable leads
US home services (plumbing, HVAC) ~55-60% ~175
Professional services (law, dental) ~70-75% ~135
Wellness, design, studios (AU/IN) ~85-90% ~115

Home services deliberately push click-to-call and hide email behind contact forms. That is a real market behaviour, not a scraper defect. Price those campaigns at the low end.

# Probe three verticals cheaply before committing a full budget.
probe = client.actor("thirdwatch/google-maps-lead-enrichment").call(
    run_input={
        "queries": [
            "plumbers in Austin TX",
            "law firms Manchester UK",
            "yoga studios Melbourne AU",
        ],
        "maxResults": 60,          # 20 per query is enough to read the ratio
        "onlyWithContact": False,  # keep empty rows so you can measure the true rate
    }
)

Leave onlyWithContact off for the probe. You want the honest denominator once; you want it filtered every run after that.

What should I set maxPagesPerSite and concurrency to?

Leave maxPagesPerSite at its default of 4 and treat concurrency as a speed dial, not a cost dial. The page cap is deliberate: the Actor reads the homepage plus the highest-ranked contact, about and impressum pages, and yield saturates there. Pushing deeper on sites that produced no email recovered two results out of eighteen while tripling bandwidth per site.

Concurrency does not change how many rows you are billed for. It changes how long you wait, because every target is a different host with no shared rate limit.

run_input = {
    "queries": ["interior designers Bangalore"],
    "maxResults": 500,
    "maxPagesPerSite": 4,   # capped at 4 by design; 1 only for a fast reachability probe
    "concurrency": 16,      # wall-clock lever, not a billing lever
    "onlyWithContact": True,
}

Drop maxPagesPerSite to 1 only when you are testing whether a list of domains is alive at all. Raise concurrency above 16 only after increasing the run's memory allocation in the Apify console.

Does retryBlockedWithProxy earn its keep?

It earns its keep on fixed target lists and rarely anywhere else. retryBlockedWithProxy gives sites that reject the first request a second attempt with a browser-grade client and, optionally, proxy rotation. That lifts website reachability from roughly 89 percent to roughly 95 percent, at the price of extra bandwidth and run time.

The decision rule is simple. If you are prospecting open-endedly, another Google Maps query is a cheaper way to find six more contactable businesses than rescuing six blocked ones. If someone handed you 400 specific accounts and every miss is a real gap, turn it on.

run_input = {
    "businesses": [
        {"business_name": "Northgate Dental", "website": "https://northgatedental.example"},
        {"business_name": "Harbor Legal", "website": "https://harborlegal.example"},
    ],
    "retryBlockedWithProxy": True,   # fixed account list: coverage beats speed
    "proxyConfiguration": {"useApifyProxy": True},
    "onlyWithContact": False,        # you want to see which accounts failed, and why
}

Note the inversion: on a fixed list you want onlyWithContact off, so fetch_status and http_server_header tell you which accounts are dead versus merely defended.

Sample output

Each row is one business. The two records below show both ends of the economics: a fully contactable lead and a billed row that produced nothing usable.

[
  {
    "source_query": "dentists in Portland OR",
    "business_name": "Rose City Dental",
    "business_category": "Dentist",
    "business_address": "1220 SW Morrison St, Portland, OR 97205",
    "business_rating": 4.7,
    "website": "https://rosecitydental.example/",
    "final_url": "https://www.rosecitydental.example/",
    "primary_email": "front.desk@rosecitydental.example",
    "primary_email_source": "mailto",
    "email_domain": "rosecitydental.example",
    "email_is_freemail": false,
    "email_domain_mx_ok": true,
    "email_mx_provider": "google",
    "emails_count": 2,
    "primary_phone": "+15035550142",
    "phones_count": 3,
    "linkedin_url": "https://www.linkedin.com/company/rose-city-dental/",
    "instagram_url": "https://www.instagram.com/rosecitydental/",
    "social_profiles_count": 3,
    "contact_pages": ["https://www.rosecitydental.example/contact/"],
    "fetch_status": "ok",
    "http_status": 200,
    "pages_fetched": 4,
    "has_contact": true,
    "scraped_at": "2026-07-28T09:14:02+00:00"
  },
  {
    "source_query": "plumbers in Austin TX",
    "business_name": "Lonestar Drain Co",
    "business_category": "Plumber",
    "business_rating": 4.4,
    "website": "https://lonestardrain.example/",
    "primary_email": null,
    "emails_count": 0,
    "email_domain_mx_ok": false,
    "primary_phone": "+15125550188",
    "phones_count": 1,
    "social_profiles_count": 0,
    "fetch_status": "ok",
    "http_status": 200,
    "http_server_header": "cloudflare",
    "pages_fetched": 4,
    "has_contact": true,
    "scraped_at": "2026-07-28T09:14:07+00:00"
  }
]

The second row survives onlyWithContact because has_contact is true on the phone number alone. It is billed, and it is callable, but it is not emailable. Filter on primary_email plus email_domain_mx_ok when you are sizing an email sequence, and on has_contact when you are sizing a call list. Those are two different denominators and two different costs per contactable lead.

Common pitfalls

Treating the headline hit rate as your hit rate. Around 71 to 75 percent of businesses yield an email overall, but that average hides a 55 percent floor in US home services. Budget from the floor of your vertical.

Assuming onlyWithContact means "emailable". It only requires an email, a phone or a social profile. Phone-only rows still bill.

Assuming MX validation means the mailbox exists. email_domain_mx_ok proves the domain can receive mail, which kills roughly 5 percent of rows that carry theme-placeholder domains. It does not prove info@ is monitored; mailbox-level checks need SMTP conversations this Actor does not make.

Over-broadening a single query. One Google Maps search saturates at about 120 businesses. Raising maxResults on a broad query does not get you past it. Split by suburb or sub-category instead, which is covered in the city-by-city list post.

Paying twice for the same business. Overlapping metro queries duplicate rows, and duplicates bill. Deduplicate on place_id before you count.

Each of these is visible in the output rather than hidden: fetch_status, http_server_header, email_domain_mx_ok and has_contact exist precisely so you can audit your own multiplier instead of guessing at it.

Related use cases

Frequently asked questions

What is cost per contactable lead?

Cost per contactable lead is what you paid divided by the rows you can actually reach, not the rows you received. If a quarter of your enriched rows have no email address, your real cost per emailable lead is roughly a third higher than the sticker rate per row.

Does onlyWithContact reduce what I am billed?

Yes. The Actor charges per enriched row it outputs, and onlyWithContact drops businesses where no email, phone or social profile was found anywhere. That removes about five percent of rows in a typical run, and far more when you feed it a list of dead or unreachable domains.

Should I raise maxPagesPerSite above four?

You cannot, and you would not want to. Four is the schema maximum because testing deeper crawls on sites that produced no email recovered two results out of eighteen while tripling bandwidth per site. The contacts are on the homepage and the contact page or they are not published at all.

Is retryBlockedWithProxy worth turning on?

Only when you are working a fixed target list you cannot afford to lose. It lifts website reachability by roughly six percentage points and costs extra bandwidth and run time. For open-ended prospecting, adding another query is cheaper than rescuing a blocked site.

Related

Try it yourself

100 free credits, no credit card.

About 30 real searches. Add the MCP to Claude or Cursor in two minutes.