# Google Maps Leads: Territory Filter & Deduplication (`lifelong_starfruit/pilot-maps-territory-leads`) Actor

Filter Google Maps leads by geographic bounds, deduplicate Place IDs and exclude leads you already have. Process Compass-format data or use optional Compass search, billed separately. Export qualifying businesses to JSON or CSV.

- **URL**: https://apify.com/lifelong\_starfruit/pilot-maps-territory-leads.md
- **Developed by:** [Mitchell Graham](https://apify.com/lifelong_starfruit) (community)
- **Categories:** Lead generation, Business, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.50 / 1,000 validated businesses

This Actor is paid per event and usage. You are charged both the fixed price for specific events and for Apify platform usage.

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 Leads: Territory Filter & Deduplication

Keep businesses whose reported coordinates fall inside your territory, remove duplicate Place IDs and suppress leads you have already received. Use an existing Compass-format dataset or collect a bounded set of candidates through Compass Google Maps Scraper.

### Ready-to-run examples

- [Find coffee shop leads inside downtown Phoenix bounds](https://apify.com/lifelong_starfruit/pilot-maps-territory-leads/examples/find-downtown-phoenix-coffee-leads)
- [Deduplicate Maps leads and exclude existing contacts](https://apify.com/lifelong_starfruit/pilot-maps-territory-leads/examples/deduplicate-and-suppress-existing-leads)

These examples create a task in your own account when you choose to try them. Review inputs and your spending limit before starting. Example companies and events are unaffiliated with this tool.

### Quick start: existing data

```json
{
  "bounds": [-112.2, 33.3, -111.9, 33.6],
  "datasetId": "YOUR_DATASET_ID",
  "maxItems": 100,
  "excludeClosed": true,
  "excludePlaceIds": []
}
```

Bounds are `[west, south, east, north]` in longitude/latitude. Replace the Phoenix example with your territory. You may supply `places` directly instead of `datasetId`.

The Console form starts with a clearly labeled synthetic row to demonstrate filtering. Replace it with your own records, or clear **Google Maps candidate rows** before using a dataset ID or search terms. Running the unchanged example does not discover real businesses.

### Optional candidate search

Replace `datasetId` with `searchTerms: ["coffee shop"]`, `maxCandidates: 30` and `sourceChargeLimitUsd: 0.5`. Supply exactly one of `datasetId`, `places` or `searchTerms`.

Search starts Compass Google Maps Scraper under your Apify account. **Its charges are separate** from this Actor and may include candidates subsequently rejected. The required source ceiling is $0.50–$1; it is a maximum charge setting, not a flat fee. The parent run's spending limit does not replace that separate ceiling. Check the upstream Actor's current pricing before using search.

### Output and coverage

Results include Place ID, title, address, coordinates, category, website, phone, source URL and `insideRequestedBounds`. `SUMMARY` counts rejections, duplicates and delivery and records reported upstream costs when search is used.

Validation uses source coordinates, not a verified street address or a business's service area. Points on the boundary are included. Missing or invalid coordinates are excluded. Suppression uses the `excludePlaceIds` supplied with each run; there is no automatic shared customer history.

Search is limited to 1–3 terms and 300 total candidate results. Existing datasets or arrays may contain up to 10,000 candidates. Maximum output is 1,000 businesses. Dateline-crossing territories are unsupported. Results are a bounded sample, not an exhaustive census; filtering can correctly return zero rows.

### Permissions and troubleshooting

Full Actor permissions allow reading your selected dataset and starting the upstream Actor in your account. Existing datasets must be accessible to your account. No email enrichment or outreach is included.

Inspect `SUMMARY` for out-of-bounds and missing-coordinate counts. With search enabled, inspect `SOURCE_RUN` for the upstream run ID. Report reproducible errors through this Actor's Issues tab with your run ID and a non-sensitive example.

### Pricing

See the live Pricing tab for this Actor's active rate and any platform usage charges. Candidate search adds the upstream provider's separate fees.

### Tutorial: keep only new leads inside your sales territory

Use this workflow after collecting Google Maps business data when your sales list must respect a rectangle and exclude businesses already in your CRM. Matching uses Place IDs and reported coordinates, not names, inferred addresses or service areas.

#### Try filtering without an upstream search

1. Open **Deduplicate Maps leads and exclude existing contacts** above. It has four clearly synthetic rows and a Phoenix bounding box. Choose a maximum Actor charge of $0.05; platform usage is additional.
2. Start the task. You should receive only `demo-new`. Its repeated row and the excluded `demo-existing` ID count as two duplicates/suppressions; the remaining row is outside the rectangle.
3. Replace `places` with real Compass-format rows, or clear it and supply a dataset ID you can access. Use exactly one source: `places`, `datasetId` or `searchTerms`.
4. Export existing Google Place IDs from your CRM into `excludePlaceIds`. Supply the list every run; the Actor does not retain a shared customer suppression list.
5. Download JSON/CSV and inspect `SUMMARY.rejected`. Before importing CSV into a CRM, map Place ID to a unique field so your CRM can also prevent duplicate imports.

Bounds are **\[west longitude, south latitude, east longitude, north latitude]**. Boundary points are included. Missing coordinates and closed businesses are excluded by default; a business serving your area from outside the rectangle will be excluded.

#### Collect a small set of candidates

The **Find coffee shop leads inside downtown Phoenix bounds** example uses real Compass search for at most 10 candidates. It can return fewer qualifying businesses, including zero. Edit the search term and bounds for your use case. Results are not a census of all businesses in the area.

The source search is billed separately by Compass. Its `sourceChargeLimitUsd: 0.5` is a **$0.50 ceiling, not a fixed fee**, and does not replace this Actor's own spending limit. Candidates that are later excluded can still incur upstream charges. For the lowest extra cost when you already have data, use `places` or `datasetId`.

#### Sample result and costs

The synthetic filtering example should return this subset of fields:

```json
{"placeId":"demo-new","title":"Synthetic new lead","latitude":33.45,"longitude":-112.07,"insideRequestedBounds":true}
```

At the September 28, 2026 base price, one delivered row costs **$0.00055 in Actor events** ($0.50/1,000 rows plus a $0.00005 start event), **plus platform usage**. Optional search adds Compass's separate charges. Check both live pricing pages before running a search. The script below uses synthetic supplied rows and starts no upstream search; replace them with your real data after inspecting the output.

#### Complete Node.js example

Requires Node.js 22 or newer and your own Apify account. Save the following as `maps-territory.mjs`. Set `APIFY_TOKEN` privately in your environment, then run `node maps-territory.mjs`. No npm packages are needed. Keep credentials out of source control and shared workflows. The script prints a run ID; if your local session stops, set `RESUME_RUN_ID` to that ID to retrieve the same run without starting another.

```javascript
import fs from 'node:fs/promises';

// Node.js 22+. Set APIFY_TOKEN in your environment; never paste it into a URL.
async function request(path, { method = 'GET', body, text = false } = {}) {
  if (!process.env.APIFY_TOKEN) throw new Error('Set APIFY_TOKEN in your environment');
  const response = await fetch(`https://api.apify.com/v2${path}`, {
    method, redirect: 'error', signal: AbortSignal.timeout(30000),
    headers: { Authorization: `Bearer ${process.env.APIFY_TOKEN}`, 'Content-Type': 'application/json' },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  if (!response.ok) throw new Error(`Apify returned HTTP ${response.status}`);
  if (text) return response.text();
  const data = await response.json();
  return data.data ?? data;
}

async function run(actorName, input, maxCharge, timeoutSecs = 120) {
  // To recover a stopped local script, set RESUME_RUN_ID instead of starting again.
  const resumed = process.env.RESUME_RUN_ID;
  if (resumed && !/^[a-zA-Z0-9]{10,30}$/.test(resumed)) throw new Error('Invalid RESUME_RUN_ID');
  const actor = await request(`/actors/lifelong_starfruit~${actorName}`);
  let current = resumed
    ? await request(`/actor-runs/${resumed}`)
    : await request(`/actors/${actor.id}/runs?timeout=${timeoutSecs}&memory=256&maxTotalChargeUsd=${maxCharge}`, { method: 'POST', body: input });
  if (current.actId !== actor.id) throw new Error('Run belongs to a different Actor');
  console.log(`Run ID: ${current.id}`);
  const deadline = Date.now() + (timeoutSecs + 60) * 1000;
  while (['READY', 'RUNNING', 'ABORTING', 'TIMING-OUT'].includes(current.status)) {
    if (Date.now() > deadline) throw new Error(`Still pending: ${current.id}. Inspect it in Console; resume instead of starting a duplicate.`);
    await new Promise(resolve => setTimeout(resolve, 3000));
    current = await request(`/actor-runs/${current.id}`);
  }
  if (current.status !== 'SUCCEEDED') throw new Error(`Run ${current.id}: ${current.status}. Inspect SUMMARY in Console before retrying.`);
  const summary = await request(`/key-value-stores/${current.defaultKeyValueStoreId}/records/SUMMARY`);
  if (!summary.healthy || !summary.deliveredAll) throw new Error('Incomplete delivery; previous local state preserved');
  const rows = await request(`/datasets/${current.defaultDatasetId}/items?clean=true&limit=5000`);
  if (rows.length !== summary.written) throw new Error('Incomplete download; previous local state preserved');
  return { current, summary, rows };
}


// Synthetic rows demonstrate behavior; replace them with real Compass-format data.
const input = {
  bounds: [-112.085, 33.44, -112.06, 33.465],
  places: [
    { placeId: 'demo-new', title: 'Synthetic new lead', location: { lat: 33.45, lng: -112.07 } },
    { placeId: 'demo-new', title: 'Synthetic duplicate', location: { lat: 33.45, lng: -112.07 } },
    { placeId: 'demo-existing', title: 'Synthetic existing lead', location: { lat: 33.45, lng: -112.07 } },
    { placeId: 'demo-outside', title: 'Synthetic outside territory', location: { lat: 34, lng: -112.07 } },
  ],
  excludePlaceIds: ['demo-existing'], excludeClosed: true, maxItems: 10,
};
const { current, summary, rows } = await run('pilot-maps-territory-leads', input, 0.05);
await fs.writeFile(`territory-leads-${current.id}.json`, JSON.stringify(rows, null, 2));
console.log({ delivered: rows.map(r => r.placeId), rejected: summary.rejected });
// Expected: demo-new only; 2 duplicate/suppressed rows and 1 outside the rectangle.

```

### Related tools

- [Greenhouse & Ashby Job Scraper + Change Tracking](https://apify.com/lifelong_starfruit/pilot-employer-job-feed)
- [Eventbrite Scraper: Event URLs to JSON & ICS](https://apify.com/lifelong_starfruit/pilot-event-calendar-feed)
- [Map Your Show Exhibitor Scraper: Company Matching & CSV](https://apify.com/lifelong_starfruit/map-your-show-company-matching)
- [Thomasnet Supplier Shortlist: Filters, Deduplication & CSV](https://apify.com/lifelong_starfruit/thomasnet-supplier-shortlist)
- [10times Events: Industry Discovery, Changes & ICS Calendar](https://apify.com/lifelong_starfruit/10times-event-calendar-sync)

# Actor input Schema

## `bounds` (type: `array`):

\[west, south, east, north] in longitude/latitude. No dateline crossing.

## `searchTerms` (type: `array`):

Optional: 1–3 search terms. Uses Compass Google Maps Scraper, billed separately to your Apify account. Supply search terms OR existing data.

## `maxCandidates` (type: `integer`):

Maximum source candidates, from 1 to 300.

## `sourceChargeLimitUsd` (type: `number`):

Required for search; the source requires a ceiling of at least $0.50. Separate from this Actor’s own usage charges; rejected and duplicate candidates still incur upstream cost.

## `places` (type: `array`):

Replace the synthetic example with real Compass rows, or clear it to use datasetId/searchTerms. Supply exactly one source.

## `datasetId` (type: `string`):

Up to 10,000 rows from a dataset you can access.

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

Place IDs to suppress from this run, such as leads already delivered.

## `excludeClosed` (type: `boolean`):

Remove businesses marked temporarily or permanently closed by the source.

## `maxItems` (type: `integer`):

Maximum qualifying businesses, from 1 to 1000.

## Actor input object example

```json
{
  "bounds": [
    -112.2,
    33.3,
    -111.9,
    33.6
  ],
  "maxCandidates": 50,
  "places": [
    {
      "placeId": "synthetic-example",
      "title": "Synthetic example: replace with your data",
      "location": {
        "lat": 33.45,
        "lng": -112.07
      }
    }
  ],
  "excludeClosed": true,
  "maxItems": 100
}
```

# Actor output Schema

## `results` (type: `string`):

Delivered records; check run status and coverage before treating these as complete.

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

Source coverage, written record count and completion status.

# 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 = {
    "bounds": [
        -112.2,
        33.3,
        -111.9,
        33.6
    ],
    "places": [
        {
            "placeId": "synthetic-example",
            "title": "Synthetic example: replace with your data",
            "location": {
                "lat": 33.45,
                "lng": -112.07
            }
        }
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("lifelong_starfruit/pilot-maps-territory-leads").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 = {
    "bounds": [
        -112.2,
        33.3,
        -111.9,
        33.6,
    ],
    "places": [{
            "placeId": "synthetic-example",
            "title": "Synthetic example: replace with your data",
            "location": {
                "lat": 33.45,
                "lng": -112.07,
            },
        }],
}

# Run the Actor and wait for it to finish
run = client.actor("lifelong_starfruit/pilot-maps-territory-leads").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 '{
  "bounds": [
    -112.2,
    33.3,
    -111.9,
    33.6
  ],
  "places": [
    {
      "placeId": "synthetic-example",
      "title": "Synthetic example: replace with your data",
      "location": {
        "lat": 33.45,
        "lng": -112.07
      }
    }
  ]
}' |
apify call lifelong_starfruit/pilot-maps-territory-leads --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,lifelong_starfruit/pilot-maps-territory-leads"
        }
    }
}
```

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/JyUU3TjlbCArBToL6/builds/D0encWliwyq0qX25C/openapi.json
