Google Maps Scraper & Email Extractor – Business Leads avatar

Google Maps Scraper & Email Extractor – Business Leads

Pricing

from $1.20 / 1,000 result delivereds

Go to Apify Store
Google Maps Scraper & Email Extractor – Business Leads

Google Maps Scraper & Email Extractor – Business Leads

Google Maps scraper and email extractor for lead generation. Search any category in any city and get name, address, phone, website, rating and weekly hours, plus role emails, social profiles and WhatsApp from each business's own website. Free filters, and you pay only for the places returned.

Pricing

from $1.20 / 1,000 result delivereds

Rating

0.0

(0)

Developer

Locomint

Locomint

Maintained by Community

Actor stats

0

Bookmarked

2

Total users

1

Monthly active users

a day ago

Last modified

Share

This Google Maps scraper and email extractor turns a search term and a city into a list of business records, then visits each business's own website for its role mailboxes, social profiles and WhatsApp number. One price covers all of it, and you pay only for the places returned.

{
"searchStringsArray": ["dentist"],
"locationQuery": "London, United Kingdom",
"maxCrawledPlacesPerSearch": 100
}

What it does

For every search term the actor reads the listing results for your location, 50 at a time, until it reaches your cap. A plain map search runs dry at about 100 to 120 results per area, so for bigger asks it splits the area it resolved into a grid of up to 64 cells and searches each one, removing duplicates by place_id. The sweep ends early once three cells in a row add fewer than two new places each, because by then the area is exhausted.

Results outside the resolved area are dropped. Deep result pages drift: page four of "restaurant in Lagos, Nigeria" once returned restaurants in Lagos, Portugal. The area check keeps rows like that out of your dataset.

Each place then gets two more reads, both included in the price:

  • The full listing (includeDetails): description, opening hours for all seven days, timezone, plus code, photo count with up to five photo URLs, and attributes such as delivery or dine-in.
  • The business website (includeEnrichment): the homepage, plus one hop to the contact page when the homepage is missing an email address or a WhatsApp number. From those pages it takes role mailboxes, social profile links (Facebook, Instagram, LinkedIn, X, YouTube, TikTok), a WhatsApp number from click-to-chat links, the contact page URL, the technologies the site runs on (about 35 signatures, among them WordPress, Shopify, Wix and HubSpot) and a website_status.

The five filters are free and run on the search result before either read, so a place that fails a filter is never fetched and never charged.

What each row contains

GroupColumns
Identityplace_id, cid, name, primary_category, categories, description
Locationaddress, street, city, region, postal_code, country_code, lat, lng, plus_code, timezone, service_area_business
Listing contactphone (as displayed), phone_e164, website
Reputationrating, review_count, price_level
Status and hoursstatus, open_now, hours, hours_status
Media and attributesphotos_count, photo_urls, attributes
From the websiteemails, socials, whatsapp, contact_form_url, tech_stack, website_status, enriched_at
Provenancesource, fetched_at

website_status is one of ok, unreachable (the site was down or refused the request), parked (a domain-for-sale page), redirect_social (the "website" is a Facebook or Instagram page, kept in socials) or no_website.

hours is keyed by weekday. Each day is a list of {"open": "09:00", "close": "18:00"} periods in local 24-hour time; an empty list means closed that day, null means unknown, and 00:00 to 24:00 means open all day. hours_status is ok when the full week was read, not_published when the listing gives no hours, and unavailable when the listing has hours but the read did not return the whole week.

Who it is for

  • Lead generation. Every dentist, roofer or accountant in a city, with the listing phone number, the practice's own role mailbox and its WhatsApp line in one row. Set websiteFilter to with to keep only places that have a site to read.
  • Sales prospecting. Qualify before you call: rating, review_count and open_now show whether a business is active, and tech_stack shows what its site runs on, from the CMS to the chat widget.
  • Agencies. websiteFilter: "without" lists businesses with no website at all, and website_status: "parked" finds the ones whose domain has lapsed. Both are web-design pitch lists.
  • Local SEO. Benchmark a client against every competitor in its category with rating, review_count, primary_category, photos_count and attributes side by side.
  • CRM enrichment. Paste the place IDs you already hold into placeIds and get fresh records without searching. place_id does not change between runs, so it is a safe join key.
  • Market research. Count a category across a city, map it with lat and lng, and compare rating spreads between districts or between cities.
  • AI agents and RAG. Set outputFormat to compact-for-ai and every row comes back trimmed for a context window, with a text line to put straight in a prompt and a citation for the answer. See Compact rows for AI agents.

How to use it

  1. Open the actor in Apify Console and click Try for free.
  2. Enter one or more search terms, one per line: dentist, coffee shop, car rental.
  3. Enter the location with the country: Warsaw, Poland, not Warsaw. In our own tests a bare "Warsaw" resolved to Warsaw, Indiana.
  4. Set max places per search term (up to 500) and any filters.
  5. Click Start. Rows appear in the dataset as they are finished; export them to CSV, Excel or JSON, or read them through the API.

The synchronous endpoint runs the actor and returns the rows in one request:

curl -X POST "https://api.apify.com/v2/acts/locomint~google-maps-scraper/run-sync-get-dataset-items?token=YOUR_APIFY_TOKEN" \
-H "Content-Type: application/json" \
-d '{"searchStringsArray": ["dentist"], "locationQuery": "London, United Kingdom", "maxCrawledPlacesPerSearch": 50}'

That endpoint waits at most 300 seconds, which is about 100 places with details on. For bigger runs use the Python client (pip install apify-client), which starts the run and waits for it to finish:

from apify_client import ApifyClient
client = ApifyClient("YOUR_APIFY_TOKEN")
run = client.actor("locomint/google-maps-scraper").call(run_input={
"searchStringsArray": ["dentist"],
"locationQuery": "London, United Kingdom",
"maxCrawledPlacesPerSearch": 500,
"websiteFilter": "with",
})
for place in client.dataset(run.default_dataset_id).iterate_items():
print(place["name"], place["phone_e164"], place["emails"])

Input example

{
"searchStringsArray": ["dentist", "orthodontist"],
"locationQuery": "London, United Kingdom",
"maxCrawledPlacesPerSearch": 200,
"placeMinimumStars": "4",
"websiteFilter": "with",
"skipClosedPlaces": true,
"categoryFilterWords": ["dentist", "orthodontist", "dental"],
"searchMatching": "all",
"includeNearbyPlaces": false,
"includeDetails": true,
"includeEnrichment": true,
"outputFormat": "full",
"language": "en"
}
FieldDefaultWhat it does
searchStringsArraySearch terms, each searched separately. Up to 25 per run; extra terms are ignored with a warning in the log.
locationQueryCity, area or address, with the country.
maxCrawledPlacesPerSearch20Cap per search term, 1 to 500.
placeIdsSkip the search and read these places directly: ChIJ… place IDs or 0x…:0x… feature IDs, up to 1,000. If you also give search terms, the IDs are read first.
placeMinimumStarsany"2" to "4.5". Places with no rating are left out.
websiteFilteranywith or without a website.
skipClosedPlacesfalseLeave out places marked temporarily or permanently closed.
categoryFilterWordsKeep a place only if its categories contain one of these words (case-insensitive).
searchMatchingallonly_includes: the name must contain the search term. only_exact: the name must equal it. Useful for brand searches.
includeNearbyPlacesfalseKeep results outside the resolved area. Off by default because they are usually a same-name place elsewhere.
includeDetailstrueRead the full listing. Same price either way. When off, hours is empty and hours_status reads not_published.
includeEnrichmenttrueVisit each business website. Same price either way.
outputFormatfullfull is every column. compact-for-ai trims each row to the facts a model needs and adds a text line and a citation. Same price either way.
languageenLanguage for names and categories (en, de, ar, ja, …).

Give either searchStringsArray with locationQuery, or placeIds.

Output example

A record from a restaurant run in Lisbon, Portugal, trimmed to the columns most people use first:

{
"place_id": "ChIJ…",
"name": "Belcanto",
"primary_category": "Fine dining restaurant",
"categories": ["Fine dining restaurant", "Portuguese restaurant", "Restaurant"],
"service_area_business": false,
"phone": "+351 21 342 0607",
"phone_e164": "+351213420607",
"website": "https://belcanto.pt/",
"rating": 4.6,
"review_count": 6697,
"status": "open",
"open_now": true,
"timezone": "Europe/Lisbon",
"address": "Largo de São Carlos 10, 1200-410 Lisboa, Portugal",
"city": "Lisbon",
"country_code": "PT",
"lat": 38.7099,
"lng": -9.1418,
"emails": ["press@belcanto.pt", "work@belcanto.pt"],
"socials": {
"facebook": "https://www.facebook.com/belcantolisboa",
"instagram": "https://www.instagram.com/belcantolisboa",
"linkedin": null,
"x": null,
"youtube": null,
"tiktok": null
},
"whatsapp": null,
"contact_form_url": "https://belcanto.pt/contact/",
"tech_stack": ["wordpress", "react", "google-analytics"],
"website_status": "ok"
}

Missing values are null or an empty list, never guessed. A business with no website has website_status: "no_website" and empty contact columns.

Compact rows for AI agents

Set outputFormat to compact-for-ai and each row is written for a context window instead of a spreadsheet: the facts a model needs to answer a "near me" question, the same business as one line of text, and a citation saying which place ID the facts came from, which data path answered and when it was read. The reads and the price are exactly the same as a full run; only the columns change. It is the row shape the LLM Grounding API on locomint.io returns, so a pipeline can prototype on one and run in bulk on the other.

Use it when the dataset is going into a retrieval index, a prompt or an agent's tool result. Three things make that work:

  • The text column is the prompt. Forty fields of JSON cost tokens and answer nothing; one line per business costs a fraction of that and models read it more reliably. Concatenate the column and you have the context block.
  • Every row carries its citation. An answer grounded in local data is only worth its provenance, so citation.place_id traces the claim back to the listing and citation.fetched_at says how old it is. Opening hours and closures move; a model quoting them should be able to say when they were read.
  • null is not an empty list. emails: [] means the website was read and publishes no business mailbox. null means nothing was read: there is no website, includeEnrichment was off, or the site did not answer. Reporting [] in that case would state a fact nobody checked, and a model would repeat it. A compact row leaves website_status out, so null is the only thing left to tell you the difference — use the full format when you need to know which of the three it was.

One row from a restaurant run in Lisbon, Portugal:

{
"place_id": "ChIJ…",
"cid": "11928394857463728190",
"name": "Belcanto",
"categories": ["Fine dining restaurant", "Portuguese restaurant"],
"address": "Largo de São Carlos 10, 1200-410 Lisboa, Portugal",
"city": "Lisbon",
"country_code": "PT",
"phone": "+351213420607",
"website": "https://belcanto.pt/",
"rating": 4.6,
"review_count": 6697,
"status": "open",
"open_now": true,
"hours_today": "12:30-15:00, 19:00-23:00",
"emails": ["press@belcanto.pt", "work@belcanto.pt"],
"whatsapp": null,
"socials": {
"facebook": "https://www.facebook.com/belcantolisboa",
"instagram": "https://www.instagram.com/belcantolisboa"
},
"contact_form_url": "https://belcanto.pt/contact/",
"citation": {
"place_id": "ChIJ…",
"source": "maps_public",
"fetched_at": "2026-09-12T09:14:02.918431+00:00"
},
"text": "Belcanto | Fine dining restaurant | 4.6/5 (6697 reviews) | Largo de São Carlos 10, 1200-410 Lisboa, Portugal | +351213420607 | https://belcanto.pt/ | today 12:30-15:00, 19:00-23:00 | press@belcanto.pt"
}

hours_today needs the listing read, so leave includeDetails on. Both ids stay on the row: place_id and cid are the join keys back to your own records, and a spreadsheet column cannot reach inside citation. Social profiles that belong to a person rather than to the business are dropped here as everywhere else, so a compact row can never put someone's personal profile into a prompt. The dataset has a Compact for AI tab that shows these columns; the other tabs stay for full runs.

Pricing

This actor is pay-per-event. You pay for two events and nothing else; Apify platform usage and proxy traffic are not billed to you separately.

EventPriceCharged
Place delivered$0.0015 ($1.50 per 1,000) until 26 September 2026, then $0.0030 ($3.00 per 1,000)once per row in the dataset
Actor start$0.00005 per GB of run memoryonce per run ($0.00005 at 1 GB)

Worked example. 1,000 dentists in London: 1,000 x $0.0015 = $1.50, plus $0.00005 for the start. From 26 September 2026 the same run is $3.00.

What you are not charged for:

  • Filtered-out places. With search terms, the filters run on the search result before the listing and the website are read. Either way, a place that fails a filter is never written to the dataset.
  • Duplicates. A place found by two search terms in the same run is delivered once.
  • Empty searches. A term that finds nothing costs nothing beyond the start event.

Set a maximum cost per run in the run options and the actor stops before the next row would pass it. Apify's free plan includes $5 of usage a month with no card.

FAQ

How many places does one search term return? Up to maxCrawledPlacesPerSearch, which goes to 500. A plain map search stops at about 100 to 120 results per area, so past that the actor sweeps a grid over the city. Big categories in big cities reach the cap; a niche category returns what exists.

Why do some places have no email? The business has no website, the site did not answer, or it publishes no role mailbox on its homepage or contact page. A 403 or a challenge page counts as a refusal, and the actor does not retry it through another IP. Many small businesses offer a contact form instead, which contact_form_url captures, and mailboxes that name a person are dropped on purpose.

Does it include reviews? No. Every row carries rating and review_count, but not review text. The reviews_summary column exists for compatibility and is empty in this actor.

Are opening hours for the whole week? Yes, when the listing publishes them and includeDetails is on: hours carries all seven days and hours_status is ok. The source sometimes answers with today's hours only; the actor then reads the listing once more, and a row that still lacks the full week says hours_status: "unavailable" so you know to re-read that place.

How fresh is the data? Every run reads the listings and the websites live, at the time of the run; nothing comes from a stored database. Each row carries fetched_at for the listing read and enriched_at for the website read.

Which countries does it cover? Any country with map listings. There is no country targeting: the location you type decides where the search runs, so always include the country. The same search code is checked every day in 30 cities across 28 countries.

Is it legal to scrape Google Maps? The actor reads publicly available listing pages and business websites while logged out. It never signs in, never solves a CAPTCHA, and backs off when a site refuses it rather than retrying through another IP. Whether a particular use is lawful depends on your jurisdiction and on what you do with the data, and that part is your responsibility.

Can I give it place IDs instead of searching? Yes. Put up to 1,000 ChIJ… or 0x…:0x… IDs in placeIds and the actor reads those places directly. If you have place-page links rather than IDs, the Google Maps Place Details Scraper below accepts those too.

Limits

  • Speed. Listing reads are paced at about 20 a minute, so a run with details on delivers roughly 20 places a minute and 1,000 places take close to an hour. With includeDetails off, one search call returns up to 50 places and the website visits become the slow part.
  • Run size. 25 search terms per run, 500 places per term, 1,000 place IDs.
  • Reviews. No review text. reviews_summary, snapshot_id and first_seen_at are always empty in actor runs.
  • Websites. Pages are read as served, without running JavaScript, so a contact page that only appears after scripts load is missed. The actor reads the homepage and at most one contact page. A site that takes longer than 20 seconds is abandoned: that row keeps its website but shows website_status: "unreachable" with enriched_at: null, meaning the site was not read. In a compact row the same place has emails, socials, whatsapp and contact_form_url set to null rather than empty, for the same reason.
  • Emails. Role mailboxes only, such as info@, sales@ and bookings@. An address on another domain, a Gmail account for example, is kept only when its name is a business function.
  • Phones. phone and phone_e164 come from the listing, not from the website. phone_e164 is empty when only a national number was offered and the calling code cannot be settled safely (+1 and +7 are each shared by several countries).
  • Fields not yet seen filled. price_level has not been populated in any record we have captured, and closed markers have not appeared in our captures either, so skipClosedPlaces has not yet been tested against a real closed listing.
  • Addresses. Service-area businesses with no storefront have an empty address and service_area_business: true; lat and lng are still set. street, region and postal_code are filled only when the listing's structured address carries them. Australian localities come through with the state attached ("Melbourne VIC").

Compliance

Business contact points only: role mailboxes published on the business's own website. Mailboxes that name a person are dropped and owner names are never collected. You are responsible for using the data lawfully (for example GDPR, CAN-SPAM and PECR for outreach).

Questions, bug reports and feature requests go on this actor's Issues tab. Business owners can send data-removal requests to info@locomint.io; that address is for removal requests only. This actor keeps no copy of what it reads between runs, so each run returns what the public pages show at that moment.

Not affiliated with or endorsed by Google.

Other Locomint actors