---
name: maps-leads
description: "Find businesses on Google Maps by type and location (optionally within a radius): name, address, phone, website, rating, reviews, categories and opening hours, plus emails (with the page they were found on), phone numbers and social profiles from each business website. One row per company. No login, no browser. Use when the user wants businesses from Google Maps for a type and place: lead lists for sales or marketing, local competitor research, or contact details (email, social profiles) for a category of business in an area. No login, no browser. Calls the Pullwire API with the key in PULLWIRE_API_KEY; costs 1 credit per business lead."
metadata:
  openclaw:
    requires:
      env:
        - PULLWIRE_API_KEY
      bins:
        - curl
    primaryEnv: PULLWIRE_API_KEY
    homepage: https://pullwire.dev/tools/maps-leads
---

# Google Maps Leads Scraper

Find businesses on Google Maps by type and location (optionally within a radius): name, address, phone, website, rating, reviews, categories and opening hours, plus emails (with the page they were found on), phone numbers and social profiles from each business website. One row per company. No login, no browser. The data comes from the Pullwire API: you send the input as JSON and get structured JSON back. Only public data.

## Setup (once)

The API key must be in the environment variable `PULLWIRE_API_KEY`. Keys start with `af_live_`. Check it without printing it:

```bash
test -n "$PULLWIRE_API_KEY" && echo "key is set" || echo "PULLWIRE_API_KEY is missing"
```

If it is missing, stop and tell the user: sign up at https://pullwire.dev/login (new accounts get 2,000 free credits), create a key at https://pullwire.dev/app/keys, then run `export PULLWIRE_API_KEY="af_live_..."` in the shell the agent uses. Never print the key, write it into a file or repeat it in the chat.

## Run it

```bash
curl -sS -X POST "https://pullwire.dev/api/v1/tools/maps-leads/runs" \
  -H "x-api-key: $PULLWIRE_API_KEY" \
  -H "content-type: application/json" \
  -d '{"searches":[{"query":"dentist","location":"Berlin, Germany"}],"maxResults":3}'
```

The request waits up to 25 seconds and answers with `{"run": {...}, "results": [...]}`. Check `run.status` (`succeeded` or `failed`) and `run.error`: when it is set, the run stopped early and `results` is partial (everything in it was charged). `run.creditsUsed` is what the run cost. For big answers add `-o result.json` and read only the fields you need.

Good to know:

- Every entry in `searches` needs a `query` (business type or keyword); add `location` unless the query already names a place.
- Google Maps lists roughly 120-200 businesses per search. For a bigger area, split it into several searches (one per district or nearby town); `dedupe` (default on) removes businesses found by more than one search, and `onePerCompany` (default on) keeps one row per website domain or phone number (branches, double listings).
- For "within N km of a place", set `radiusKm` on the search (needs `location`): results are sorted by `distanceKm`, nearest first, among the places Google Maps lists for that area.
- `enrichContacts` (default on) visits each business website for emails, extra phone numbers and social profiles; turn it off for a faster run with Maps data only. `email` is the address of the business itself when one was found (own domain first, then a domain with its name, then a personal mailbox; an agency or platform address comes last), and `emailSource` names the page it was read from. `contactsStatus` on each result says what happened (`found`, `none-found`, `no-website`, `failed`, `skipped`).
- Reviews and photos are not collected, only what Google Maps and each business website already show publicly.

## Input

The JSON body is the tool input. Fields:

| Field | Type | Required | Default | What it does |
| --- | --- | --- | --- | --- |
| `searches` | object[] | yes |  | Searches: One entry per business type + location combination. |
| `searches[].query` | string | yes |  | Business type or keyword: What to look for, as you would type it into Google Maps, e.g. "dentist", "plumber", "vegan restaurant". |
| `searches[].location` | string | no |  | Location: City, district, region or country, e.g. "Berlin, Germany" or "Austin, TX". Empty: the query is used as-is. |
| `searches[].radiusKm` | number | no |  | Radius (km): Only businesses within this distance of the location's center, e.g. 3 for "within 3 km of Berlin-Kreuzberg", sorted nearest first among the ~120-200 places Google Maps returns for that area. Needs a location. Empty: the area Google Maps picks for the location. |
| `searches[].maxResultsPerSearch` | integer | no | `100` | Max results for this search: Stop this search after this many businesses. Google Maps itself returns roughly 120-200 places per search; for more, split the area into districts or nearby towns. |
| `enrichContacts` | boolean | no | `true` | Find emails and social profiles: Visit each business website (homepage plus contact, imprint or privacy pages when needed) to collect email addresses, phone numbers and Facebook, Instagram, LinkedIn, X, YouTube, TikTok and Pinterest profiles. |
| `maxContactPages` | integer | no | `3` | Website pages per business: Homepage plus up to this many minus one contact, imprint or privacy pages, only while no address of the business itself has been found (an agency or platform address alone does not count). |
| `language` | string | no | `"en"` | Language of categories, opening hours and descriptions, e.g. "en", "de", "fr". |
| `countryCode` | string | no |  | Country bias: Two-letter country code that biases ambiguous place names, e.g. "us" or "de". Empty: none. |
| `dedupe` | boolean | no | `true` | Skip duplicates across searches: Return each business only once even when several searches find it. |
| `onePerCompany` | boolean | no | `true` | One row per company: Skip a business whose website domain or phone number was already returned (branches of one company, double listings). Shared hosts like Facebook pages or booking platforms never count as the same company. |
| `maxResults` | integer | no |  | Max results: Stop after this many businesses in total across all searches. Empty: no extra cap. |

Without `maxResults` a run returns at most 20 results; the maximum is 200 per run. Credits for `maxResults` results are reserved before the run starts and the unused part is given back, so ask for what you need.

## Output

Each item in `results` has these fields:

| Field | Type | Always there |
| --- | --- | --- |
| `placeId` | string | yes |
| `name` | string | yes |
| `category` | string | no |
| `categories` | string[] | yes |
| `address` | string | no |
| `street` | string | no |
| `neighborhood` | string | no |
| `city` | string | no |
| `postalCode` | string | no |
| `state` | string | no |
| `countryCode` | string | no |
| `phone` | string | no |
| `phoneInternational` | string | no |
| `phones` | string[] | no |
| `website` | string | no |
| `domain` | string | no |
| `email` | string | no |
| `emails` | string[] | yes |
| `emailSource` | string | no |
| `emailSources` | object[] | no |
| `facebookUrl` | string | no |
| `instagramUrl` | string | no |
| `linkedinUrl` | string | no |
| `xUrl` | string | no |
| `youtubeUrl` | string | no |
| `tiktokUrl` | string | no |
| `pinterestUrl` | string | no |
| `contactsStatus` | "found" \| "none-found" \| "no-website" \| "failed" \| "skipped" | yes |
| `rating` | number | no |
| `reviewsCount` | integer | no |
| `latitude` | number | no |
| `longitude` | number | no |
| `distanceKm` | number | no |
| `openingHours` | object[] | no |
| `description` | string | no |
| `timezone` | string | no |
| `featureId` | string | no |
| `mapsUrl` | string | yes |
| `rank` | integer | yes |
| `searchQuery` | string | yes |
| `searchLocation` | string | no |
| `scrapedAt` | string | yes |

## Cost

1 credit per business lead. One business returned from a Google Maps search, including contact enrichment from its website when enabled. Searches with zero matches are not charged. Check the balance with:

```bash
curl -sS "https://pullwire.dev/api/v1/me" -H "x-api-key: $PULLWIRE_API_KEY"
```

The answer has `balance` in credits. Tell the user what a run cost when it was more than a few credits.

## Bigger jobs

Add `?async=1` to the run URL to start a background run (up to 50 seconds). It answers `202` with `run.id` right away. Poll every few seconds until `status` is `succeeded` or `failed`, then fetch the results:

```bash
curl -sS "https://pullwire.dev/api/v1/runs/$RUN_ID" -H "x-api-key: $PULLWIRE_API_KEY"
curl -sS "https://pullwire.dev/api/v1/runs/$RUN_ID/results" -H "x-api-key: $PULLWIRE_API_KEY"
```

Add `?format=csv` to the results URL when the user wants a spreadsheet.

## Errors

Errors answer `{"error": {"code": "...", "message": "..."}}`.

| Status | Code | What to do |
| --- | --- | --- |
| 400 | `INVALID_INPUT` | The message names the field. Fix the input and retry; nothing was charged. |
| 401 | `UNAUTHORIZED` | The key is missing, wrong or revoked. Ask the user to set a valid key in `PULLWIRE_API_KEY`. |
| 402 | `INSUFFICIENT_CREDITS` | The balance cannot cover the reservation. Lower `maxResults` or tell the user the balance is too low. |
| 404 | `NOT_FOUND` | Unknown tool or run ID. |
| 429 | `RATE_LIMITED` | Used up its burst of 120 requests and its 60 per minute. Wait, then retry. |
| 500 | `INTERNAL_ERROR` | Retry later. If the body has `runId`, fetch the partial results with `GET /runs/{runId}/results`. |
| 503 | `TOOL_UNAVAILABLE` | Our latest automatic checks against this site failed, so its runs are paused. Nothing was reserved or charged. Tell the user and try again later. |
| 503 | `SERVICE_UNAVAILABLE` | Retry later. |

Docs: https://pullwire.dev/docs · Tool page: https://pullwire.dev/tools/maps-leads
