# Google Maps Email Enrichment - Local Business Leads (`lukehunter/lead-enricher`) Actor

Google Maps enricher for business leads enrichment: feed it Google Maps Scraper results and get local business emails, phones, socials, team-page people and tech stack. Found an email for 81% of 80 real test businesses. $0.005 per lead with contacts; the rest is free.

- **URL**: https://apify.com/lukehunter/lead-enricher.md
- **Developed by:** [Luke Hunter](https://apify.com/lukehunter) (community)
- **Categories:** Lead generation, Marketing
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

$5.00 / 1,000 lead with contacts

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 Email Enrichment - Local Business Leads

**A Google Maps enricher for agencies, sales teams, freelancers and growth teams who scrape Google Maps and then need local business emails.** This is business leads enrichment: Google Maps email enrichment, Google Maps contacts and the Google Maps tech stack of each business, from the business's own website. Run a Google Maps scraper, pick its results here, and get **one enriched row per business**: the **emails** the business publishes on its own website (each with the page it came from, the best one marked as `primaryEmail`), its **phone numbers**, **social links**, the **named people with job titles** from its team page, and the **technology stack** of its site (CMS, ecommerce platform, analytics, ad pixels, email marketing, payments, chat), plus simple yes/no **lead flags** (`hasEmail`, `isEcommerce`, `runsAds`, `usesEmailMarketing`, ...). Your own Google Maps columns (name, category, address, rating, place ID, ...) come back next to the result under `source`, so everything joins to the rows you started with.

It enriches **any existing results**, so you keep using the Google Maps scraper you already like, and you **pay per lead found**: **$0.005 per business with an email or phone** - 1,000 leads = $5. Businesses with no website, nothing found, a site that blocked the request, or that your filters removed are **never charged**.

### Google Maps email enrichment: quick start (3 steps)

1. **Run a Google Maps scraper** (for example [Google Maps Scraper by Compass](https://apify.com/compass/crawler-google-places)) and wait until the run has finished.
2. **Start this Actor and pick that run's dataset** in **Your Google Maps Scraper results (dataset)**. By hand: open the scraper run's **Storage** tab, copy the dataset ID (the code at the end of `.../storage/datasets/WkzbQMuFYuamGv3YF`), and paste it. Raise **Maximum leads** to the number of businesses you want (default 100, up to 10,000).
3. **Export** the dataset as CSV, Excel or JSON from the run's Storage tab, or pull it through the API (see below).

```json
{
  "datasetId": "WkzbQMuFYuamGv3YF",
  "maxItems": 1000
}
```

No Google Maps run yet? The form is prefilled with three example websites (`plausible.io`, `mailerlite.com`, `stumptowncoffee.com`) so you can see what a row looks like for about $0.015. They are ignored as soon as you choose a dataset. You can also paste a list of websites or give a link to a CSV file instead of a dataset (see "Other ways to give it businesses").

#### Chain it automatically (Integrations)

Every Google Maps run can be enriched the moment it ends, with nothing to click:

1. In Apify Console open your Google Maps scraper (or its saved Task) and go to **Integrations**.
2. Add an **Actor** integration, choose **Google Maps Email Enrichment** (`lukehunter/lead-enricher`), and set the trigger to **Run succeeded**.
3. Use this as the integration's input:

```json
{
  "datasetId": "{{resource.defaultDatasetId}}",
  "maxItems": 1000
}
```

`{{resource.defaultDatasetId}}` is filled in by Apify with the dataset of the run that just finished. Add the other inputs as usual (`requireTech`, `onlyWithEmail`, ...). Prefer a webhook? Use the event `ACTOR.RUN.SUCCEEDED` on the scraper, call this Actor's run endpoint, and put the same `datasetId` in the payload template. The dataset is read with your own account's access, so you never hand over a token.

### Tested on real businesses

We ran it on 80 real business websites (dentists, agencies, shops, gyms, venues and more, each checked first for robots.txt permission) on 2026-10-10:

| Result | Share of businesses |
|---|---|
| At least one email found | 81% |
| Phone number found | 71% |
| Named person (team page) found | 34% |
| Tech stack detected | 88% |
| Site blocked us or unreachable (free) | 9% |
| Nothing published (free) | 6% |

You pay only for the businesses where an email or phone was found. Results vary by industry and country; small sites with only a contact form have no email to find. Aggregate numbers only: no contact data from the test is published or stored.

### Example output: Google Maps contacts and local business emails

One row per business (a chain with several Google Maps listings and one website is one business, charged once). An example, produced by this Actor from the real stumptowncoffee.com home page (captured 2026-10-04) and a Google Maps Scraper row (the Maps row is an invented sample; a live run reads up to six pages, so it may list more, and `emailDomainHasMx` and `checkedAt` are filled in at run time):

```json
{
  "companyName": "Stumptown Coffee",
  "domain": "stumptowncoffee.com",
  "primaryEmail": "info@stumptowncoffee.com",
  "emails": [
    { "email": "info@stumptowncoffee.com", "kind": "generic", "person": null, "ownDomain": true, "sourceUrl": "https://www.stumptowncoffee.com/" }
  ],
  "phones": [
    { "phone": "877-711-3385", "e164": null, "sourceUrl": "https://www.stumptowncoffee.com/" }
  ],
  "socials": {
    "linkedin": null,
    "facebook": "https://www.facebook.com/stumptowncoffee",
    "instagram": "https://www.instagram.com/stumptowncoffee",
    "x": "https://twitter.com/stumptowncoffee",
    "youtube": "https://www.youtube.com/channel/UC0yk_H5Np-uGvy98UM-jj6w",
    "tiktok": "https://www.tiktok.com/@stumptowncoffee"
  },
  "people": [
    { "name": "Duane Sorenson", "title": "Founder", "email": null, "linkedin": null, "sourceUrl": "https://www.stumptowncoffee.com/" }
  ],
  "techStack": {
    "cms": null,
    "ecommerce": "Shopify",
    "analytics": [],
    "marketing": ["Klaviyo"],
    "payments": ["Shop Pay"],
    "chat": [],
    "technologies": [
      { "name": "Shopify", "category": "Ecommerce", "confidence": "high", "evidence": "meta shopify-digital-wallet tag present" },
      { "name": "Klaviyo", "category": "Marketing", "confidence": "high", "evidence": "inline script contains _learnq" },
      { "name": "Shop Pay", "category": "Payments", "confidence": "high", "evidence": "script src contains shopifycloud/shop-js" },
      { "name": "Shopify CDN", "category": "CDN", "confidence": "high", "evidence": "script src contains cdn.shopify.com" }
    ]
  },
  "leadFlags": {
    "hasEmail": true,
    "hasPhone": true,
    "hasNamedContact": true,
    "isEcommerce": true,
    "runsAds": false,
    "usesEmailMarketing": true
  },
  "contactPageUrl": null,
  "address": { "street": "700 SW 5th Ave, 3rd Floor, #400", "locality": "Portland", "region": "OR", "postalCode": "97214", "country": null },
  "emailDomainHasMx": true,
  "status": "ok",
  "statusReason": null,
  "inputUrl": "https://www.stumptowncoffee.com/",
  "finalDomain": "stumptowncoffee.com",
  "pagesCrawled": 1,
  "changeType": null,
  "changedFields": null,
  "checkedAt": "2026-10-04T12:00:00.000Z",
  "source": {
    "title": "Stumptown Coffee Roasters",
    "categoryName": "Coffee roasters",
    "address": "128 SW 3rd Ave, Portland, OR 97204, United States",
    "city": "Portland",
    "phone": "(503) 295-6144",
    "totalScore": 4.5,
    "reviewsCount": 1342,
    "url": "https://www.google.com/maps/search/?api=1&query=Stumptown%20Coffee%20Roasters&query_place_id=ChIJSyntheticStumptown0001",
    "placeId": "ChIJSyntheticStumptown0001",
    "datasetRow": 0,
    "sourceRowCount": 2,
    "otherRows": ["ChIJSyntheticStumptown0002"]
  }
}
```

`e164` is `null` here because a US-looking number on a `.com` is not enough to be sure of the country; the Actor never guesses.

A business whose team page names its people gives a named contact. This row is **synthetic** (an invented dental practice used in the Actor's tests), showing `primaryEmail` choosing the named dentist over the reception mailbox, and the flags:

```json
{
  "domain": "brightsmile-dental.co.uk",
  "primaryEmail": "nadia.rahman@brightsmile-dental.co.uk",
  "emails": [
    { "email": "nadia.rahman@brightsmile-dental.co.uk", "kind": "person", "person": "Nadia Rahman", "ownDomain": true, "sourceUrl": "http://www.brightsmile-dental.co.uk/meet-the-team/" },
    { "email": "reception@brightsmile-dental.co.uk", "kind": "generic", "person": null, "ownDomain": true, "sourceUrl": "http://www.brightsmile-dental.co.uk/" }
  ],
  "people": [
    { "name": "Nadia Rahman", "title": "Principal Dentist", "email": "nadia.rahman@brightsmile-dental.co.uk", "linkedin": null, "sourceUrl": "http://www.brightsmile-dental.co.uk/meet-the-team/" },
    { "name": "Owen Whitfield", "title": "Associate Dentist", "email": null, "linkedin": null, "sourceUrl": "http://www.brightsmile-dental.co.uk/meet-the-team/" },
    { "name": "Karen Doyle", "title": "Practice Manager", "email": null, "linkedin": null, "sourceUrl": "http://www.brightsmile-dental.co.uk/meet-the-team/" }
  ],
  "leadFlags": { "hasEmail": true, "hasPhone": true, "hasNamedContact": true, "isEcommerce": false, "runsAds": true, "usesEmailMarketing": true },
  "status": "ok"
}
```

A row of your dataset that has **no website** comes back free, with the same columns, empty, and your `source` fields, so you can see which leads could not be enriched (and phone them instead):

```json
{
  "companyName": "Corner Cottage Dental",
  "domain": null,
  "primaryEmail": null,
  "emails": [],
  "phones": [],
  "people": [],
  "leadFlags": { "hasEmail": false, "hasPhone": false, "hasNamedContact": false, "isEcommerce": false, "runsAds": false, "usesEmailMarketing": false },
  "status": "no-website",
  "statusReason": "This row of your dataset has no website to read: the only value found, in \"url\", is a Google Maps or Google search link (www.google.com). Nothing was fetched and nothing is charged.",
  "source": { "title": "Corner Cottage Dental", "categoryName": "Dentist", "city": "Leeds", "phone": "0113 496 0188", "placeId": "ChIJSyntheticCorner0009", "datasetRow": 8, "sourceRowCount": 1, "otherRows": [] }
}
```

(Rows also carry `techStack`, `socials`, `address` and the other columns, with empty or `null` values; they are left out of this example for space.)

#### The columns

| Category | Fields |
|---|---|
| Identity | `companyName`, `domain`, `inputUrl`, `finalDomain` |
| Best contact | `primaryEmail` |
| Contacts | `emails` (`email`, `kind`, `person`, `ownDomain`, `sourceUrl`), `phones` (`phone`, `e164`, `sourceUrl`), `socials` (`linkedin`, `facebook`, `instagram`, `x`, `youtube`, `tiktok`) |
| People | `people` (`name`, `title`, `email`, `linkedin`, `sourceUrl`; at most 50 per business) |
| Tech stack | `techStack.cms`, `techStack.ecommerce`, `techStack.analytics[]`, `techStack.marketing[]`, `techStack.payments[]`, `techStack.chat[]`, and the full `techStack.technologies[]` (`name`, `category`, `confidence`, `evidence`) |
| Qualification | `leadFlags`: `hasEmail`, `hasPhone`, `hasNamedContact`, `isEcommerce`, `runsAds`, `usesEmailMarketing` |
| Company | `contactPageUrl`, `address` (JSON-LD PostalAddress only) |
| Quality | `emailDomainHasMx` (public DNS MX lookup of `primaryEmail`'s domain), `pagesCrawled` |
| Source row | `source` (only when a dataset was used: the Google Maps columns below plus yours; `null` for a website you pasted) |
| Status | `status` (`ok`, `no-contacts-found`, `blocked`, `unreachable`, `robots-disallowed`, `no-website`), `statusReason`, `changeType` and `changedFields` (change mode), `checkedAt` |

- **`primaryEmail`** is the best address to write to: an address on the business's own domain beats one on another domain, then **a named person beats a role mailbox (`sales@`, `support@`, `bookings@`) beats a generic one (`info@`, `hello@`, `contact@`)**, then the order the site gave them in. `kind` says which it is: `person` (the site ties the address to a named person, or it is written `first.last@` or is a lone common first name such as `sarah@`), `role` or `generic`. It is judged only from the published address and the people the site lists: nothing is ever built or guessed.
- **`leadFlags`**: `hasNamedContact` is true when the site publishes a named person or an email that belongs to one. `isEcommerce`: an ecommerce platform was detected. `runsAds`: an advertising pixel or tag (Meta Pixel, Google Ads, TikTok Pixel, LinkedIn Insight Tag, ...) is on the home page. `usesEmailMarketing`: Klaviyo, Mailchimp, HubSpot, Marketo, ActiveCampaign, Omnisend, Brevo, ConvertKit or MailerLite was detected.
- **`source`** always has the Google Maps columns `title`, `categoryName`, `address`, `city`, `phone`, `totalScore`, `reviewsCount`, `url` (the Google Maps link) and `placeId` (whichever the row has), plus any extra columns you list in **Dataset: extra columns to copy into `source`** (up to 15, text cut at 2,000 characters), plus `datasetRow` (the row's number in the dataset, counting from 0), `sourceRowCount` and `otherRows` (for a chain: the other Maps listings that share the website, at most 20). Join on `source.placeId`.

### Business leads enrichment filters: pay only for the leads you want

Filters run **before** a row is delivered or charged, so a row they remove is not in your dataset, not charged, and does not count toward **Maximum leads**.

- **Only leads with an email** (`onlyWithEmail`, on by default). Off, you also get businesses with a phone number only (charged, a phone is a lead) and, free, rows that show why a site gave nothing.
- **Only sites that run** (`requireTech`): at least one of these technologies, found on the home page. **Skip sites that run** (`excludeTech`): none of these. Names are matched ignoring case and punctuation; a name the Actor cannot detect is an input error with suggestions. All 166 detectable technologies are listed below.

**Recipe: only Shopify stores with an email**

```json
{
  "datasetId": "WkzbQMuFYuamGv3YF",
  "requireTech": ["Shopify"],
  "onlyWithEmail": true,
  "maxItems": 500
}
```

**Recipe: dentists with a named contact.** Scrape "dentist in Leeds" with the Google Maps scraper, enrich it here, then keep the rows where `leadFlags.hasNamedContact` is true (in the dataset view, the CSV, or your CRM); `primaryEmail` is already the named person's address where the site publishes one, and `people` has the dentists' names and titles.

```json
{
  "datasetId": "WkzbQMuFYuamGv3YF",
  "includePeople": true,
  "maxPagesPerSite": 8,
  "maxItems": 500
}
```

**Recipe: agencies selling email marketing.** Skip businesses that already run it, and keep ones that advertise: `"excludeTech": ["Klaviyo", "Mailchimp", "HubSpot"]`, then filter `leadFlags.runsAds` in your sheet.

To bound a run when filters remove many rows, a run reads at most 10 x **Maximum leads** websites (at least 100); the status message says if that cap was reached.

### Other ways to give it businesses

- **Paste websites** into **Websites**: domains, URLs with a path, email addresses (the company domain is used; free-mail such as gmail is skipped) or a whole spreadsheet column or CSV row. A URL with a path is read as the first extra page. De-duplicated by registrable domain.
- **A link to a CSV or TXT file** in **Website list file URL** (a Google Sheets "Publish to web" CSV link, Dropbox shared link, GitHub raw link): downloaded once (at most 5 MB, 20,000 lines), every cell read like a pasted entry.
- Both are added to the dataset's businesses and de-duplicated with them. Everything skipped (invalid entries, free-mail, duplicates, dataset rows with no usable website) is listed with its reason in the run status and in the `INPUT_REPORT` record of the run's key-value store.

**Which column holds the website?** The one you name in **Dataset: website column**, else the first of `website`, `websiteUrl`, `web`, `url`, `domain`, `homepage`, `companyWebsite`, `company_website`, `site` that has a value (Google Maps Scraper uses `website`). A Google Maps, social network (Facebook, Instagram, LinkedIn, X, ...) or marketplace link is **never** read as a company website, whichever column it is in. A business whose only "website" is a Facebook page is a `no-website` row.

### How it works

For each business the Actor:

1. Reads `robots.txt` of the host (once per host) and checks every path against it before requesting it. A disallowed path is never fetched.
2. Reads the home page, then the same-site pages most likely to carry contacts and people (contact, kontakt, contacto, contatti, impressum, mentions legales, about, team, leadership, support, locations and similar, best match first), up to **Pages per website** (default 6; if the home page links no contact or team page it reads one sitemap file to find them).
3. Extracts what the pages literally publish: `mailto:` links, `tel:` links, JSON-LD, addresses written in the text, simple encodings of an address that is already on the page (`name [at] domain [dot] com`, `&#64;`, Cloudflare's obfuscation), clearly labelled phone numbers, social profile links and the people on team pages.
4. **Detects the technology stack on the home page it already fetched** - the scripts, links, meta tags, markup, response headers and cookie names, matched against 166 curated signatures. No extra request is made for the tech stack.
5. Delivers one row per business, the best email first, with the page each contact came from.

It reads politely: about one request per second per site (slower when a site's `robots.txt` asks for a `Crawl-delay`), 5 sites at a time, an honest self-identifying User-Agent, a time budget per site, at most three redirects per page. If a site blocks it, the row says `blocked` and the Actor stops requesting that site instead of working around it.

#### Google Maps tech stack: the technologies it detects

CMS, ecommerce, analytics, ad pixels, email marketing, chat, payments and more: 166 in all.

- **CMS**: WordPress, Wix, Squarespace, Webflow, Drupal, Joomla, Ghost, HubSpot CMS, Framer, TYPO3
- **Ecommerce**: Shopify, WooCommerce, Magento, BigCommerce, PrestaShop, Shopware, Squarespace Commerce, Ecwid, Salesforce Commerce Cloud, Snipcart
- **Analytics**: Google Analytics 4, Universal Analytics, Google Tag Manager, Plausible, Fathom, Matomo, Hotjar, Microsoft Clarity, Mixpanel, Segment, Amplitude, Heap, Adobe Analytics, Adobe Experience Platform Launch, Simple Analytics, Umami, PostHog, FullStory, Optimizely, VWO, Cloudflare Web Analytics, Vercel Analytics, Yandex Metrica
- **Advertising**: Meta Pixel, TikTok Pixel, LinkedIn Insight Tag, Pinterest Tag, Google Ads, Google AdSense, Google Ad Manager, X (Twitter) Pixel, Snapchat Pixel, Reddit Pixel, Microsoft Advertising
- **Marketing**: Klaviyo, Mailchimp, HubSpot, Marketo, ActiveCampaign, Omnisend, Brevo, ConvertKit, Attentive, MailerLite
- **Chat**: Intercom, Drift, Zendesk, Crisp, Tawk.to, LiveChat, Tidio, Gorgias, Freshchat, Olark, Help Scout Beacon
- **Payments**: Stripe, PayPal, Klarna, Afterpay, Shop Pay, Square, Braintree, Adyen, Amazon Pay, Affirm, Mollie, Paddle, Razorpay
- **Reviews**: Trustpilot, Yotpo, Judge.me, Okendo, Reviews.io, Stamped.io, Loox
- **CDN**: Cloudflare, Fastly, Akamai, AWS CloudFront, Shopify CDN
- **Hosting**: Vercel, Netlify, WP Engine, Kinsta, GitHub Pages, Heroku, Pantheon, Google Cloud
- **Security**: Imperva, reCAPTCHA, hCaptcha, Cloudflare Turnstile
- **JavaScript framework**: React, Next.js, Vue.js, Nuxt, Angular, AngularJS, Svelte, SvelteKit, Gatsby, Remix, Astro, Alpine.js, htmx
- **JavaScript library**: jQuery, jQuery UI
- **UI framework**: Bootstrap, Tailwind CSS
- **Web framework**: ASP.NET, Express, Laravel, Ruby on Rails, Django
- **Web server**: Nginx, Apache, LiteSpeed, Microsoft IIS
- **Programming language**: PHP
- **Consent**: OneTrust, Cookiebot, CookieYes, Usercentrics, Termly, iubenda, Osano, Complianz
- **Search**: Algolia
- **Fonts**: Font Awesome, Google Fonts, Adobe Fonts
- **Maps**: Google Maps
- **Video**: YouTube, Vimeo
- **Page builder**: Elementor, Divi
- **SEO**: Yoast SEO, Rank Math
- **Monitoring**: Sentry, New Relic, LogRocket
- **Scheduling and forms**: Calendly
- **JavaScript CDN**: jsDelivr, cdnjs, unpkg, Google Hosted Libraries

Every match carries its `confidence` and the `evidence` that matched ("script src contains cdn.shopify.com", "meta generator WordPress 6.5.2", "cookie \_shopify_y"), so you can check any claim.

### Input

| Field | Type | Default | Description |
|---|---|---:|---|
| `datasetId` | string | *(one of `datasetId`, `websites` or `websitesFileUrl`)* | Your Google Maps Scraper (or any scraper) results: the dataset of a finished run. Use `{{resource.defaultDatasetId}}` in an integration. Read with your own account's access, no token needed; up to 20,000 rows. |
| `datasetWebsiteField` | string | *(automatic)* | Column of the dataset that holds the website. Automatic: the first non-empty of `website`, `websiteUrl`, `web`, `url`, `domain`, `homepage`, `companyWebsite`, `company_website`, `site`. |
| `datasetPassthroughFields` | string\[] | `[]` | Extra columns to copy into `source`, on top of the always-copied Google Maps columns. At most 15. |
| `websites` | string\[] | *(prefilled sample)* | Domains, URLs or email addresses, one per line or a whole column. Up to 20,000. The prefilled sample is ignored when you choose a dataset. |
| `websitesFileUrl` | string | *(none)* | https link to a `.csv` or `.txt` file of websites. |
| `onlyWithEmail` | boolean | `true` | Deliver only businesses with an email found. Off: phone-only businesses (charged) and free explanatory rows too. |
| `requireTech` | string\[] | `[]` | Deliver only businesses running at least one of these technologies. |
| `excludeTech` | string\[] | `[]` | Drop businesses running any of these technologies. |
| `includeNoWebsiteRows` | boolean | `true` | Return dataset rows with no usable website as free `no-website` rows. They never count toward `maxItems`. |
| `maxItems` | integer | 100 | 1-10,000. Hard cap on business rows delivered in the run; also your cost cap (at most `maxItems` x $0.005). |
| `maxPagesPerSite` | integer | 6 | 1-20. Pages read per website, home page included. |
| `includePeople` | boolean | `true` | Read the people the site publishes into `people`. |
| `outputFormat` | string | `full` | `full`: every field (default). `compact`: a smaller row for AI agents (see "Use with AI agents (MCP)"). Charging is identical. |
| `onlyChangedSinceLastRun` | boolean | `false` | Change mode: deliver a business only when it is new or its contacts or tech stack changed. |
| `stateStoreName` | string | `lead-enricher-state` | Name of the store that remembers each business's state in change mode. |

Optional **Notifications** (Discord, Telegram, Slack, Teams, Google Chat, ntfy, Pushover or a generic JSON webhook) are described below.

#### Input details

The Store form and the MCP tool schema show a short description for each field; the longer rules are here.

- **`datasetId`**: the dataset of a finished run of [Google Maps Scraper by Compass](https://apify.com/compass/crawler-google-places) (`compass/crawler-google-places`) or any scraper whose rows carry a website. To chain automatically, open the scraper's **Integrations** tab, add this Actor and set the field to `{{resource.defaultDatasetId}}`; by hand, copy the dataset ID from the run's **Storage** tab. A link to the dataset in Console or the API is accepted too. The dataset is read with your own account's access, so you never give a token. Up to 20,000 rows are read. Rows whose website is a Google Maps, social network or marketplace link are never read as a company website. It is added to `websites` and `websitesFileUrl` and de-duplicated with them.
- **`datasetWebsiteField`**: the column is matched exactly as spelled. Left empty, the first non-empty of `website`, `websiteUrl`, `web`, `url`, `domain`, `homepage`, `companyWebsite`, `company_website`, `site` is used (Google Maps Scraper uses `website`). A value that is a Google Maps, social network (Facebook, Instagram, LinkedIn, X, YouTube, TikTok, ...) or marketplace link is never read as a website, whichever column it is in.
- **`datasetPassthroughFields`**: these Google Maps columns are always copied into `source` (whichever the row has): `title`, `categoryName`, `address`, `city`, `phone`, `totalScore`, `reviewsCount`, `url`, `placeId`. List any further columns here (for example `plusCode`, `state`, `postalCode`, `price`), at most 15. Text is cut at 2,000 characters; a list or object is copied as JSON text. `source` also always has `datasetRow`, `sourceRowCount` and `otherRows` (at most 20); those three names are reserved. When several rows have the same website, the first row's columns are kept.
- **`websites`**: paste anything, one per line or a whole spreadsheet column or CSV row. Each entry may be a domain (`plausible.io`), a URL with a path, query or `www` (`https://www.mailerlite.com/contact`), an email address (its domain is used; free-mail addresses such as gmail, outlook or yahoo are skipped), or several of these on one line separated by commas, semicolons, spaces or tabs. The home page is always read first, then the contact, impressum, about, team, leadership, support and locations pages of the same site (up to `maxPagesPerSite`). Entries are de-duplicated by registrable domain. Entries that cannot be used are never an error: they are skipped and listed, with the reason, in the run status and in the `INPUT_REPORT` record of the run's key-value store. The three prefilled sample websites are a first test run: they are ignored when you choose a dataset and leave them unchanged.
- **`websitesFileUrl`**: for example a Google Sheets "Publish to web" CSV link (File > Share > Publish to web > "Comma-separated values (.csv)"), a Dropbox shared link or a GitHub "Raw" link. The file is downloaded once (at most 5 MB and 20,000 lines) and every cell is read exactly like an entry of `websites`. The link must open without signing in. The file is your own input list, so `robots.txt` is not applied to downloading it; it is never requested from private or internal addresses. The businesses' websites are still read politely, with `robots.txt` checked for every page.
- **`onlyWithEmail`**: on by default. A business with no email (phone only, nothing found, blocked, unreachable) is dropped before delivery, so it is not in your dataset and not charged. Off: businesses with a phone number only (charged, a phone counts as a lead) and, free of charge, rows that show why a site gave nothing (`no-contacts-found`, `blocked`, `unreachable`, `robots-disallowed`).
- **`requireTech`**: applied before charging, matched ignoring case and punctuation, against the technologies found on the home page. A name this Actor cannot detect is an input error with suggestions. Businesses whose site could not be read have no technologies, so they are dropped when this is set.
- **`maxItems`**: rows removed by the filters are not delivered and do not count; rows of businesses with nothing found (only when `onlyWithEmail` is off) are delivered free and count. To keep a run bounded when filters remove many rows, at most 10 x `maxItems` websites (at least 100) are read per run.
- **`maxPagesPerSite`**: contact-like and team-like pages are read first, in many languages (contact, kontakt, contacto, contatti, impressum, mentions legales, about, team, leadership, equipo, equipe, ueber uns, support, locations, ...). Each site is read at about one page per second, 5 sites at a time. The technology stack is detected on the home page, which is always read.
- **`includePeople`**: only what the site publishes is used: no address is ever guessed or built from a name. People alone (no email or phone) do not make a lead and are not charged.
- **`onlyChangedSinceLastRun`**: the first run delivers every lead and remembers it; later runs deliver only what is new or changed (`changeType` is `new` or `changed`, `changedFields` says what). A site that is blocked or down on a later run keeps its last known state and is not delivered.

### Google Maps owner names? People and job titles from team pages

The `people` list holds the people a business's own website publishes on its team, about or leadership pages. They are not guaranteed to be the owners: an owner or founder appears only when the site names them as such.

With **Include the people behind the business** on (the default), each row has a `people` list: `{name, title, email, linkedin, sourceUrl}` for every person the site publishes as part of the business. Only what the site publishes is used:

- **JSON-LD**: an Organization's `employee` or `founder`, and a `Person` that states a `jobTitle`.
- **Team cards** on team, about and leadership pages: a repeated block with a name heading and a job-title line. A LinkedIn `/in/` link inside a person's card becomes their `linkedin`; one `mailto:` link in the card becomes their `email`.
- **`mailto:` links whose text is a person's name**.

It never guesses or builds an email address (no `first.last@company.com`), takes nothing from social networks and does not look people up. Testimonials, blog authors, product cards, speaker line-ups and footer links are left out. The aim is precision: you get fewer people than are on the page rather than wrong ones. People on their own (no email or phone) are listed but are not a lead and are not charged.

### Track new leads and changes (change mode)

Turn on **Only new or changed leads** and run the Actor on a schedule (for example after each weekly Google Maps run). A business is delivered - and charged - only when it is **new to the tracker** or its state changed: its emails, phones, social links, people (name plus job title) or technology set.

1. Save the input as a **Task** with `"onlyChangedSinceLastRun": true`.
2. Add a **Schedule**.
3. **The first run is a baseline**: it delivers every lead (`changeType: "baseline"`) and remembers it. You are charged normally for it.
4. Later runs deliver only `changeType: "new"` or `"changed"`, and `changedFields` lists which of `emails`, `phones`, `socials`, `people`, `tech` moved.

A site that is blocked or down on a later run keeps its last known state and is not delivered. A run where nothing changed **succeeds** with an empty dataset and charges nothing. Rows removed by your filters never touch the tracker, so a business that starts to match later arrives as `new`. `no-website` rows are not delivered in change mode. State is kept in a named key-value store (`stateStoreName`), so it survives between runs.

#### Notifications

Fill in one or more destinations in the **Notifications** section and each run's delivered leads are sent to your chat or phone, for example **"New lead: Acme Dental — 2 emails, Shopify"**, or **"Lead update: Acme Dental — emails changed, tech stack changed"** for a changed business. Only leads that were delivered and charged are sent, never free rows, never filtered ones, and in change mode never the baseline run. Notifications are free.

### Send results to a Google Sheet or CRM

Save the input as a **Task**, then in the Task's **Integrations** tab add **Google Sheets** (appends the dataset's rows to a spreadsheet; the flat columns `companyName`, `domain`, `primaryEmail`, `status` and the nested objects, which export as JSON text or dotted columns, are easy to filter), or **Make**, **Zapier** or **n8n** to push each lead into a CRM (HubSpot, Pipedrive, Salesforce, Zoho, Airtable, Notion). Map `primaryEmail`, `people[0].name`, `people[0].title`, `source.title`, `domain`, `phones[0].phone` and `techStack.ecommerce` to the CRM's fields. Or download the dataset as **CSV** from the run's Storage tab and use the CRM's own import.

### Use it as an API

```python
from apify_client import ApifyClient

client = ApifyClient("YOUR_APIFY_TOKEN")

run = client.actor("lukehunter/lead-enricher").call(
    run_input={"datasetId": "WkzbQMuFYuamGv3YF", "requireTech": ["Shopify"], "maxItems": 500}
)

for lead in client.dataset(run["defaultDatasetId"]).iterate_items():
    print(lead["source"]["title"], lead["primaryEmail"], lead["techStack"]["ecommerce"])
```

### Use with AI agents (MCP)

This Actor works as a tool for Claude, ChatGPT, Cursor and other MCP clients through [Apify's MCP server](https://mcp.apify.com). The only input a call needs is `websites` (or a `datasetId` from a Google Maps run).

**Claude Desktop** (`claude_desktop_config.json`), with your own token from Apify Console > Settings > API & Integrations:

```json
{
  "mcpServers": {
    "apify-leads": {
      "command": "npx",
      "args": [
        "-y", "mcp-remote",
        "https://mcp.apify.com?tools=lukehunter/lead-enricher",
        "--header", "Authorization: Bearer ${APIFY_TOKEN}"
      ],
      "env": { "APIFY_TOKEN": "<YOUR_APIFY_TOKEN>" }
    }
  }
}
```

Clients that support OAuth (Claude.ai, VS Code) can connect with just the URL `https://mcp.apify.com?tools=lukehunter/lead-enricher` and sign in to Apify. Never paste a real token into a shared file or chat.

**Example.** You ask: "Get me a contact email, the people and the tech stack for plausible.io and mailerlite.com." The agent calls the tool `lukehunter--lead-enricher` with:

```json
{
  "websites": ["plausible.io", "mailerlite.com"],
  "outputFormat": "compact",
  "maxItems": 10
}
```

`outputFormat: "compact"` returns a small row per business: `companyName`, `domain`, `primaryEmail`, `emails` (plain addresses, at most 5), `phones` (at most 3), the `socials` found, `people` (name and title, at most 5), a `techStack` summary (`cms`, `ecommerce`, `analytics`, `marketing`), `leadFlags`, `status` and, for dataset runs, `source.title`, so the result fits an agent's context. It is charged exactly like the default `"full"` output: $0.005 per lead with at least one email or phone, nothing for sites where none was found. Set `maxItems` to cap spend. In change mode, compact rows also keep `changeType` and `changedFields`.

### Pricing and cost control

Pay per lead, and only for a lead with something in it.

**$0.005 per business with at least one email or phone - 1,000 leads = $5.**

| Leads | Cost |
|---:|---:|
| 3 | $0.015 |
| 100 | $0.50 |
| 1,000 | $5.00 |
| 10,000 | $50.00 |

There is no charge for starting a run. One business is one charge, however many emails, phones and people it has. **Not charged:** a business removed by your filters (it is not delivered at all), a dataset row with no website, a site where nothing was found, a site that blocked the request, was unreachable or disallows reading in `robots.txt`, people with no email or phone, a business that did not change (change mode), duplicates, and reading the dataset. `maxItems` is a hard upper bound on cost (at most `maxItems` x $0.005), and the Actor never delivers a charged row your run's maximum-cost setting cannot pay for.

### Limitations

- **Only what businesses publish on their own sites.** If a site shows no email or phone (many show only a contact form), the row says `no-contacts-found`. The Actor does not guess addresses, look people up, verify that a mailbox exists, or search other sites. No email is ever built from a name or a pattern.
- **Only the pages it reads.** By default six pages per site. Contacts on pages that are not linked as contact, about, team, legal, support or locations pages, or beyond the page limit, are not found. Raise `maxPagesPerSite` (up to 20).
- **No logged-in sources.** It does not log in anywhere, and it never requests Google Maps or any social network: the dataset is only a list of websites and your own columns.
- **robots.txt is respected** for every website, page by page; a disallowed page is not read (the reason is in `statusReason`).
- **Sites can block automated requests.** If a site answers HTTP 401/403/429, shows a bot check or refuses `robots.txt`, the row is `blocked` and nothing is charged. The Actor does not use proxies, rotate identities, solve CAPTCHAs or try to get around a block.
- **No JavaScript rendering.** Pages that build their content in the browser may show nothing in their HTML; contacts and technologies loaded later by scripts are not found. The tech stack comes from the home page only and from what that page's HTML, headers and cookies reveal, so a tool added only through a tag manager after load can be missed.
- **Technology detection is conservative** (precision over recall) and limited to the 166 technologies listed above.
- **People: precision over recall.** A team shown only as photos, in a slider script or an image, or with unusual titles can be missed.
- **`kind` is a judgement from the published address**: a department mailbox with an unusual name is labelled `role`, and a person's address that is neither `first.last` nor a common first name nor tied to a named person on the site is labelled `role` too.
- **Phone numbers** come from `tel:` links, JSON-LD and clearly labelled text; `e164` is `null` unless the country is clear.
- **A business whose "website" is a Facebook or Instagram page** is a `no-website` row, not crawled.
- **Dataset input** must be a dataset your account can open (the default dataset of a run in the same account is what an integration passes). At most 20,000 rows are read per run; `maxItems` defaults to 100, so raise it.
- **Speed:** about one page per second per site and five sites at a time, so 1,000 sites at six pages each take roughly 25-35 minutes.
- This is an independent tool, not affiliated with Google or any company whose website it reads. You are responsible for using the collected data in line with the law and the sites' terms.

### Data protection

This Actor only reads what a business publishes itself on its own website, keeps the source page of every contact and person, and respects `robots.txt`. It does not log in, does not collect personal social profiles (only profile links a company's own site publishes, and a person's LinkedIn link only when their own team card on that site links it), does not guess or build email addresses from names, and does not look anything up outside the sites you give it (the only other lookup is the public DNS MX record of an email's domain). Your dataset is read only to get the list of websites and the columns you chose to copy back; nothing is collected from Google Maps or any other site it came from, and you stay responsible for the lawful basis for that source data too.

**The `people` field and personal email addresses (`kind: "person"`) are personal data** under the GDPR and similar laws, even though the business published them. **You are responsible for having a lawful basis to use the data**: depending on where you and your recipients are, for example a documented legitimate-interest assessment under the GDPR and compliance with CAN-SPAM, PECR/ePrivacy and similar rules before you send marketing email or call. Honour opt-outs and deletion requests, keep a record of where each contact came from (`sourceUrl` is there for that) and prefer role and generic mailboxes for cold outreach. If you receive a deletion request about data this Actor returned, delete it from your copies.

### Use it from your code, Make, Zapier or n8n

Built for automated pipelines: small runs, stable field names, one row per item, and you pay only for rows actually delivered.

**One API call that runs the Actor and returns the data** (replace `$APIFY_TOKEN` with your token from Apify Console → Settings → Integrations):

```bash
curl -s -X POST "https://api.apify.com/v2/acts/lukehunter~lead-enricher/run-sync-get-dataset-items?token=$APIFY_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"websites": ["plausible.io", "mailerlite.com", "stumptowncoffee.com"], "maxItems": 20, "maxPagesPerSite": 6}'
```

**JavaScript** (`npm i apify-client`):

```js
import { ApifyClient } from 'apify-client';
const client = new ApifyClient({ token: process.env.APIFY_TOKEN });
const run = await client.actor('lukehunter~lead-enricher').call({
  "websites": [
    "plausible.io",
    "mailerlite.com",
    "stumptowncoffee.com"
  ],
  "maxItems": 20,
  "maxPagesPerSite": 6
});
const { items } = await client.dataset(run.defaultDatasetId).listItems();
```

**Python** (`pip install apify-client`):

```python
import os
from apify_client import ApifyClient
client = ApifyClient(os.environ['APIFY_TOKEN'])
run = client.actor('lukehunter~lead-enricher').call(run_input={"websites": ["plausible.io", "mailerlite.com", "stumptowncoffee.com"], "maxItems": 20, "maxPagesPerSite": 6})
items = client.dataset(run['defaultDatasetId']).list_items().items
```

**Schedules, webhooks and no-code tools:** save your input as a Task, add a Schedule (e.g. hourly or daily), and attach a webhook or one of the Integrations tab's connectors (Make, Zapier, n8n, Google Sheets, Slack) to push each run's results into your own system.

**Only new items each run:** set `"onlyChangedSinceLastRun": true` on a scheduled Task and every run after the first delivers, and charges for, only items it hasn't delivered before for that same input. That makes it a ready-made change feed.

**Reliability:** an automated test runs this Actor's parser against the live site every day, and failures are fixed fast.

**Feedback:** an honest rating on the Store page, good or bad, helps others decide if this Actor fits their needs. Missing a field or found a problem? Open an issue on the Actor page.

### FAQ

**How is this different from Google Maps scrapers that return contact details?**
It enriches **any existing results**: keep using the Google Maps scraper you already like (or any scraper that gives a website per row) and add this step, instead of re-scraping Google Maps with a different tool. On top of emails and phones it adds **the technology stack and the named people with job titles**, flags such as `runsAds` and `isEcommerce`, and filters (only Shopify stores with an email). And **you pay per lead found** ($0.005 per business with an email or phone), not per Google Maps place.

**Does it scrape Google Maps?**
No. It reads the website each business already has in your dataset. It never requests Google Maps.

**What if a business has no website?**
Its row comes back free with status `no-website` and your `source` fields (phone, address, rating, ...), so you can follow up another way. Turn this off with `includeNoWebsiteRows`.

**Why did I get fewer rows than my dataset has?**
By default only businesses with an email are delivered (`onlyWithEmail`), dataset rows that share a website are one business, and `maxItems` caps the run (default 100). The run status says how many were filtered, how many had no website and how many were not read.

**Are the emails verified?**
No mailbox is contacted. `emailDomainHasMx` tells you whether the domain accepts mail at all. The emails are the ones the business published on its own site.

**Which technologies can the filters use?**
Any of the 166 listed above. A misspelled name is an input error with suggestions.

**Can I run it on a schedule?**
Yes: turn on change mode (see above) to receive only new or changed leads, optionally with notifications.

### Related Actors

Other data tools from the same developer, built to the same standard: official or public sources, hard cost caps, and honest documentation of limits.

- **[Domain Email Provider Lookup](https://apify.com/lukehunter/email-provider-mx-lookup)**: the email provider (Google Workspace, Microsoft 365, Zoho and more), MX, SPF and DMARC setup of any domain list, with provider-change alerts.
- **[French Company Leads](https://apify.com/lukehunter/french-companies-sirene)**: French companies (entreprises) from the official Sirene-based API, filtered by NAF code, department, headcount and creation date, with SIREN/SIRET, officers and new-company alerts.
- **[Poland KRS Company Lookup](https://apify.com/lukehunter/poland-krs-company-lookup)**: Polish companies from the official KRS API by KRS number: NIP, REGON, address, share capital, PKD and the board as published, with optional VAT status.
- **[Website Contact Scraper](https://apify.com/lukehunter/company-contact-scraper)**: emails, phone numbers and social profile links from any list of company domains, with the page each came from and a changed-contacts-only mode for scheduled runs.
- **[Impressum Scraper](https://apify.com/lukehunter/impressum-scraper)**: legal entity data from the imprint of any company website in Germany, Austria and Switzerland: legal name and form, managing directors, HRB/HRA or FN register, VAT id and address, with a changed-data-only mode for scheduled runs.
- **[Website Tech Stack Detector](https://apify.com/lukehunter/website-tech-stack-detector)**: the technologies a website runs (CMS, ecommerce platform, analytics, ad pixels, chat, payments, CDN, frameworks) from its home page, with the evidence for each match and a changed-technologies-only mode for scheduled runs.
- **[Federal Register Rule Effective-Date Radar](https://apify.com/lukehunter/federal-rule-effective-date-radar)**: final federal rules about to take effect, with citations, dockets and urgency.
- **[UK Tenders Monitor](https://apify.com/lukehunter/uk-tenders-monitor)**: new UK public-sector tenders from Contracts Finder and Find a Tender, filtered by CPV code, value, region and buyer, with Slack, Discord and Telegram alerts.

# Actor input Schema

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

Optional but the main input. ID of a finished Google Maps Scraper (compass/crawler-google-places) or other dataset whose rows have a website, e.g. "AbCdEf123456". Each site is read for emails, phones, socials, people and tech stack; one lead row per business, your columns under `source`. To chain, set it to {{resource.defaultDatasetId}} in the scraper's Integrations tab. No token needed; up to 20,000 rows. No-website rows come back free as `no-website`.

## `datasetWebsiteField` (type: `string`):

Name of the dataset column holding the website, exactly as spelled, e.g. "website". Only used with a dataset. Leave empty to auto-detect (website, websiteUrl, web, url, domain, homepage, companyWebsite, company_website, site; the first non-empty one wins). Google Maps, social network and marketplace links are never read as a company website.

## `datasetPassthroughFields` (type: `array`):

Only used with a dataset. Extra dataset columns to copy into each lead's `source` object, e.g. \["plusCode", "state", "postalCode"], max 15 columns. These are always copied: title, categoryName, address, city, phone, totalScore, reviewsCount, url, placeId. Text is cut at 2,000 characters. datasetRow, sourceRowCount and otherRows are always added and reserved. For rows sharing a website the first row's columns are kept.

## `websites` (type: `array`):

Businesses to enrich when you have no dataset, as domains or URLs, e.g. \["plausible.io", "https://www.mailerlite.com/contact"]. One per line or a pasted column. Home page first, then contact, impressum, about and team pages. De-duplicated by domain; up to 20,000 per run. Unusable entries are listed in INPUT_REPORT. The prefilled sample sites are ignored when a dataset is given.

## `websitesFileUrl` (type: `string`):

Optional https link to a public .csv or .txt file of company websites, e.g. "https://example.com/companies.csv" (a Google Sheets "Publish to web" CSV link, Dropbox shared link or GitHub Raw link works). Max 5 MB / 20,000 lines; every cell is read like an entry of the list above, and the result is merged and de-duplicated with it. Must open without signing in.

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

true (default): deliver a business only if an email was found, e.g. a phone-only business is dropped uncharged. Set false to also get phone-only leads (charged) and, free, rows showing why a site gave nothing (no-contacts-found, blocked, unreachable, robots-disallowed). Dataset rows with no website are controlled by "Return rows with no website".

## `requireTech` (type: `array`):

Optional filter applied before charging: deliver only businesses whose home page runs at least ONE of these technologies, e.g. \["Shopify"] for Shopify stores with an email, or \["WooCommerce", "Klaviyo", "HubSpot"]. Names match ignoring case and punctuation; an unknown name is an input error with suggestions. Sites that could not be read are dropped when this is set. Leave empty for no filter.

## `excludeTech` (type: `array`):

Optional filter, applied before charging: drop businesses whose website runs ANY of these technologies, for example \["Wix"] to skip Wix sites, or \["Klaviyo", "Mailchimp"] to skip businesses that already run email marketing. Same names as "Only sites that run"; a technology cannot be in both lists. Leave empty for no filter.

## `includeNoWebsiteRows` (type: `boolean`):

true (default), only with a dataset: a row with no usable website (empty or only a Google Maps or social link) comes back free with status `no-website` and its `source` fields, e.g. so you can phone those leads instead. These rows never count toward Maximum leads and are never charged. Set false to leave them out.

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

Maximum business rows delivered, integer 1-10,000, default 100. Also your cost cap: $0.005 per row with an email or phone, so at most maxItems x $0.005. For a quick test use 5 (e.g. maxItems 5). Filtered-out rows do not count; free rows with nothing found do. At most 10 x maxItems websites (min 100) are read per run.

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

Pages read per website (home page included), integer 1-20, default 6, e.g. 3 for speed. Contact and team pages are read first, in many languages. More pages find more addresses and people on large sites. The tech stack is detected on the home page regardless of this setting.

## `includePeople` (type: `boolean`):

true (default): also return people the site publishes (name, job title, and an email or LinkedIn link only where the page itself links one), e.g. {"name": "Jane Doe", "title": "CEO"}. People alone do not make a lead and are not charged. No address is ever guessed. Set false for just emails, phones, socials and tech stack.

## `outputFormat` (type: `string`):

"full" (default): every field. "compact": a much smaller row for AI agents: companyName, domain, primaryEmail, emails (addresses, max 5), phones (max 3), socials found, people (name, title, max 5), a tech summary (cms, ecommerce, analytics, marketing), leadFlags, status and source.title. Charging is identical in both formats. Example: "compact" when results are read into an LLM context.

## `onlyChangedSinceLastRun` (type: `boolean`):

true returns a business only when it is new or its emails, phones, social links, people or technologies changed since the last run (`changeType` new/changed, `changedFields`). Default false: a one-off run returns every lead. Agents making one-off calls should leave it false. Example: run weekly on a Schedule with true; unchanged businesses are not delivered or charged. The first run delivers all.

## `stateStoreName` (type: `string`):

Only used when change mode is on. Name of the key-value store that remembers each business's last state between scheduled runs, e.g. "lead-enricher-state". Letters, numbers and hyphens only. Leave the default unless several tasks need separate histories.

## `discordWebhookUrl` (type: `string`):

Post results to a Discord channel. In Discord: Server Settings -> Integrations -> Webhooks -> New Webhook -> choose the channel -> Copy Webhook URL. Example: https://discord.com/api/webhooks/<id>/<token>. Leave empty to skip Discord.

## `telegramBotToken` (type: `string`):

Post results to Telegram. Create a bot by chatting with @BotFather on Telegram (send /newbot) and paste the token it gives you, e.g. 123456789:AA.... Needs "Telegram chat ID" below. Leave empty to skip Telegram.

## `telegramChatId` (type: `string`):

Where the bot should post: a numeric chat id (e.g. 123456789, or -1001234567890 for a group or channel) or a public @channelusername. The bot must be added to that chat or channel first (as an administrator for a channel), and for a private chat you must press Start on the bot. Needs "Telegram bot token" above.

## `slackWebhookUrl` (type: `string`):

Post results to a Slack channel (Block Kit cards, up to 10 per message). Create an incoming webhook at api.slack.com/apps -> Create New App -> Incoming Webhooks -> Add New Webhook to Workspace. Example: https://hooks.slack.com/services/T000/B000/XXXX. Leave empty to skip Slack.

## `slackCompatibleWebhookUrl` (type: `string`):

Post results to a Mattermost or Rocket.Chat incoming webhook, which accept Slack-style webhook payloads. Must be a public https:// address, e.g. https://chat.example.com/hooks/abc123 (no localhost, private IP or .local/.internal hosts). These apps do not render Slack Block Kit, so they get a plain Markdown list. For real Slack use "Slack webhook URL" above. Leave empty to skip.

## `teamsWebhookUrl` (type: `string`):

Post results to a Teams channel as an Adaptive Card. In Teams: channel -> ... -> Workflows -> "Post to a channel when a webhook request is received" -> copy the URL. The host must end in .logic.azure.com or .powerplatform.com, e.g. https://prod-12.westeurope.logic.azure.com/workflows/... The retired Office 365 connector webhooks are not supported. Leave empty to skip Teams.

## `googleChatWebhookUrl` (type: `string`):

Post results to a Google Chat space. In the space: Apps & integrations -> Webhooks -> Add webhook -> copy the URL. Example: https://chat.googleapis.com/v1/spaces/<space>/messages?key=...\&token=.... Leave empty to skip Google Chat.

## `ntfyTopic` (type: `string`):

Get a phone push with no account via ntfy (ntfy.sh). Install the ntfy app, subscribe to a topic name, and enter the same name here. Anyone who knows the topic can read it, so pick a long, unguessable name (letters, numbers, - and \_), e.g. acme-leads-7k2x9q. At most 10 individual pushes per run, then one "...and N more". Leave empty to skip ntfy.

## `ntfyServer` (type: `string`):

Only used with "ntfy topic". Leave as https://ntfy.sh, or enter the public https:// address of your own ntfy server, e.g. https://ntfy.example.com.

## `ntfyToken` (type: `string`):

Only used with "ntfy topic", for a self-hosted server or a reserved topic that needs a login. An access token from ntfy (starts with tk\_, e.g. tk_abc123...). Leave empty for open topics.

## `pushoverUserKey` (type: `string`):

Get a phone push via Pushover. Your user key (30 characters, e.g. uQiRzpo4DXghDmr9QzzfQu27cmVRsG) is shown on the Pushover dashboard (pushover.net). Needs "Pushover app token" below. At most 10 individual pushes per run, then one "...and N more". Leave empty to skip Pushover.

## `pushoverAppToken` (type: `string`):

The API token of a Pushover application you create at pushover.net/apps/build (name it after this Actor), e.g. azGDORePK8gMaC0QOYAMyEEuzJnyUi. Needs "Pushover user key" above.

## `webhookUrl` (type: `string`):

POST the delivered rows as JSON to any public https:// URL: {source, actor, runId, count, items}. At most 100 rows per POST; every delivered row is sent regardless of the max-notifications cap. For example, route results to email, a spreadsheet or any app. Localhost, private IP and .local/.internal addresses are rejected. Leave empty to skip.

## `maxNotifications` (type: `integer`):

Caps how many results are sent to chat and phone channels per run (Discord, Telegram, Slack, Teams, Google Chat, ntfy, Pushover), 1-100, default 25 (e.g. 10). Extra results are still in the dataset. The generic webhook is not capped. Only used when a destination above is set.

## Actor input object example

```json
{
  "websites": [
    "plausible.io",
    "mailerlite.com",
    "stumptowncoffee.com"
  ],
  "onlyWithEmail": true,
  "requireTech": [],
  "excludeTech": [],
  "includeNoWebsiteRows": true,
  "maxItems": 100,
  "maxPagesPerSite": 6,
  "includePeople": true,
  "outputFormat": "full",
  "onlyChangedSinceLastRun": false,
  "stateStoreName": "lead-enricher-state",
  "ntfyServer": "https://ntfy.sh",
  "maxNotifications": 25
}
```

# Actor output Schema

## `leads` (type: `string`):

No description

## `techStack` (type: `string`):

No description

## `inputReport` (type: `string`):

No description

# 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 = {
    "websites": [
        "plausible.io",
        "mailerlite.com",
        "stumptowncoffee.com"
    ],
    "maxItems": 100,
    "maxPagesPerSite": 6
};

// Run the Actor and wait for it to finish
const run = await client.actor("lukehunter/lead-enricher").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 = {
    "websites": [
        "plausible.io",
        "mailerlite.com",
        "stumptowncoffee.com",
    ],
    "maxItems": 100,
    "maxPagesPerSite": 6,
}

# Run the Actor and wait for it to finish
run = client.actor("lukehunter/lead-enricher").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 '{
  "websites": [
    "plausible.io",
    "mailerlite.com",
    "stumptowncoffee.com"
  ],
  "maxItems": 100,
  "maxPagesPerSite": 6
}' |
apify call lukehunter/lead-enricher --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,lukehunter/lead-enricher"
        }
    }
}
```

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/dkK98gHyp2tn2rfBc/builds/gRMHA7qq4aXADBJuW/openapi.json
