← Back to blogTutorial

How to Build a Service-Area Lead List from Google Maps

· 11 min read

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 (12z14z), 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 (cidphone_rawwebsite) 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.

FieldSourceNotes
nameMaps listingAlways present
addressMaps listingFull + parsed parts
categoryMaps listinge.g. "Plumber", "HVAC contractor"
phone / phone_rawMaps listingFormatted + digits-only
websiteMaps listingPresent for most contractors
rating / reviewsMaps listingUseful as a still-operating signal
cidMaps listingDedupe key
emailsWebsite enrichmentScraped from the business's own site; empty if no site or no public email
socialsWebsite enrichmentFacebook / Instagram / LinkedIn handles found on the site
extra_phonesWebsite enrichmentNumbers 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

  1. Sign up at logposervices.com and generate an API key under Tool → API Keys.
  2. export LOGPOSE_API_KEY=lp_xxxxxxx
  3. 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.

Frequently asked questions

Is it legal to scrape Google Maps for business leads?
Google Maps business listings are public data — name, address, phone, website, category, and hours are displayed without authentication to anyone who opens the page. Scraping public web data is not a CFAA violation in the United States, per hiQ Labs v. LinkedIn (9th Cir. 2022), and EU/UK precedent treats public B2B contact information as collectible under a legitimate-interest basis. What Google's Terms of Service forbid is hitting the underlying Google Places API without a key and republishing the dataset as a competing product — neither of which describes pulling contractor phone numbers and websites into your own CRM. The contact enrichment in this pipeline reads each business's own public website, which is the same data a human visitor sees. The genuinely regulated step is downstream: cold-call and cold-email rules (TCPA, CAN-SPAM, CASL, GDPR) govern how you contact the leads, not how you collected them, and that is where the real compliance work lives.
How do you cover a whole city when Maps caps results per search?
A single Google Maps search URL is anchored on one `@lat,lng,zoom` viewport, and Google caps that viewport at roughly 120 results before it starts padding with low-relevance fillers from neighboring areas. You cannot beat that cap by asking for more pages — the density is enforced at the source. The fix is geographic: slice the metro into a grid of overlapping viewport centroids, run one scrape per centroid, then merge everything and dedupe by Google's `cid` identifier so the same business pulled from two adjacent tiles collapses into one row. A medium metro is usually 9 to 25 viewport tiles at `13z`; a large one with sprawling suburbs can be 40 or more. The grid is the whole trick — once you are deduping on `cid`, adding tiles only ever increases unique coverage, never double-counts.

Related posts

Comparison

Outscraper Alternatives for Google Maps Reviews

10 min read
Comparison

Apify Google Maps Scraper Alternatives Without Actor Maintenance

10 min read
Tutorial

How to Build a VC Deal-Flow List from Crunchbase

10 min read