# Google Maps Scraper — Leads, Emails, Phone Numbers & Contacts (`kestrel/google-maps-scraper`) Actor

Search Google Maps for local businesses by keyword and location. One row per place: name, category, address, phone numbers, website, ratings, opening hours, place ID. Optional website contacts: emails, social media, WhatsApp. Filter leads free: no website, low rated, unclaimed, new businesses.

- **URL**: https://apify.com/kestrel/google-maps-scraper.md
- **Developed by:** [Tedj MEABIOU](https://apify.com/kestrel) (community)
- **Categories:** Lead generation, Travel, Automation
- **Stats:** 9 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

Pay per event

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Google Maps Scraper — Leads, Emails, Phone Numbers & Contacts

A Google Maps scraper built for lead lists. Give it search terms and locations and it returns one flat row per business — name, category, full address in parts, coordinates, phone, website, rating, review count, reviews per star, opening hours, claimed and closed status — and, if you ask, the emails and social links found on each business's own website. Use it as a Google Maps extractor for plain Google Maps data, as a source of Google Maps leads and local business leads, or as a Google Maps email extractor when you need Google Maps emails for outreach. It is priced the way reviewers of other actors keep asking for:

- **$0.003 per place, details included.** No add-on for details, no charge per filter, no start fee.
- **$0.002 per contact — only when one is found.** A place with no website, an unreachable website or a website with nothing on it costs $0 extra.
- **Every filter is free and runs before billing.** A place your filters reject is never charged. The status row tells you how many each filter removed.
- **Charged equals delivered.** Rows are charged first and only the charged rows are written, so an aborted run or a spending limit never leaves you paying for something you did not get.

**Last verified working: 2026-09-21.**

### Google Maps scraper for AI agents: which input answers which request?

Built to be called by an AI agent (Claude, ChatGPT, or any MCP client through the Apify MCP server) as well as by people. One call, one flat table back, no API key for Google, no login, and the cost is known before the run: `maxPlacesPerSearch` × $0.003, plus $0.002 for each place where a contact is actually found.

Use this actor when the task is to **find local businesses** (any category, any country) and get their **phone numbers, websites, addresses, ratings, opening hours, emails or social media contacts**. Do not use it for review text, photos or popular times — it does not return them.

| The user asks for | Input |
| --- | --- |
| "dentists in Austin with phone numbers" | `{"searches": ["dentist"], "locations": ["Austin, TX"], "maxPlacesPerSearch": 50}` |
| "…with emails" / "contact details" / "social media" | add `"enrichContacts": true` |
| "only businesses that have an email" | add `"onlyWithEmail": true` |
| "businesses without a website" | add `"hasWebsite": "no", "hasPhone": "yes"` |
| "unclaimed" Google Business Profiles | add `"isClaimed": "no"` |
| "low rated" / "bad reviews" businesses | add `"maxRating": 3.9, "minReviews": 20` |
| "top rated" / "established" businesses | add `"minRating": 4.5, "minReviews": 100` |
| "new businesses since last week" (scheduled) | add `"onlyNew": true, "monitorKey": "my-list"` |
| "every restaurant in the city", more than ~200 places | add `"fullCoverage": true` and raise `maxPlacesPerSearch` |
| "details / phone / email for this place" (URL, place ID or CID known) | `{"startUrls": ["ChIJ…"], "enrichContacts": true}` |
| several categories × several cities | `"searches": ["plumber", "electrician"], "locations": ["Tulsa, OK", "Norman, OK"]` |

What comes back: rows with `type: "place"` (the data — `name`, `category`, `phone`, `phone_international`, `website`, `address`, `city`, `postal_code`, `country_code`, `lat`, `lng`, `rating`, `review_count`, `opening_hours`, `place_id`, `google_maps_url`, `lead_flags`, and with enrichment `email_primary`, `emails`, `facebook`, `instagram`, `linkedin`, `whatsapp`) and one free `type: "status"` row per search that says how many places were found, filtered, delivered and charged — read it to explain a short or empty result to the user instead of guessing. An unknown location or an empty search is a successful run that costs $0.

### Google Maps scraper output: what is in a row?

One row per place, always the same columns in the same order, so CSV and Excel exports are never ragged. Fill rates were measured on 870 places from 12 cities in 6 countries on 2026-09-21.

| Field | What it is | Filled |
| --- | --- | --- |
| `place_id`, `cid`, `feature_id`, `google_maps_url` | The stable place id (`ChIJ…`), the CID from `?cid=` links, the feature id, and a link that opens the place | 100% |
| `name`, `category`, `categories` | Business name, primary category and every category, in the language you ask for | 100% |
| `search_term`, `search_location`, `rank` | Which search returned the place and where it stood in the list | 100% |
| `address`, `street`, `city`, `state`, `postal_code`, `country_code`, `neighborhood` | The one-line address and its parts. `state` and `neighborhood` exist only where the country uses them | 100 / 99.8 / 91 / 66 / 91 / 100 / 76% |
| `lat`, `lng`, `plus_code`, `distance_km_from_center`, `timezone` | Coordinates, the global plus code, kilometres from the centre of your location or area, IANA timezone | 100% |
| `phone`, `phone_international` | Local format and `+1 512-…` format | 95% |
| `website`, `domain` | The website on the listing and its host | 83% |
| `rating`, `review_count`, `reviews_per_star` | Stars, number of reviews, and the 1★–5★ histogram | 100% |
| `is_closed_permanently`, `is_closed_temporarily`, `is_claimed` | Closed flags, and whether the listing has an owner (`false` = "Claim this business") | 100% |
| `opening_hours`, `open_now` | `{"monday": "8 AM–5 PM", …}` and whether it was open when fetched | 99% |
| `description`, `attributes` | The short description and the attributes listed as present (accessibility, amenities, service options) | 31% / 86% |
| `booking_url`, `menu_url`, `image_url`, `photo_count`, `price_level` | Reservation link, menu link, cover photo, photo count. `price_level` and `menu_url` are in the schema but the search answer did not carry them in any of the 870 places — expect null | 24 / 0 / 99.8 / 99 / 0% |
| `has_website`, `has_email`, `lead_flags` | Free lead helpers: `lead_flags` is any of `no_website`, `low_rating` (< 4.0), `few_reviews` (< 10), `unclaimed` | 100% |
| `fetched_at` | UTC timestamp | 100% |

With `enrichContacts` on, every place row also carries: `emails` (each with `type` — `named` or `role` such as info@ — and the `source_url` it was read from), `email_primary`, `phones_from_site`, `contact_page_url`, `facebook`, `instagram`, `linkedin`, `x`, `youtube`, `tiktok`, `whatsapp` and `enrichment_status`.

A second row type, `status`, is written once per search and is **always free**: `search_term`, `search_location`, `target`, `status` (`ok` / `no_results` / `unknown_location` / `error`), `requested`, `found`, `filtered`, `filtered_by` (how many each filter removed), `duplicates`, `already_seen`, `delivered`, `charged`, `contacts_found`, `pages`, `grid_cells`, `coverage_note`, `note` and `error`. `delivered` always equals `charged`.

### Google Maps leads by city: how do I get them?

Cross `searches` with `locations`. Each term is searched in every location; `maxPlacesPerSearch` caps what is delivered (and charged) per pair and `maxPlacesTotal` caps the whole run.

```json
{
  "searches": ["dentist", "orthodontist"],
  "locations": ["Austin, TX", "Round Rock, TX"],
  "maxPlacesPerSearch": 100,
  "enrichContacts": true
}
```

A place that turns up under two searches is delivered and billed once — de-duplication is by place id across every search and grid cell in the run. A full phrase that already says where, such as `"pubs near Prague 2"`, works as a search term on its own; leave `locations` empty.

`language` sets the language of categories, hours and descriptions (`en`, `de`, `fr`, `pt-BR`, `ja` …) and `country` the country to assume for ambiguous names (`us`, `de`, `jp` …). The release test covers the United States, Germany, France, Brazil, India, Japan and the UAE.

### Google Maps emails: how does contact enrichment work, and what does it find?

Turn on `enrichContacts`. For each place with a website the actor reads the home page and then the most promising contact, about, team and imprint pages — up to `maxPagesPerSite` (default 5), because the address is usually not on the home page. It collects emails (including Cloudflare-protected and `name [at] domain` ones), `tel:` numbers, Facebook, Instagram, LinkedIn, X, YouTube, TikTok and WhatsApp links.

Measured on 200 places sampled across the 12 test cities on 2026-09-21:

| Outcome | Places | Charged |
| --- | --- | --- |
| Contact found (email or social link) | 102 (51%) | $0.002 each |
| … of which with at least one email | 67 (34% of all places, 41% of places that have a website) | |
| Website read, nothing on it | 21 | $0 |
| Website unreachable or refusing crawlers | 25 | $0 |
| The "website" is itself a social profile | 16 | $0 |
| No website on the listing | 36 | $0 |

So expect an email for roughly one place in three, not for every place — and pay for the ones you get. An address on somebody else's domain that is not even a `mailto:` link (the web agency's, a booking widget's, the template's) is left out rather than passed off as the business's email. Emails are not verified over SMTP.

The crawler identifies itself, honours `robots.txt`, reads at most one page per second per site, gives each page a 10-second timeout and reads HTML only.

`onlyWithEmail` delivers — and charges — only the places where an email was found. The run reads further down the result list to fill your limit.

### Scrape Google Maps for businesses without a website

The prospecting list web and marketing agencies ask for: places with a phone but no website.

```json
{
  "searches": ["plumber", "electrician", "roofer"],
  "locations": ["Tulsa, OK"],
  "hasWebsite": "no",
  "hasPhone": "yes",
  "skipClosed": true,
  "maxPlacesPerSearch": 50
}
```

`hasWebsite`, `hasPhone` and `isClaimed` take `any`, `yes` or `no` (`"isClaimed": "no"` keeps only listings Google still offers to "Claim this business"). The places that do have a website are counted under `filtered_by.has_website` in the status row and cost nothing.

### Google Maps data for reputation work: how do I find low rated businesses?

```json
{
  "searches": ["restaurant"],
  "locations": ["Lyon"],
  "language": "fr",
  "country": "fr",
  "maxRating": 3.9,
  "minReviews": 20,
  "maxPlacesPerSearch": 50
}
```

`minRating` / `maxRating` filter on stars and `minReviews` / `maxReviews` on the review count (0 = no limit). A place whose rating or count is unknown never passes a filter that needs it: nobody should pay for a row that cannot be shown to match. The Google Maps review count is on every row, `reviews_per_star` gives the 1★–5★ split, and `lead_flags` marks `low_rating`, `few_reviews` and `unclaimed` on every row for free.

More filters, all free: `categoriesInclude` / `categoriesExclude` match words against the place's own categories (the fix for "two thirds of my leads were off-industry and I was still charged"); `nameContains` / `nameExcludes` match the name; `excludePlaceIds` skips ids you already have and `excludeDatasetId` skips every `place_id` in an earlier dataset of this actor.

### How do I monitor for new businesses on Google Maps?

```json
{
  "searches": ["bakery"],
  "locations": ["Boulder, CO"],
  "maxPlacesPerSearch": 200,
  "onlyNew": true,
  "monitorKey": "boulder-bakeries"
}
```

With `onlyNew`, a run delivers and charges only places that earlier runs with the same `monitorKey` have not delivered; everything else is counted under `already_seen`. Schedule it weekly and each run is the delta. Only delivered places are remembered, so a place a spending limit cut off comes back next time. Use one `monitorKey` per list.

One honest caveat. Maps re-ranks and rotates the tail of every result list between requests: asked six times in a row, "bakery Boulder, CO" returned 111–129 places each time, 138 distinct, 90 in all six. The actor asks an exhausted list twice and merges the answers, which covers 93–99% of the union, but a repeat run can still surface a few places the previous run was never shown. They are new to you and are delivered as new; nothing is ever delivered twice.

### How do I use my own Google Maps URLs, place ids or CIDs?

Paste them into `startUrls`: search URLs (`google.com/maps/search/…`, read from the map position in the URL), place URLs (`google.com/maps/place/…`), short links (`maps.app.goo.gl/…`), a Google Maps place id (`ChIJ…`), a feature id or a CID.

```json
{
  "startUrls": [
    "ChIJb5gBC-u0RIYRHkntaUcdJGs",
    "https://maps.google.com/?cid=7720327853776914718",
    "https://www.google.com/maps/search/ramen/@35.658,139.7016,15z"
  ],
  "maxPlacesPerSearch": 40
}
```

A place gives one full row. The first two lines above are the same business pasted two ways — it is delivered once. An id Maps does not know is reported in a free status row.

If you are moving from another Google Maps actor, a saved input pastes unchanged: `searchStringsArray`, `locationQuery`, `maxCrawledPlacesPerSearch` and `countryCode` are accepted as aliases of `searches`, `locations`, `maxPlacesPerSearch` and `country`.

### Searching an area: circle, bounding box or polygon

Instead of `locations`, give `latitude` and `longitude` with `radiusKm`, or a `boundingBox`, or a `polygon`:

```json
{
  "searches": ["zahnarzt"],
  "latitude": "52.52",
  "longitude": "13.405",
  "radiusKm": 2,
  "withinRadiusOnly": true,
  "language": "de",
  "country": "de"
}
```

Maps ranks by prominence as well as distance, so a well-known place slightly outside your circle can appear. `withinRadiusOnly` drops anything outside the circle, box or polygon **before billing** — the answer to "results far outside my radius". `distance_km_from_center` is on every row either way. A `boundingBox` is `{"south": …, "west": …, "north": …, "east": …}`; a `polygon` is a list of `[latitude, longitude]` points.

### FAQ: how many places can one search return, and what is full coverage?

A single Maps query lists a limited number of places and then stops — in our measurements between about 120 and 220, depending on the query. When that happens and you asked for more, the status row carries a `coverage_note` saying how many were listed.

`fullCoverage` goes past it: the area is split into a grid, every cell is searched separately from the centre outwards, and the results are merged. It works with `locations` (the area is looked up) or with a circle, box or polygon. `gridCellKm` sets the cell size (0 = automatic) and `maxGridCells` bounds the number of cells (default 64). In the release test a 3 km circle in central Austin returned 260 places for "cafe" through 9 cells, where single queries in the same city stopped between 203 and 222. Every place is still delivered and billed once, however many cells it appears in.

#### What happens with a small town, or a location that does not exist?

Every location is looked up first. If Maps does not know it, that search ends with `status: unknown_location`, a note, and **no charge** — and if it was the only search, the run still ends as a success with the explanation on the run card. This matters more than it sounds: asked for "plumber Zzqqxwv Nowhereville", Maps answers with twenty real plumbers near whatever IP asked, as if nothing were wrong. The actor never passes those off as your town.

A real small town never comes back as a silent zero either. If the typed query returns nothing, or returns places centred far from the looked-up town, the actor searches again around the town's centre and says so in the `note`.

#### What if Google throttles or blocks a request?

A consent page, a captcha or an empty shell is never read as "no more places". The request is retried on a fresh proxy session; if the search still fails it becomes an `error` row, not an empty success. There is one more thing the actor handles quietly: about half of all answers arrive with every review count stripped. Those are asked again, so `review_count` is filled on 100% of rows in the release test rather than on half of them.

#### Does it work outside the United States?

Yes, by default. The release gate runs 12 cities in the US, Germany, France, Brazil, India, Japan and the UAE and requires at least 60 places from 4 pages in each; on 2026-09-21 every city returned between 67 and 77.

#### Is the same search the same twice?

Mostly, and the README will not pretend otherwise: Maps re-ranks per request, so two identical runs capped at 80 places shared 68–77 of them in our tests. Set the cap above what Maps lists, or use `fullCoverage`, when you need the complete set rather than the top of it.

#### Is it legal to scrape Google Maps?

The actor reads publicly visible business listings — the same information anyone sees on the map without logging in — and the public pages of business websites, with `robots.txt` honoured. Business contact details are generally not personal data, but some listings belong to sole traders, and emails can be personal data under the GDPR and similar laws. You are responsible for having a lawful basis for how you use what you collect, for honouring opt-outs, and for following the anti-spam rules where your recipients live. If in doubt, ask a lawyer.

### Pricing: what does a run cost?

| Event | Price | When |
| --- | --- | --- |
| `place` | $0.003 | One place row delivered, all details included |
| `contact-found` | $0.002 | Only when enrichment is on **and** an email or social link was read off that place's own website |
| Filters, status rows, duplicates, already-seen places, failed or empty searches, unknown locations, start | $0 | Always |

Worked example: 1,000 places with enrichment on, 380 contacts found → 1,000 × $0.003 + 380 × $0.002 = **$3.76**. Without enrichment the same list is $3.00.

Your spending limit is honoured to the row: when it is reached the run stops charging and stops delivering at the same moment, and a contact the limit refused is blanked rather than handed over unpaid. Aborting a run stops billing immediately, because nothing is charged until the moment it is written. There are no hidden free-tier caps.

### Speed and proxy settings

Apify datacenter proxies work for Maps and are the default in `proxyConfiguration`; residential is not needed. `sessions` is the number of proxy sessions used in parallel (default 6) and `perIp` the requests per second per session (default 1). Measured on 2026-09-21: the 20-place example takes about 20 seconds, 260 places through a 9-cell grid about a minute, and 40 places with enrichment under two minutes — reading websites politely is the slow part. A filter that very few places pass (say, restaurants rated under 4.0) makes the run read the whole result list, which takes a couple of minutes per search.

### Google Maps API alternative: run it from Python, JavaScript or curl

Python:

```python
from apify_client import ApifyClient

client = ApifyClient("<APIFY_TOKEN>")
run = client.actor("kestrel/google-maps-scraper").call(run_input={
    "searches": ["coffee shop"], "locations": ["Austin, TX"], "maxPlacesPerSearch": 50, "enrichContacts": True})
for row in client.dataset(run["defaultDatasetId"]).iterate_items():
    if row["type"] == "place": print(row["name"], row["phone"], row["email_primary"])
```

JavaScript (Node):

```javascript
import { ApifyClient } from 'apify-client';

const client = new ApifyClient({ token: '<APIFY_TOKEN>' });
const run = await client.actor('kestrel/google-maps-scraper').call({
  searches: ['coffee shop'], locations: ['Austin, TX'], maxPlacesPerSearch: 50, hasWebsite: 'no' });
const { items } = await client.dataset(run.defaultDatasetId).listItems();
console.log(items.filter((r) => r.type === 'place').map((r) => [r.name, r.phone]));
```

curl, synchronous, straight to CSV:

```bash
curl -X POST "https://api.apify.com/v2/acts/kestrel~google-maps-scraper/run-sync-get-dataset-items?token=<APIFY_TOKEN>&format=csv" \
  -H "Content-Type: application/json" \
  -d '{"searches":["coffee shop"],"locations":["Austin, TX"],"maxPlacesPerSearch":20}'
```

That last call is also the shortest route from Google Maps to CSV. Compared with the official Places API there is no per-field billing, no 60-result ceiling per query, and the emails and social links are not something that API offers at all — which is why people look for a Google Places API alternative for lead generation in the first place.

### Google Maps lead generation in n8n, Make and AI agents

- **n8n** — use the Apify node (or an HTTP Request node with the curl call above): *Run actor and get dataset* → filter `type = place` → your CRM, Google Sheets or an email tool. With `onlyNew` and a schedule trigger, each execution passes on only the new leads. People searching for a Google Maps scraper n8n workflow usually want exactly this loop.
- **Make** — the Apify app's *Run an Actor* module followed by *Get Dataset Items*; map `name`, `phone_international`, `email_primary` and `lead_flags` into the next module.
- **AI agents (MCP)** — through the Apify MCP server an agent can call this actor as a tool: "find 30 dentists in Austin without a website" becomes one run with `hasWebsite: "no"`. The flat row and the `lead_flags` column are there so a model does not have to interpret nested data.
- **Zapier, Google Sheets, webhooks** — any Apify integration works; the dataset exports as JSON, CSV, Excel or an RSS feed.

### What this Google places scraper does not do

Said plainly, so nobody finds out from a bill:

- **Review text.** This actor returns the rating, the review count and the per-star histogram, not the reviews themselves.
- **Photos and popular times.** One cover image URL and the photo count only.
- **Email verification.** Addresses are read off websites; they are not checked over SMTP.
- **Person-level leads.** No names and job titles from bought databases — that data is the source of the worst reviews in this category. The Google Maps business data here is what the business itself publishes.
- **Google Maps phone numbers for places that list none.** About 5% of places have no phone on their listing; `phones_from_site` sometimes fills the gap when enrichment is on.

### Support

Open an issue on the actor's Issues tab with the run id and what you expected; issues are answered personally and quickly, and a field you need is usually a small change. If a run fails because of the source rather than your input, it fails loudly on purpose — a daily canary runs this actor and checks both the places and the enrichment.

# Changelog

This Actor's version history is a separate document: https://apify.com/kestrel/google-maps-scraper/changelog.md

# Actor input Schema

## `searches` (type: `array`):

Business type or keyword, one per entry, exactly as typed into Google Maps: \["dentist"] or \["coffee shop", "bakery"]. Every term is searched in every entry of `locations`, so do not repeat the city here. A full phrase that already says where ("pubs near Prague 2") also works with `locations` left empty.

## `locations` (type: `array`):

Where to search, as free text, one per entry: \["Austin, TX"], \["Lyon, France"], \["Shibuya, Tokyo"], a ZIP code or a neighbourhood. Add the state or country when a name is ambiguous. Each location is verified on Google Maps first: an unknown location ends that search with an explanation and $0 charged, never with places from somewhere else.

## `maxPlacesPerSearch` (type: `integer`):

Maximum places delivered AND charged per search term per location, counted after filters. Cost ceiling = this number × $0.003. One Google Maps query lists about 120–220 places; set `fullCoverage` to true to go beyond that.

## `enrichContacts` (type: `boolean`):

Set true to get emails and social links. For each place with a website, the home page plus contact, about, team and imprint pages are read for: emails (`emails`, `email_primary`), phone numbers, Facebook, Instagram, LinkedIn, X, YouTube, TikTok and WhatsApp. Adds $0.002 only for places where something was found (about 1 in 2; an email for about 1 in 3). Off = places only.

## `onlyWithEmail` (type: `boolean`):

Set true to deliver and charge ONLY places where an email address was found. Turns `enrichContacts` on and reads further down Google's list to fill `maxPlacesPerSearch`. Use for "give me N businesses with emails".

## `hasWebsite` (type: `string`):

"any" (default), "yes" = only places that list a website, "no" = only places WITHOUT a website (prospects for web design and marketing agencies). Free; filtered places are not charged.

## `hasPhone` (type: `string`):

"any" (default), "yes" = only places with a phone number, "no" = only places without one. Free; filtered places are not charged.

## `isClaimed` (type: `string`):

"any" (default), "no" = only UNCLAIMED listings (Google still shows "Claim this business": owners who have not set up their profile — prospects for local SEO and profile management), "yes" = only claimed listings. Free; filtered places are not charged.

## `minRating` (type: `number`):

Keep places rated at least this many stars (1–5). 0 = no limit. Places with no rating do not pass. Free filter.

## `maxRating` (type: `number`):

Keep places rated at most this many stars, e.g. 3.9 to find low rated businesses for reputation services. 0 = no limit. Places with no rating do not pass. Free filter.

## `minReviews` (type: `integer`):

Keep places with at least this many Google reviews (established businesses). 0 = no limit. Free filter.

## `maxReviews` (type: `integer`):

Keep places with at most this many Google reviews (new or under-marketed businesses). 0 = no limit. Free filter.

## `categoriesInclude` (type: `array`):

Keep a place only if one of its Google Maps categories contains one of these words, case-insensitive: \["dentist", "dental"]. Removes off-topic results before they are charged.

## `skipClosed` (type: `boolean`):

Set true to drop permanently and temporarily closed places before billing.

## `onlyNew` (type: `boolean`):

Monitoring: set true to deliver and charge only places that earlier runs with the same `monitorKey` have not delivered — i.e. new businesses since last time. Run it on a schedule; each run returns only the delta.

## `monitorKey` (type: `string`):

Name of the memory `onlyNew` compares against. Use one key per list you maintain, e.g. "austin-dentists". Letters, digits, dash, underscore.

## `startUrls` (type: `array`):

Known places or saved searches instead of (or besides) `searches`, e.g. \["ChIJb5gBC-u0RIYRHkntaUcdJGs", "https://maps.google.com/?cid=7720327853776914718"]. Accepts Google Maps place URLs, search URLs (google.com/maps/search/…), short links (maps.app.goo.gl/…), place IDs (ChIJ…) and CIDs. A place returns one full row: use it to look up the phone, website or emails of specific businesses.

## `language` (type: `string`):

Language code for category names, opening hours and descriptions: en, de, fr, es, pt-BR, ja, ar … Default en.

## `country` (type: `string`):

Two-letter country code Google should assume for ambiguous names and phone formats: us, gb, de, fr, br, in, jp, ae … Default us.

## `fullCoverage` (type: `boolean`):

Set true to collect MORE than the ~120–220 places one Google Maps query lists: the area is split into a grid and every cell is searched. Works with `locations` or with latitude/longitude/radiusKm, `boundingBox` or `polygon`. Each place is still delivered and charged once.

## `maxPlacesTotal` (type: `integer`):

Hard cap across every search in the run. 0 = no cap beyond the per-search limit and your spending limit.

## `latitude` (type: `string`):

Centre of a circular search area, for example 30.2672. Use with Longitude and Radius instead of Locations.

## `longitude` (type: `string`):

Centre of a circular search area, for example -97.7431.

## `radiusKm` (type: `number`):

Radius around the centre, 0.2–200 km. Google ranks by prominence as well as distance, so turn on "Only inside my area" to drop anything beyond this radius before billing.

## `withinRadiusOnly` (type: `boolean`):

Drop places outside the circle, box or polygon before billing. Without it Google may include prominent places a little beyond the area.

## `boundingBox` (type: `object`):

A rectangle as {"south": 30.20, "west": -97.80, "north": 30.30, "east": -97.70}.

## `polygon` (type: `array`):

A custom shape as a list of \[latitude, longitude] points, for example \[\[30.20,-97.80],\[30.30,-97.80],\[30.25,-97.70]]. (A GeoJSON Polygon object is accepted through the API.)

## `gridCellKm` (type: `number`):

Side of one grid cell in km (0.5–50). 0 = size cells automatically so the area fits in Max grid cells. Smaller cells find more places in dense cities and take longer.

## `maxGridCells` (type: `integer`):

Upper bound on cells per search; cells are searched from the centre outwards.

## `categoriesExclude` (type: `array`):

Drop a place if any of its Google Maps categories contains one of these words: \["supplier", "school"].

## `nameContains` (type: `array`):

Keep a place only if its name contains one of these words.

## `nameExcludes` (type: `array`):

Drop a place if its name contains any of these words — for example chain names.

## `excludePlaceIds` (type: `array`):

Place ids (ChIJ…), CIDs or feature ids you already have. They are skipped before billing.

## `excludeDatasetId` (type: `string`):

Id of an earlier dataset of this actor: every place\_id in it is skipped before billing.

## `maxPagesPerSite` (type: `integer`):

Home page plus the most promising contact, about, team and imprint pages, up to this many.

## `sessions` (type: `integer`):

Proxy sessions (IPs) used in parallel.

## `perIp` (type: `number`):

Pace for each session.

## `proxyConfiguration` (type: `object`):

Apify datacenter proxies work for Google Maps and are the default; residential is not needed.

## `searchStringsArray` (type: `array`):

Same as Search terms. Accepted so an input saved for another Google Maps actor can be pasted unchanged.

## `locationQuery` (type: `string`):

Same as one entry in Locations.

## `maxCrawledPlacesPerSearch` (type: `integer`):

Same as Max places per search.

## `countryCode` (type: `string`):

Same as Country.

## Actor input object example

```json
{
  "searches": [
    "coffee shop"
  ],
  "locations": [
    "Austin, TX"
  ],
  "maxPlacesPerSearch": 20,
  "enrichContacts": false,
  "onlyWithEmail": false,
  "hasWebsite": "any",
  "hasPhone": "any",
  "isClaimed": "any",
  "minRating": 0,
  "maxRating": 0,
  "minReviews": 0,
  "maxReviews": 0,
  "categoriesInclude": [],
  "skipClosed": false,
  "onlyNew": false,
  "monitorKey": "default",
  "startUrls": [],
  "language": "en",
  "country": "us",
  "fullCoverage": false,
  "maxPlacesTotal": 0,
  "latitude": "",
  "longitude": "",
  "radiusKm": 5,
  "withinRadiusOnly": false,
  "boundingBox": {},
  "polygon": [],
  "gridCellKm": 0,
  "maxGridCells": 64,
  "categoriesExclude": [],
  "nameContains": [],
  "nameExcludes": [],
  "excludePlaceIds": [],
  "excludeDatasetId": "",
  "maxPagesPerSite": 5,
  "sessions": 6,
  "perIp": 1,
  "proxyConfiguration": {
    "useApifyProxy": true
  },
  "searchStringsArray": [],
  "locationQuery": "",
  "maxCrawledPlacesPerSearch": 0,
  "countryCode": ""
}
```

# Actor output Schema

## `dataset` (type: `string`):

One flat row per place with contact, reputation, status and lead-helper columns, plus one free status row per search that accounts for every place found, filtered, deduplicated, delivered and charged.

## `summary` (type: `string`):

One JSON record with the counts this run delivered and charged, its filtered, duplicate and error tallies, and its HTTP and crawl stats.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "searches": [
        "coffee shop"
    ],
    "locations": [
        "Austin, TX"
    ],
    "maxPlacesPerSearch": 20,
    "proxyConfiguration": {
        "useApifyProxy": true
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("kestrel/google-maps-scraper").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "searches": ["coffee shop"],
    "locations": ["Austin, TX"],
    "maxPlacesPerSearch": 20,
    "proxyConfiguration": { "useApifyProxy": True },
}

# Run the Actor and wait for it to finish
run = client.actor("kestrel/google-maps-scraper").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "searches": [
    "coffee shop"
  ],
  "locations": [
    "Austin, TX"
  ],
  "maxPlacesPerSearch": 20,
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}' |
apify call kestrel/google-maps-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,kestrel/google-maps-scraper"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/uwJ1vzHxJB9ZDyKXm/builds/eiWrXK5tcA2fKDc8g/openapi.json
