How to Build a Service-Area Lead List from Google Maps
If you sell to home-service contractors — plumbers, HVAC techs, roofers, electricians — your total addressable market is not "businesses." It is every actively-operating service company inside a specific metro, with a phone number you can dial and a website you can pull an email from. Google Maps is the best public source for exactly that list: owners maintain their own listings, so the data is current, and almost every contractor has a website on file. The problem is that a single Maps search only ever shows you a slice of the city, not all of it.
This guide is the full service-area pipeline. We will cover why one search can never give you a whole metro, how to turn a bounding box into a grid of viewport centroids with a short Python helper, how to fire a Google Maps leads job per viewport, poll and collect the results, dedupe by Google's cid, and write a clean CSV that drops straight into outreach or a CRM. The example niche is plumbers in Austin, but the exact same code covers roofers in Phoenix or HVAC in the Dallas–Fort Worth sprawl by swapping two strings and a bounding box.
Why One Search Will Never Cover a Metro
Every Google Maps search is encoded entirely in its URL, and the geographic part is the @lat,lng,zoom block:
https://www.google.com/maps/search/plumbers/@30.2672,-97.7431,13z
The structure is /maps/search/<query>/@<lat>,<lng>,<zoom>z. The @ block is the viewport center — Google returns the businesses it considers most relevant around that point, ranked by a blend of distance and prominence. And here is the constraint that shapes the entire pipeline: a single viewport caps at roughly 120 results. Past that, result density collapses and Google starts returning low-relevance fillers from outside the area.
That cap is not a per-page or per-request limit you can pay your way around — it is enforced at the source, so every scraping tool inherits it. If you point one job at downtown Austin and ask for 50 pages, you do not get 1,000 plumbers; you get the same ~120, padded with junk.
The other thing to internalize: a wider zoom does not mean more coverage. @30.27,-97.74,9z covers all of central Texas in one frame, but it still returns ~120 results — now spread thin across a huge area, so you get the most prominent businesses metro-wide and miss the long tail of small operators in every neighborhood. For lead-gen you want the long tail. That means tighter viewports (12z–14z), more of them, stitched together.
So covering a whole service area is a grid problem: tile the metro into overlapping viewport centers, scrape each one, and dedupe.
Step 1: Turn a Bounding Box into a Viewport Grid
You do not want to hand-pick centroids for 30 neighborhoods. Instead, define a bounding box around the metro — (south_lat, west_lng, north_lat, east_lng) — and walk it into a grid of evenly spaced centers. At 13z a viewport comfortably covers a few kilometers, so stepping the grid by roughly 0.04–0.05 degrees gives you tiles that overlap slightly (overlap is good — dedupe handles it, and gaps lose businesses).
Here is a small, dependency-free helper that does it:
def viewport_grid(south, west, north, east, step=0.045, zoom=13):
"""Walk a bounding box into a list of (lat, lng, zoom) viewport centers.
step ~0.045 deg gives slightly-overlapping tiles at zoom 13.
Smaller step = denser grid = more coverage and more API calls.
"""
centers = []
lat = south
while lat <= north:
lng = west
while lng <= east:
centers.append((round(lat, 4), round(lng, 4), zoom))
lng += step
lat += step
return centers
def maps_url(query, lat, lng, zoom):
q = query.replace(" ", "+")
return f"https://www.google.com/maps/search/{q}/@{lat},{lng},{zoom}z"
# Austin, TX bounding box (south, west, north, east)
AUSTIN = (30.10, -97.95, 30.52, -97.56)
grid = viewport_grid(*AUSTIN, step=0.045, zoom=13)
urls = [maps_url("plumbers", lat, lng, z) for lat, lng, z in grid]
print(f"{len(urls)} viewports to cover the metro")
# → ~90 viewports
Get the bounding box by panning to the corners of your target area in Google Maps and reading the @lat,lng out of the URL bar at each corner. For a metro like Austin, a 0.045 step at 13z produces on the order of 80–100 tiles — that sounds like a lot, but each runs in parallel and dedupe collapses the overlap. If that is more coverage than you need for a first pass, bump step to 0.07 and zoom to 12 for a coarser ~30-tile grid, then densify only the areas where you are clearly hitting the 120-cap.
Step 2: Fire a Leads Job per Viewport
The Google Maps leads endpoint takes one viewport search URL, scrapes the businesses in it, and then enriches each row by visiting the business's own website to pull contact details — emails, additional phones, and social profiles that are not on the Maps listing itself. Every call is asynchronous: you submit, get a job_id back, then poll.
Confirm one viewport works with curl before you loop:
# 1) Submit one viewport — returns a job id immediately
curl -G "https://api.logposervices.com/api/v1/ecommerce/googlemaps/leads" \
-H "X-API-Key: lp_xxxxxxx" \
--data-urlencode "url=https://www.google.com/maps/search/plumbers/@30.2672,-97.7431,13z" \
--data-urlencode "pages=5" \
--data-urlencode "start_page=1"
# → {"job_id": "gm_8f3a...", "status": "pending"}
# 2) Poll the job until status == "completed"
curl -H "X-API-Key: lp_xxxxxxx" \
https://api.logposervices.com/api/v1/jobs/gm_8f3a
# 3) Fetch the enriched rows
curl -H "X-API-Key: lp_xxxxxxx" \
https://api.logposervices.com/api/v1/jobs/gm_8f3a/result
The leads path does more work than a plain search because the website-enrichment step opens each business site — so it is the slower of the Maps endpoints. That makes the async pattern non-negotiable, especially across a grid. api.logposervices.com sits behind Cloudflare, which kills any single connection at roughly 90 seconds. A 5-page enriched viewport can run longer than that, so never wait on one inline request — submit the job, let it run server-side, and poll for the result.
pages=5 is about 100 businesses from a viewport before the cap and duplicates take over; start_page lets you resume or window into a viewport, but for grid coverage you almost always want start_page=1 and a modest page count per tile, since the grid — not deep paging — is what buys you coverage.
Step 3: Submit the Grid and Poll It
For a whole metro you are submitting dozens of jobs, so the right pattern is fire-all-then-poll: submit every viewport up front (each returns instantly with a job_id), then poll the outstanding job ids in a loop until they all finish. This keeps the whole grid running in parallel server-side instead of waiting on each viewport in sequence.
import os, time, requests
API_KEY = os.environ["LOGPOSE_API_KEY"]
BASE = "https://api.logposervices.com/api/v1"
HEADERS = {"X-API-Key": API_KEY}
def submit(url, pages=5):
r = requests.get(
f"{BASE}/ecommerce/googlemaps/leads",
params={"url": url, "pages": pages, "start_page": 1},
headers=HEADERS, timeout=30,
)
r.raise_for_status()
return r.json()["job_id"]
def collect(job_ids, poll_every=5, timeout_s=900):
"""Poll a batch of job ids; return the merged list of business rows."""
pending = set(job_ids)
rows, deadline = [], time.time() + timeout_s
while pending and time.time() < deadline:
for jid in list(pending):
s = requests.get(f"{BASE}/jobs/{jid}", headers=HEADERS, timeout=15).json()
status = s.get("status")
if status == "completed":
res = requests.get(f"{BASE}/jobs/{jid}/result",
headers=HEADERS, timeout=30).json()
rows.extend(res.get("listings", []))
pending.discard(jid)
elif status == "failed":
print(f" viewport job {jid} failed: {s.get('error')}")
pending.discard(jid)
if pending:
time.sleep(poll_every)
if pending:
print(f" {len(pending)} jobs still running at timeout — collect later")
return rows
# Submit the whole Austin grid, then poll it
job_ids = [submit(u, pages=5) for u in urls]
print(f"submitted {len(job_ids)} viewport jobs")
all_rows = collect(job_ids)
print(f"collected {len(all_rows)} raw rows (pre-dedupe)")
Submitting first and polling second is what turns a 90-viewport grid from an hour of sequential waiting into a few minutes of wall-clock time — the jobs run concurrently on the server up to your account's concurrency cap, and your script just watches the queue drain.
Step 4: Dedupe by Google's cid
A grid with overlapping tiles guarantees the same business appears in multiple viewports — that is by design, because overlap is how you avoid coverage gaps. The clean way to collapse duplicates is Google's own internal identifier, cid, which is stable per business regardless of which viewport surfaced it. Deduping on cid is more reliable than deduping on name or phone, because franchise locations share names and call centers share phone numbers, but each physical listing has its own cid.
def dedupe(rows):
seen, unique = set(), []
for r in rows:
key = r.get("cid") or r.get("phone_raw") or r.get("website")
if not key or key in seen:
continue
seen.add(key)
unique.append(r)
return unique
leads = dedupe(all_rows)
print(f"{len(leads)} unique businesses after cid dedupe")
# e.g. ~90 viewports x ~80 rows -> ~7,000 raw -> ~1,400 unique plumbers
The fallback chain (cid → phone_raw → website) covers the rare row where Google omitted the cid, so you never silently drop a real lead just because one identifier was missing.
Step 5: Understand the Enriched Fields (and What's Missing)
Be honest with yourself about where each field comes from, because it changes how you treat it. The Maps listing itself gives you the firmographic core. The website enrichment step is what adds the outreach-grade contact fields — and those only exist when the business has a reachable website.
| Field | Source | Notes |
|---|---|---|
name | Maps listing | Always present |
address | Maps listing | Full + parsed parts |
category | Maps listing | e.g. "Plumber", "HVAC contractor" |
phone / phone_raw | Maps listing | Formatted + digits-only |
website | Maps listing | Present for most contractors |
rating / reviews | Maps listing | Useful as a still-operating signal |
cid | Maps listing | Dedupe key |
emails | Website enrichment | Scraped from the business's own site; empty if no site or no public email |
socials | Website enrichment | Facebook / Instagram / LinkedIn handles found on the site |
extra_phones | Website enrichment | Numbers on the site beyond the Maps listing |
The honest caveat: Google Maps does not publish email addresses. Nothing in the Maps listing contains an email. The emails and socials fields are derived by visiting the website and reading what the contractor put on their own public site — a contact page, a footer mailto:, a "follow us" bar. That means email coverage tracks website quality: a plumber with a real site and a contact page enriches cleanly; a one-truck operator with only a Maps listing and a cell number will have an empty emails field, and no enrichment step can invent one. For home services specifically, that is fine — the phone is the actually-useful identifier, present on nearly every row, and email is the bonus you get for the ~50–70% of contractors who maintain a website.
Step 6: Write a Clean CSV for Outreach
The last step turns the deduped, enriched list into a CSV your SDRs or your CRM importer can consume directly. Flatten the list-valued fields (emails, socials) into delimited strings, drop rows with no way to reach the business, and apply one quality filter.
import csv
def write_csv(leads, out_path):
fields = ["name", "category", "phone", "website",
"email", "socials", "rating", "reviews", "address", "cid"]
written = 0
with open(out_path, "w", newline="", encoding="utf-8") as f:
w = csv.DictWriter(f, fieldnames=fields, extrasaction="ignore")
w.writeheader()
for r in leads:
phone = r.get("phone_raw") or ""
emails = r.get("emails") or []
# Reachable = has a phone OR at least one website-derived email
if len(phone) < 10 and not emails:
continue
# Drop zero-review listings: disproportionately closed/ghost rows
if (r.get("reviews") or 0) < 1:
continue
w.writerow({
"name": r.get("name", ""),
"category": r.get("category", ""),
"phone": r.get("phone", ""),
"website": r.get("website", ""),
"email": (emails[0] if emails else ""),
"socials": " | ".join(r.get("socials") or []),
"rating": r.get("rating", ""),
"reviews": r.get("reviews", ""),
"address": r.get("address", ""),
"cid": r.get("cid", ""),
})
written += 1
return written
n = write_csv(leads, "austin_plumbers_leads.csv")
print(f"wrote {n} outreach-ready rows")
Two cleanup choices earn their keep. Keeping the first email only (emails[0]) makes the CSV one-row-per-business, which is what a CRM importer expects. And the reviews >= 1 filter is the highest-leverage quality step: zero-review listings are overwhelmingly closed businesses, ghost listings, and duplicates the deduper missed, so dropping them removes a small fraction of rows but most of the bad-data complaints from your sales team. The result is a tight, deduped, metro-wide contractor list with a phone on every row and an email on the majority — ready to import.
Scaling This Across Metros and Niches
The pipeline above is one niche in one metro. The lead-gen-agency shape is usually several niches across several metros, refreshed on a cadence — plumbers, HVAC, roofers, and electricians across the top 20 US metros, re-pulled monthly to catch new businesses. Two things make that practical.
First, the grid is just data, so a multi-metro run is a nested loop over {metro_bounding_box} × {niche} feeding the same submit / collect / dedupe / write_csv functions — nothing in the pipeline changes per metro except the bounding box and the query string. Second, when the same grid runs on a schedule, what you actually care about each cycle is net-new contractors. Because every row carries a stable cid, the diff is trivial: store last cycle's cid set, re-run the grid, and surface the rows whose cid you have not seen before. If you would rather not host that cron-plus-state yourself, LogPose exposes a monitor primitive that polls a saved search on a schedule and fires an email or webhook when new cid values appear, which removes the scheduler and the state store from your build. That is the piece that turns a one-time list into a standing service-area pipeline.
The Honest Fit
This approach fits well when your target is a defined metro (or set of metros) and a small number of home-service niches, and you want a clean, deduped, contact-enriched CSV without standing up your own headless-browser fleet and proxy rotation. The async leads endpoint, the explicit viewport grid, and the cid dedupe key are the three primitives that make whole-metro coverage reliable rather than hit-or-miss.
Where it is not the right tool: if you need a single business looked up by name (use a place-detail lookup, not a grid), or if your real target is national enterprise firmographics with employee counts and revenue — Maps does not carry those, and a B2B data vendor will serve you better. And the email caveat is worth repeating honestly: enrichment reads public business websites, so coverage is strong for contractors who maintain a site and empty for those who do not. For home services, the phone carries the list and the email is upside — which is exactly the right trade for this niche.
Get Started
- Sign up at logposervices.com and generate an API key under Tool → API Keys.
export LOGPOSE_API_KEY=lp_xxxxxxx- Test one viewport, then build the grid:
curl -G "https://api.logposervices.com/api/v1/ecommerce/googlemaps/leads" \
-H "X-API-Key: lp_xxxxxxx" \
--data-urlencode "url=https://www.google.com/maps/search/plumbers/@30.2672,-97.7431,13z" \
--data-urlencode "pages=5"
Then run the viewport_grid helper over your metro's bounding box, submit one /api/v1/ecommerce/googlemaps/leads?url=...&pages=5 job per tile, dedupe by cid, and write the CSV.
Related reading: How to scrape Google Maps for local business leads for the single-viewport fundamentals, How to enrich business leads with emails, phones, and socials for the website-enrichment step in depth, and Apify Google Maps Scraper alternatives without actor maintenance for the tooling landscape.
External: Google Maps, hiQ Labs v. LinkedIn.