# Company Contact Details Scraper - Emails, Phones, Socials (`lukehunter/company-contact-scraper`) Actor

Company contact details from any list of websites: emails from domains, website phone numbers, social media links and named people per company, CRM-ready. Paste domains or a CSV link, or chain a Google Maps dataset. $0.004 per company, only when contacts are found.

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

## Pricing

$4.00 / 1,000 company 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

## Company Contact Details Scraper - Emails, Phones, Socials

**Company contact details for sales teams, agencies, recruiters and growth teams who build B2B lead lists from a list of company domains.** Give it company websites (emails from domains, website phone numbers, website social links) and you get one row per company: **emails, phones, social links and the people behind the company** - the email addresses and phone numbers the company itself publishes, the names and job titles of the people its team, about and leadership pages list (with an email or LinkedIn profile only where the page links one), each with the page it was found on, plus the company's social profile links, name, postal address (when the site declares one), best contact page and whether its email domain accepts mail. Turn on **change mode** and run it on a schedule to hear only about companies whose contacts or people changed.

It takes your list the way you have it: **paste a spreadsheet column** (domains, URLs or email addresses, messy is fine), give it **a link to a CSV file**, or **hand it another Actor's dataset** (for example the results of a Google Maps scraper, which have each business's website but usually no email: see "Enrich Google Maps results with emails"), and choose **one row per company** or **one row per contact** (CRM-ready: each email, phone and person on its own flat row, ready to import).

Pay-per-result: **$0.004 per company with an email, phone or named person found - 1,000 companies with contacts = $4**, in either output mode (never per row). Companies where nothing was found, that blocked the request, are unreachable or disallow reading in `robots.txt` are delivered so you can see why, and are **never charged**.

### Emails from domains: quick start (1 minute)

1. Open the **Input** tab and paste company domains, website URLs or email addresses (one per line, or a whole column or CSV row at once).
2. Click **Start**, then export the dataset to CSV/Excel/JSON or pull it through the API shown below.

```json
{
  "websites": ["plausible.io", "mailerlite.com", "stumptowncoffee.com"],
  "maxPagesPerSite": 6,
  "maxItems": 100
}
```

That prefilled example reads three company sites, about six pages each at one page per second, and costs at most $0.012 (only companies with an email, phone or named person are charged). Any page URL works too (`"https://www.mailerlite.com/contact"`): it is read as the first extra page after the home page.

#### Paste a column from a spreadsheet

Copy a column (or a whole row, or a messy mix) and paste it into **Company websites**. Each entry can be a domain, a URL with a path, query or `www.`, an **email address** (its company domain is read), or **several of these on one line** separated by commas, semicolons, spaces or tabs:

```json
{
  "websites": [
    "plausible.io, https://www.mailerlite.com/contact; stumptowncoffee.com",
    "jason@basecamp.com",
    "info@gmail.com"
  ]
}
```

That reads four companies: plausible.io, mailerlite.com (starting at its contact page), stumptowncoffee.com and basecamp.com. `info@gmail.com` is a free-mail address, not a company, so it is **skipped and reported**, not an error. Entries are de-duplicated by registrable domain (`www.example.co.uk`, `example.co.uk` and `sales@example.co.uk` are one company). Everything skipped - invalid entries, free-mail addresses (gmail, outlook, hotmail, yahoo, icloud, proton, gmx, web.de, mail.ru, qq, 163, aol, yandex, zoho mail), duplicates - is listed with its reason in the run status and in the `INPUT_REPORT` record of the run's key-value store. A bad entry never fails the run; only a list with nothing usable does.

#### Give it a file of domains

Have your list in a spreadsheet? Publish it and pass the link in **Website list file URL** (`websitesFileUrl`):

```json
{
  "websitesFileUrl": "https://docs.google.com/spreadsheets/d/e/YOUR-SHEET-ID/pub?output=csv",
  "outputMode": "contacts",
  "maxItems": 500
}
```

For Google Sheets use **File > Share > Publish to web**, choose the sheet and **Comma-separated values (.csv)**, and copy the link. A Dropbox shared link or a GitHub **Raw** link to a `.csv` or `.txt` file works too. The file is downloaded once (at most 5 MB and 20,000 lines), every cell is read exactly like an entry above (so a column of domains, a column of emails or a whole CRM export all work; header cells such as "Website" are skipped and reported), and the result is merged with `websites`. The link must open without signing in. **robots.txt is not applied to this download**: the file is your own input document, not a website being crawled. It is never fetched from private or internal addresses (IP addresses, localhost and internal names are refused, also after redirects). The companies' own websites are still read politely, with robots.txt checked for every page.

#### CRM-ready rows: one row per contact

Set **Output** to `contacts` and every email, phone and person becomes its own flat row, with the company columns repeated on each, so the dataset imports into a CRM or a spreadsheet as it is:

```json
{
  "websites": ["stumptowncoffee.com", "mailerlite.com/contact"],
  "outputMode": "contacts"
}
```

The price does not change: **$0.004 once per company with at least one contact, however many rows it produces.** See "CRM mode: one row per contact" below for the columns.

### Enrich Google Maps results with emails

Google Maps scrapers (for example [Google Maps Scraper by Compass](https://apify.com/compass/crawler-google-places), used here only as an example source) return each business's `website`, phone and address, but usually **no email**. This Actor takes that run's dataset as its input: it reads the `website` of every row, opens **each business's own website** (never Google Maps) for the emails, phones and people it publishes, and puts the fields of the source row next to the result in a `source` object, so you can join the enriched rows back to the Google Maps rows. **Any dataset with a website column works**, from any Actor.

#### Chain it automatically (Integrations)

1. In Apify Console open the source Actor (or its saved Task), for example your Google Maps Scraper, and go to **Integrations**.
2. Add an **Actor** integration, choose **Website Contact Scraper** (`lukehunter/company-contact-scraper`), and set the trigger to **Run succeeded**.
3. Use this as the integration's input:

```json
{
  "datasetId": "{{resource.defaultDatasetId}}"
}
```

`{{resource.defaultDatasetId}}` is filled in by Apify with the dataset of the run that just finished, so every Google Maps run is enriched as soon as it ends. Add the other inputs as usual, for example `"maxItems": 1000` (see the note on `maxItems` below) and `"outputMode": "contacts"`. If you prefer a webhook, use the event `ACTOR.RUN.SUCCEEDED` on the source Actor, call this Actor's run endpoint, and put the same `datasetId` in the payload template.

#### Or do it by hand

1. Run the Google Maps scraper and wait until it has finished.
2. Open the run's **Storage** tab (or the **Output** tab) and copy the **dataset ID**, the code at the end of the dataset's address (`.../storage/datasets/WkzbQMuFYuamGv3YF`). A link to the dataset also works.
3. Start Website Contact Scraper and paste it into **Enrich a dataset: dataset ID** (`datasetId`):

```json
{
  "datasetId": "WkzbQMuFYuamGv3YF",
  "maxItems": 1000,
  "datasetPassthroughFields": ["title", "placeId", "address", "totalScore"]
}
```

**Set `maxItems` to at least the number of businesses you want enriched** (default 100, up to 5,000 companies per run). It is also your cost cap: at most `maxItems` x $0.004.

#### What you get

One result row per company (a business with several locations is one company), exactly the rows described below, plus `source`. An illustrative row (an invented business):

```json
{
  "inputUrl": "https://www.corner-bakery-example.co.uk/",
  "domain": "corner-bakery-example.co.uk",
  "companyName": "Corner Bakery",
  "emails": [{ "email": "hello@corner-bakery-example.co.uk", "type": "generic", "sourceUrl": "https://www.corner-bakery-example.co.uk/contact" }],
  "phones": [{ "phone": "020 7946 0000", "e164": "+442079460000", "sourceUrl": "https://www.corner-bakery-example.co.uk/contact" }],
  "status": "ok",
  "source": {
    "title": "Corner Bakery",
    "placeId": "ChIJexample0000000000000000",
    "address": "1 Example Street, London",
    "totalScore": 4.6,
    "reviewsCount": 212,
    "url": "https://www.google.com/maps/search/?api=1&query=Corner%20Bakery&query_place_id=ChIJexample0000000000000000",
    "datasetRow": 41,
    "sourceRowCount": 3,
    "otherRows": ["ChIJexample0000000000000001", "ChIJexample0000000000000002"]
  }
}
```

(The row also has all the usual columns: `finalDomain`, `people`, `socials`, `address`, `emailDomainHasMx`, `pagesCrawled`, `scrapedAt`, ...)

- **`source`** holds the columns you chose in **Dataset: columns to copy into `source`** (`datasetPassthroughFields`). Leave it empty for automatic: `title`, `placeId`, `address`, `city`, `countryCode`, `phone`, `categoryName`, `totalScore`, `reviewsCount` and `url`, whichever the row has. At most 15 columns, and 2,000 characters per value (longer text is cut; a list or object is copied as JSON text). Join on `source.placeId` (or `source.datasetRow`, the row's number in the source dataset, counting from 0).
- **Chains.** When several rows have the same website (a chain's branches), they are **one company**: one result row, charged once. It keeps the **first** row's `source` columns, `source.sourceRowCount` says how many rows shared the website, and `source.otherRows` lists the `placeId` (else the title) of the others, at most 20.
- **In `contacts` output** every row of a company carries the same `source` object.
- **Company websites you also list** in `websites` or a file are merged in (one company per registrable domain); a company that came only from there has `source: null`.

#### Which row gives the website

The website is taken from the column you name in **Dataset: website column** (`datasetWebsiteField`), or else from the first of these columns that has a value: `website`, `websiteUrl`, `web`, `url`, `domain`, `homepage`, `companyWebsite`, `company_website`, `site`. A value that is a Google Maps link (a Google Maps Scraper row's `url` is one), a social network (Facebook, Instagram, LinkedIn, X, YouTube, TikTok, ...) or a marketplace link is **not a company website** and is skipped, never crawled. Every skipped row, with its row number in the dataset (counting from 0) and the reason (no website, not a company website, invalid value, free-mail address, a duplicate of an earlier row, over the 5,000-company limit), is listed in the `INPUT_REPORT` record of the run's key-value store, and the run status says how many.

#### Cost

The same as always: **$0.004 per company with an email, phone or named person found**, never per source row, never for companies where nothing was found, and nothing for reading the dataset. 1,000 Google Maps rows that point at 700 different websites cost at most $2.80 (700 x $0.004), and only for the ones that publish a contact. Rows without a website are not delivered or charged.

### What you can use it for

- **B2B lead lists.** Turn a list of company domains from a directory, a conference page or another Actor into contact details you can work with: role mailboxes (`info@`, `sales@`), the named people with their job titles (founders, directors, heads of department), phone numbers and LinkedIn/X/Facebook pages.
- **Add emails to Google Maps results.** Chain it after a Google Maps scraper (or any Actor whose rows carry a website) and get the emails, phones and people of every business, with the source row's title and place ID attached so the two datasets join. See "Enrich Google Maps results with emails".
- **Enrich a CRM or spreadsheet of domains.** Paste the domain (or email) column, or point the Actor at the published CSV, then export the dataset - or switch to one row per contact and import it straight into your CRM - and merge it back on `domain`. `emailDomainHasMx` tells you which email domains accept mail before you send anything.
- **Agency prospecting.** Find the contact page, impressum or about-page details of local businesses in any country: the crawler reads contact pages in English, German, French, Spanish, Italian, Dutch, Portuguese and more.

### Example output

One company row, as the parser produced it from the real stumptowncoffee.com home page (captured 2026-10-04; a live run reads up to six pages, so it may list more, and `emailDomainHasMx` and `scrapedAt` are filled in at run time). Its `people` entry is real: the home page's JSON-LD names the founder.

```json
{
  "inputUrl": "www.stumptowncoffee.com",
  "domain": "stumptowncoffee.com",
  "finalDomain": "stumptowncoffee.com",
  "companyName": "Stumptown Coffee",
  "emails": [
    { "email": "info@stumptowncoffee.com", "type": "generic", "sourceUrl": "https://www.stumptowncoffee.com/" }
  ],
  "phones": [
    { "phone": "877-711-3385", "e164": null, "sourceUrl": "https://www.stumptowncoffee.com/" }
  ],
  "people": [
    { "name": "Duane Sorenson", "title": "Founder", "email": null, "linkedin": null, "sourceUrl": "https://www.stumptowncoffee.com/" }
  ],
  "contactPageUrl": null,
  "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",
    "pinterest": "https://www.pinterest.com/stumptowncoffee",
    "github": null
  },
  "address": {
    "street": "700 SW 5th Ave, 3rd Floor, #400",
    "locality": "Portland",
    "region": "OR",
    "postalCode": "97214",
    "country": null
  },
  "emailDomainHasMx": true,
  "pagesCrawled": 1,
  "status": "ok",
  "statusReason": null,
  "changedFields": null,
  "scrapedAt": "2026-10-04T08:00:00.000Z"
}
```

`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. `contactPageUrl` is `null` because this one-page capture did not show a contact page; in a live run it is the best contact page found (for example `https://plausible.io/contact`). A company whose home page redirects to another domain shows both: for `www.the-ivy.co.uk` (real capture) `domain` is `the-ivy.co.uk` and `finalDomain` is `ivycollection.com`, its address `groupdining@theivy-collection.com` is kept because it is published in a `mailto:` link, and its phone is `{"phone": "+44 20 7836 4751", "e164": "+442078364751"}`.

And what a team page adds. This `people` list is **illustrative** (an invented firm, modelled on a typical WordPress "Meet the team" page; the Actor's tests use pages like this one), showing the three optional fields:

```json
{
  "people": [
    { "name": "Helen Marsh", "title": "Managing Director", "email": null, "linkedin": "https://www.linkedin.com/in/helen-marsh-acme", "sourceUrl": "https://www.acme-plumbing.co.uk/our-team/" },
    { "name": "Raj Patel", "title": "Head of Operations", "email": "raj.patel@acme-plumbing.co.uk", "linkedin": null, "sourceUrl": "https://www.acme-plumbing.co.uk/our-team/" },
    { "name": "Sophie O’Neill", "title": "Senior Heating Engineer", "email": null, "linkedin": null, "sourceUrl": "https://www.acme-plumbing.co.uk/our-team/" }
  ]
}
```

A site that was read but publishes no email, phone or named person is delivered, not charged, and says so (real capture of bluebottlecoffee.com's home page data):

```json
{
  "inputUrl": "bluebottlecoffee.com",
  "domain": "bluebottlecoffee.com",
  "finalDomain": "bluebottlecoffee.com",
  "companyName": "Blue Bottle Coffee",
  "emails": [],
  "phones": [],
  "people": [],
  "status": "no-contacts-found",
  "statusReason": "Read 1 page; none published an email address or phone number, or a named person."
}
```

(The other fields are delivered too, with empty or `null` values; they are left out here for space.)

### CRM mode: one row per contact

With **Output** set to `contacts` (`"outputMode": "contacts"`), the dataset has one flat row per contact found instead of one row per company. Nothing is nested, every column is always present (`null` when empty), and the same columns repeat on each row of a company:

| Columns | Contents |
|---|---|
| `type` | `email`, `phone`, `person` or `none` |
| Contact | `email`, `emailType` (`generic` or `other`), `phone`, `e164`, `personName`, `personTitle`, `personLinkedin`, `sourceUrl` (the page this contact was found on) |
| Company | `domain`, `finalDomain`, `inputUrl`, `companyName`, `contactPageUrl` |
| Address | `addressStreet`, `addressLocality`, `addressRegion`, `addressPostalCode`, `addressCountry` (JSON-LD PostalAddress only) |
| Socials | `linkedin`, `facebook`, `instagram`, `x`, `youtube`, `tiktok` (the company's own profiles as its site links them) |
| Status | `status`, `statusReason`, `changedFields` (change mode, joined with `, `), `scrapedAt` |

- An **email** row carries the address, a **phone** row the number and its `e164`, a **person** row the name and job title. **A person with an email is one row carrying both** (`type: "person"`, `email` filled); that address is not repeated as a separate email row.
- A company where nothing was found, that blocked the request or is unreachable gives **one row of `type: "none"`** carrying `status` and `statusReason`, so every company you asked about is accounted for.
- Rows of a company are delivered together, in the order emails, phones, people.

Real rows from the captured stumptowncoffee.com home page (the company columns are the same on each row; `scrapedAt` is filled in at run time):

```json
{ "type": "email", "domain": "stumptowncoffee.com", "companyName": "Stumptown Coffee", "email": "info@stumptowncoffee.com", "emailType": "generic", "phone": null, "e164": null, "personName": null, "personTitle": null, "personLinkedin": null, "sourceUrl": "https://www.stumptowncoffee.com/", "finalDomain": "stumptowncoffee.com", "inputUrl": "www.stumptowncoffee.com", "contactPageUrl": null, "addressStreet": "700 SW 5th Ave, 3rd Floor, #400", "addressLocality": "Portland", "addressRegion": "OR", "addressPostalCode": "97214", "addressCountry": null, "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", "status": "ok", "statusReason": null, "changedFields": null, "scrapedAt": "2026-10-04T08:00:00.000Z" }
{ "type": "phone", "domain": "stumptowncoffee.com", "companyName": "Stumptown Coffee", "email": null, "emailType": null, "phone": "877-711-3385", "e164": null, "personName": null, "personTitle": null, "personLinkedin": null, "sourceUrl": "https://www.stumptowncoffee.com/", "...": "company columns as above" }
{ "type": "person", "domain": "stumptowncoffee.com", "companyName": "Stumptown Coffee", "email": null, "emailType": null, "phone": null, "e164": null, "personName": "Duane Sorenson", "personTitle": "Founder", "personLinkedin": null, "sourceUrl": "https://www.stumptowncoffee.com/", "...": "company columns as above" }
```

**What you pay.** One charge of $0.004 per company with at least one email, phone or named person - the same as in company mode, whatever the number of rows. The company's other rows are free. `none` rows are free. **Maximum companies** (`maxItems`) counts companies, not rows, so it is still your cost cap. In **change mode**, a company whose contacts changed delivers **all** of its rows again (and is charged once); an unchanged company delivers nothing. Without `outputMode`, nothing changes: you get the one-row-per-company output described below.

### How it works

For each website 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: links whose path or text says contact, kontakt, contacto, contatti, impressum, mentions légales, about, team, our people, leadership, founders, equipo, équipe, über uns, support, locations and similar, best match first, up to **Pages per website**. Team pages rank just below contact and impressum pages (when people are on).
3. If the home page did not link a contact, impressum or team page, it reads **one sitemap file** - the first `Sitemap:` line of `robots.txt`, else `/sitemap.xml` (and, when that is an index, one child that lists ordinary pages, never posts or products; each at most 2 MB) - **only** to find contact, impressum, about and team page addresses the home page did not link. Those pages go through the same `robots.txt` check as any other and count towards **Pages per website**.
4. Extracts what the pages literally publish: `mailto:` links, `tel:` links, JSON-LD (`email`, `telephone`, `contactPoint`, `sameAs`, `address`, `employee`, `founder`), addresses written in the page text, simple encodings of an address that is already on the page (`name [at] domain [dot] com`, `&#64;`, Cloudflare's email obfuscation), phone numbers in the text that are clearly labelled (after "Tel", "Phone", "Call us", "Telefon", "Téléphone", "Teléfono", or written with a `+` and a country code) and the people on team pages (see below).
5. Drops noise: image names (`logo@2x.png`), example and placeholder addresses, no-reply boxes, Sentry/Wix keys and the addresses of third-party services (for example a "back in stock" widget's sender address), unless the domain is the company's own. Dates, prices, opening hours, VAT and company numbers, order ids and fax numbers are not taken for phones.
6. Delivers one flat row per company, sorted best first, with the page each contact came from.

Which emails are kept: addresses on the company's own domain (the domain you gave or the one its home page redirects to), and addresses on **another** domain only when the site publishes them in a `mailto:` link or JSON-LD (some companies use a different mail domain, or a webmail address). An address on another domain that only appears as plain text or in a form placeholder is dropped. **Nothing is ever constructed or guessed**: no `first.last@` patterns, no `info@` just because a domain exists.

It reads politely: about one request per second per site (slower when a site's `robots.txt` asks for a `Crawl-delay`, up to 10 seconds), a few sites at a time, an honest self-identifying User-Agent, a time budget per site. It follows at most three redirects per page, each checked before it is requested. A home page may redirect to another domain (greggs.co.uk to greggs.com): that is the company's own redirect and is recorded as `finalDomain`. A redirect to a social network, a consent or sign-in page, or an internal address is not followed. If a site blocks it, the row says **blocked** and the Actor stops requesting that site instead of working around it.

### People: names and job titles from team pages

With **Include the people behind the company** on (the default), each row has a `people` list: `{name, title, email, linkedin, sourceUrl}` for every person the site publishes as part of the company. Lead lists are mostly about people, and the people are where the company already tells you who they are.

Where people come from - only what the site publishes:

- **JSON-LD**: an Organization's `employee` or `founder` (a founder named without a title is `"Founder"`), and a standalone `Person` that states a `jobTitle` (not a blog post's author).
- **Team cards** on team, about and leadership pages: a repeated block (WordPress columns, Webflow lists, Squarespace summary items, a plain grid of `<h3>Name</h3><p>Title</p>`) 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** (then `title` is `null`).

What it will not do: **it never guesses or builds an email address** (no `first.last@company.com`), takes no personal data from social networks and does not look people up. An `email` is only an address the page itself links; role mailboxes such as `info@` or `sales@` are never attached to a person. A card or a list is accepted only when it reads as a team: most of its cards are name plus job title and the titles are job titles. **Testimonials** (a quote above or below the name, "CEO at Acme"), **blog authors and bylines**, **product or service cards**, **speaker line-ups**, **customer logos** and **footer link lists** are left out, and so are names made of ordinary words ("Privacy Policy", "Sales Team"). The aim is precision: you get fewer people than are on the page rather than wrong ones.

A company that publishes **only people** (no email or phone) is a result: its row is `ok` and is charged like any company with contacts. Set `includePeople` to `false` if you only want emails, phones and social links; people are then not read, `people` is `[]`, and team pages are not given priority in the crawl.

### Input

| Field | Type | Default | Description |
|---|---|---:|---|
| `websites` | string\[] | *(one of `websites`, `websitesFileUrl` or `datasetId`)* | Company domains, website URLs or email addresses; an entry may hold several, separated by commas, semicolons, spaces or tabs (a pasted column or CSV row). An email's domain is read; free-mail addresses are skipped. Up to 5,000 companies per run. De-duplicated by registrable domain (the first entry for a domain is read). A URL with a path is read as the first extra page. Skipped entries are reported in the run status and in the `INPUT_REPORT` record. |
| `websitesFileUrl` | string | *(none)* | https link to a `.csv` or `.txt` file of company websites (Google Sheets "publish to web" CSV, Dropbox, GitHub raw). Downloaded once (5 MB, 20,000 lines max); every cell is read like a `websites` entry and merged with `websites`. robots.txt is not applied to this download (your own input file); private and internal addresses are refused. |
| `datasetId` | string | *(none; one of `websites`, `websitesFileUrl` or `datasetId`)* | ID of an Apify dataset whose rows carry a website, typically another Actor's run dataset (a Google Maps scraper). Use `{{resource.defaultDatasetId}}` in an integration. Read with this run's own access, no token needed; up to 20,000 rows. Rows are read like `websites` entries and merged with them; skipped rows are reported with their row number in `INPUT_REPORT`. |
| `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`. Google Maps, social network and marketplace links are never read as a company website. |
| `datasetPassthroughFields` | string\[] | *(automatic)* | Columns of the source row copied into `source` on each result row. Automatic: `title`, `placeId`, `address`, `city`, `countryCode`, `phone`, `categoryName`, `totalScore`, `reviewsCount`, `url`. At most 15 columns, 2,000 characters per value. |
| `outputMode` | string | `company` | `company`: one row per company (default). `contacts`: one flat row per email, phone or person, CRM-ready. Same price in both: $0.004 once per company with contacts. |
| `outputFormat` | string | `full` | `full`: every field (default). `compact`: a smaller row for AI agents (see "Use with AI agents (MCP)"). Charging is identical. |
| `maxPagesPerSite` | integer | 6 | 1-20. Pages read per website, home page included. |
| `maxItems` | integer | 100 | 1-5,000. Hard cap on company rows delivered in the run - your cost cap. |
| `onlyChangedSinceLastRun` | boolean | `false` | Change mode: deliver a company only when its emails, phones, social links or people (name plus job title) changed since the last run. |
| `stateStoreName` | string | `company-contact-scraper-state` | Name of the store that remembers each company's last contact set (emails, phones, social links, people) in change mode. Letters, numbers and hyphens. |
| `includePageLevelSources` | boolean | `true` | Put the page each email, phone and person was found on in `sourceUrl`. Off: `sourceUrl` is `null`. |
| `includePeople` | boolean | `true` | Also read the people the site publishes (name, job title, and the email or LinkedIn link the page itself shows) into `people`. A company with only people is `ok` and charged. Off: `people` is `[]`. |

#### Input details

The Store form and the MCP tool schema show a short description for each field (an AI agent sees only the first 500 characters of each); the longer rules are here.

- **`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`, an email address (its domain is used; free-mail addresses such as gmail, outlook, yahoo, icloud, proton, gmx, web.de, mail.ru, qq, 163 or aol are skipped), or several of these on one line separated by commas, semicolons, spaces or tabs. Entries are de-duplicated by registrable domain (`www.example.co.uk` and `example.co.uk` are one company; the first one given is read). 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 home page is always read first, then the contact, impressum, about, team, leadership, support and locations pages of the same site (up to `maxPagesPerSite`); a sitemap, if the site has one, only points to contact, impressum, about and team pages the home page did not link. A URL with a path is read as the first extra page. Up to 5,000 companies per run.
- **`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`; it is added to the websites you list and de-duplicated with them. 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 websites themselves are still read politely, with `robots.txt` checked first.
- **`datasetId`**: The dataset is typically the default dataset of another Actor's run, for example a Google Maps scraper (Google Maps Scraper by Compass is one). To chain it automatically, open the source Actor's **Integrations** tab, add this Actor and set the field to `{{resource.defaultDatasetId}}`; by hand, copy the dataset ID from the source run's **Storage** tab. It is read with this run's own access (the account that starts this Actor): you never give a token, and it must be a dataset that account can open. Up to 20,000 rows are read; rows without a website, with a Google Maps, social network or marketplace link instead of a website, with free-mail or invalid values, or that repeat a website are skipped and listed with their row number in `INPUT_REPORT`. It is added to `websites` and `websitesFileUrl` and de-duplicated with them. A link to the dataset in Console or the API is accepted too. `source` on every result row carries fields of the source row so results can be joined back.
- `datasetWebsiteField`: the column name is matched exactly as spelled. Left empty, the first non-empty of `website`, `websiteUrl`, `web`, `url`, `domain`, `homepage`, `companyWebsite`, `company_website`, `site` is used. 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`: left empty, `title`, `placeId`, `address`, `city`, `countryCode`, `phone`, `categoryName`, `totalScore`, `reviewsCount` and `url` are copied, whichever the row has. At most 15 columns and 2,000 characters per value (longer text is cut; a list or object is copied as JSON text). `source` also always has `datasetRow` (the row's number in the dataset, counting from 0), `sourceRowCount` (how many dataset rows had this website; a chain of branches shares one website) and `otherRows` (`placeId` or title of the other rows of the same website, at most 20); those three names are reserved. When several rows have the same website, the first row's columns are kept.
- **`outputMode`**: `contacts` gives one flat row per contact for a CRM or spreadsheet import, with a `type` of `email`, `phone` or `person` (a person with an email is one row carrying both), the company columns on every row and, for a company with nothing found, one row of type `none` with the status. The price is the same in both modes: $0.004 once per company with at least one contact, never per row. `maxItems` counts companies, not rows.
- **`maxItems`**: you are charged $0.004 only for a company row with at least one email, phone or named person. Rows for sites with nothing found, blocked, unreachable or disallowed by `robots.txt` are delivered (so you can see why) and count toward this cap, but are not charged.
- **`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, ...). Pages found through a sitemap count too. More pages find more addresses and people on large sites but take longer: about one page per second per site.
- **`onlyChangedSinceLastRun`**: the first run delivers every company and remembers it; later runs deliver only what changed (`changedFields` says what). A site that is blocked or down on a later run keeps its last known contacts and is not delivered. Unchanged companies are not delivered or charged.
- **`stateStoreName`**: only used in change mode; keeps each company's last contact set (emails, phones, social links, people) across scheduled runs. Leave the default unless several tasks must keep separate histories.
- **`includePageLevelSources`**: on by default; `sourceUrl` is the page of the company's own site a contact was found on, useful to verify it and to document where it came from. Off: the contacts are the same, `sourceUrl` is `null`.
- **`includePeople`**: a company that publishes only people (no email or phone) then counts as a result and is charged $0.004 like any company with contacts. Only what the site publishes is used: no address is ever guessed or built from a name.

### Output fields: company contact details, website phone numbers and social media links

| Category | Fields |
|---|---|
| Identity | `inputUrl`, `domain`, `finalDomain`, `companyName` |
| Contacts | `emails` (`email`, `type` generic or other, `sourceUrl`), `phones` (`phone`, `e164`, `sourceUrl`), `socials` (`linkedin`, `facebook`, `instagram`, `x`, `youtube`, `tiktok`, `pinterest`, `github`) |
| People | `people` (`name`, `title`, `email`, `linkedin`, `sourceUrl`; at most 50 per company) |
| Company | `address` (JSON-LD PostalAddress only: `street`, `locality`, `region`, `postalCode`, `country`), `contactPageUrl` (the best contact page found, or `null`) |
| Quality | `emailDomainHasMx` (public DNS MX lookup of the first email's domain), `pagesCrawled` |
| Source row | `source` (only when `datasetId` was used: the chosen columns of the dataset row, plus `datasetRow`, `sourceRowCount`, `otherRows`; `null` for a company that came from `websites` or a file) |
| Status | `status` (`ok`, `no-contacts-found`, `blocked`, `unreachable`, `robots-disallowed`), `statusReason`, `changedFields` (change mode: `emails`, `phones`, `socials`, `people`), `scrapedAt` |

This table is the default `company` output. With `outputMode: "contacts"` the rows are flat; see "CRM mode: one row per contact". Missing values are `null` or empty. `contactPageUrl` is a page that was read and whose address says contact (`/contact`, `/kontakt`, `/contacto`, `/contatti`, `/get-in-touch`, ...), else a contact link the site publishes, else a read imprint page that carries an email or phone; support and about pages are not contact pages. `type` is `generic` for a role mailbox from a fixed list (`info@`, `hello@`, `sales@`, `support@`, `contact@`, `enquiries@`, `press@`, `careers@`, ...) and `other` for every other address; it is not inferred beyond that list, so a department box such as `groupdining@` is `other`. `e164` is only set when the number carries a `+` prefix or the site's JSON-LD address or country-code domain names a country the Actor knows the numbering rules of (the UK, Ireland, the US and Canada, Australia, New Zealand and most of Western Europe).

### Data protection

This Actor only reads what a company publishes itself on the websites you give it, 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 list (the only other lookup is the public DNS MX record of an email's domain). When you give it another Actor's dataset (`datasetId`), that dataset is read only to get the list of websites and the source columns you chose to copy back; nothing is collected from Google Maps or any other site that dataset came from, and you stay responsible for the lawful basis for that source data as well.

**The `people` field contains named individuals** - their names, job titles and, where the site links them, their business email addresses and LinkedIn profile links. That is personal data under the GDPR and similar laws, even though the company published it. **You are responsible for having a lawful basis to use the data.** Published business contact details can be personal data, especially when they name a person (`people`, and emails with `type: "other"`). Depending on where you and your recipients are, that means, 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 and person came from (`sourceUrl` is there for that) and prefer role mailboxes (`type: "generic"`) for cold outreach. If you do not need named people, turn **Include the people behind the company** off. This is general information, not legal advice.

### Track contact changes on a schedule

Change mode turns the Actor into a contact-change feed for a watch list of companies. Every run after the first delivers - and charges for - only companies whose **emails, phone numbers, social links or people** changed since the previous run.

1. Save this as a Task:

```json
{
  "websites": ["plausible.io", "mailerlite.com", "stumptowncoffee.com"],
  "onlyChangedSinceLastRun": true,
  "maxItems": 500
}
```

2. Add a **Schedule** (weekly or monthly is plenty for contact data).
3. **The first run is a baseline**: it delivers every company (`changedFields` is `["firstSeen"]`) and remembers its contact set. You are charged normally for it.
4. Later runs deliver only changes; `changedFields` lists which of `emails`, `phones`, `socials`, `people` moved. A person is identified by name plus job title: someone joining, leaving or changing title is a change; a newly published email or LinkedIn link for an existing person is not a change of `people` (a new address still shows up under `emails`).
5. Attach a **Webhook** for `ACTOR.RUN.SUCCEEDED` or an Integration (Make, Zapier, n8n, Slack, Google Sheets).

Company name, address, contact page, how many pages were read and which page a contact came from are not changes. A site that is blocked or down on a later run keeps its last known contacts and is not delivered; when it comes back with the same contacts it is unchanged. A site that could not be read the first time is delivered once with its status, then stays quiet. A run where nothing changed **succeeds** with an empty dataset and charges nothing.

State is kept in a named key-value store (`stateStoreName`), so it survives between runs. Use another name for a separate watch list, or delete the store to start over. State saved by an earlier version of this Actor (before people existed) still loads: the first run after the update records each company's people as its new baseline **without delivering or charging** anything for it, and people changes are reported from the run after that. With **Include the people behind the company** off, the people a company had are kept as they were, so switching it off or on is not a change.

### Send results to a Google Sheet or CRM

Use `outputMode: "contacts"` and an Apify **Integration**, so every run lands in your sheet or CRM without you touching a file:

1. Save the input as a **Task**, for example `{ "websitesFileUrl": "<your published CSV link>", "outputMode": "contacts", "maxItems": 500 }`.
2. In the Task's **Integrations** tab add **Google Sheets** (Apify's integration appends or replaces the dataset's rows in a spreadsheet you choose; its default is "Run finished" and the dataset of that run). Because every row is flat, each column becomes a sheet column: `domain`, `companyName`, `type`, `email`, `phone`, `personName`, `personTitle`, `sourceUrl`, and so on.
3. For a CRM (HubSpot, Pipedrive, Salesforce, Zoho, Airtable, Notion, ...) use the Task's **Integrations** tab to connect **Make**, **Zapier** or **n8n**, or a **Webhook** to your own endpoint, on `ACTOR.RUN.SUCCEEDED`. Map `email`, `personName`, `personTitle`, `companyName`, `domain`, `phone`, `linkedin` and `sourceUrl` to the CRM's contact or lead fields, and skip rows whose `type` is `none`. Or simply download the dataset as **CSV** from the run's Storage tab and use the CRM's own CSV import.
4. To send **only what changed**, add `"onlyChangedSinceLastRun": true` and a **Schedule**: each run then delivers all rows of the companies whose contacts changed since the last run, and nothing else.

Rows of one company share `domain`, so a CRM can group them into one company with several contacts; use `type` to filter (for example only `person` rows with an `email`).

### Use it as an API

```python
from apify_client import ApifyClient

client = ApifyClient("YOUR_APIFY_TOKEN")

run = client.actor("lukehunter/company-contact-scraper").call(
    run_input={"websites": ["plausible.io", "mailerlite.com"], "maxPagesPerSite": 6}
)

for company in client.dataset(run["defaultDatasetId"]).iterate_items():
    emails = [e["email"] for e in company["emails"]]
    print(company["domain"], company["status"], emails)
```

### 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`.

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

```json
{
  "mcpServers": {
    "apify-contacts": {
      "command": "npx",
      "args": [
        "-y", "mcp-remote",
        "https://mcp.apify.com?tools=lukehunter/company-contact-scraper",
        "--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/company-contact-scraper` and sign in to Apify. Never paste a real token into a shared file or chat.

**Example.** You ask: "Find the contact emails and the CEO for plausible.io and mailerlite.com." The agent calls the tool `lukehunter--company-contact-scraper` with:

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

`outputFormat: "compact"` returns a small row per company (emails as plain addresses, phones, people with titles, socials found) so the result fits an agent's context. It is charged exactly like the default `"full"` output: $0.004 per company with at least one contact found, nothing for sites with no contacts. Set `maxItems` to cap spend.

### Pricing and cost control

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

**$0.004 per company with at least one email, phone or named person - 1,000 companies with contacts = $4.**

| Companies with contacts | Cost |
|---:|---:|
| 3 | $0.012 |
| 100 | $0.40 |
| 1,000 | $4.00 |
| 5,000 | $20.00 |

There is no charge for starting a run. One company is one charge, however many emails, phones and people it has - and in `contacts` output, however many rows it produces. A company that publishes **only people** (no email or phone) is charged; turn **Include the people behind the company** off if you do not want people to count as a result. A company row with status `no-contacts-found`, `blocked`, `unreachable` or `robots-disallowed` is delivered but **not charged**, and neither is an entry that is not a website, a free-mail address, a duplicate domain, or (in change mode) a company whose contacts did not change. `maxItems` is a hard upper bound on cost (at most `maxItems` x $0.004), and the Actor never delivers a charged row your run's maximum-cost setting cannot pay for.

### Limitations

- **It finds what companies publish on their own pages, nothing more.** If a site shows no email, phone or named person (many show only a contact form), the row says `no-contacts-found`. It does not guess addresses, look people up, verify that a mailbox exists, or search other sites.
- **Only the pages it reads.** By default six pages per site, chosen by their link and path (and, when the home page links none, by one sitemap file). 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) for big sites.
- **People: precision over recall.** Only team cards that read as a team are used (a repeated block with a name heading and a job-title line, on a page about the team, most of them with job-title words), plus JSON-LD and mailto links with a name. A team shown only as photos, as a single sentence ("Jane, our CEO, and Tom, our CTO ..."), in a slider script, in an image, or with job titles that are not job-title words (a bare "Barista" in a grid of otherwise ordinary titles is kept, but a whole page of such titles is not) can be missed, and names in non-Latin scripts and names written in ALL CAPS are not recognised. Team members' own pages are not followed. Expect fewer people than the page shows, not more.
- **No JavaScript rendering.** Pages that build their content in the browser (some single-page apps) may show nothing in their HTML; the row then says `no-contacts-found`. Contacts inside a page's own data (for example a Next.js payload) are found; contacts loaded later by scripts are not.
- **Phone numbers come from `tel:` links, JSON-LD and clearly labelled text.** A number in the text is taken only right after "Tel", "Phone", "Call us", "Telefon", "Téléphone" or "Teléfono" (within about 30 characters), or when written with a `+` and a real country code; a bare number, a date, a price, an opening time, a VAT or company number, an order id or a fax number is not. `e164` is `null` unless the country is clear.
- **`generic` / `other` is a fixed role list**, not a judgement: role mailboxes it does not know (`groupdining@`, `beans@`) are labelled `other`.
- **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. This Actor does not use proxies, rotate identities, solve CAPTCHAs, accept cookies or try to get around a block.
- **Your list.** Entries are read forgivingly, but the Actor cannot know which cell of a spreadsheet is a website: every cell is tried, and cells that are not websites (names, headers) are skipped and listed in `INPUT_REPORT`, never an error. Only an email's domain is used (never the address itself), and free-mail providers are skipped. A file must be a plain `.csv` or `.txt` reachable without signing in (an `.xlsx` is not read); at most 5 MB, 20,000 lines and 5,000 different companies per run, the rest is ignored and reported. A URL that itself contains a comma is split at it.
- **Dataset input (`datasetId`).** The dataset must be one the account that starts this Actor can open (the default dataset of a run in the same account, which is what an integration passes); a private dataset of another account cannot be read, and the run then stops with that explanation. At most 20,000 rows are read per run and 5,000 companies per run (`maxItems` defaults to 100: raise it). It reads the website the row already has: it does not search for the website of a row that has none, and it never requests Google Maps or any other site named in the dataset except the companies' own websites. A business whose "website" is only a Facebook or Instagram page is skipped, not crawled. Column names are matched exactly as spelled in the dataset (use **Dataset: website column** for other spellings). `source` is a copy of the columns you chose: it is not checked or corrected.
- **robots.txt is respected** for every website it reads, page by page. A disallowed page is not read (reported in `statusReason`), and a site whose `robots.txt` disallows its home page is `robots-disallowed`. The one thing it is not applied to is downloading your own list file (`websitesFileUrl`), which is an input document, not a crawled site.
- **`emailDomainHasMx` is a DNS check of the first email's domain**, not a mailbox check, and is `null` when the lookup could not be completed.
- **Social links are the ones a site publishes.** A site's footer can link a platform's or a partner's profile instead of its own; share buttons, posts and personal profiles are left out, but the links are only as correct as the site's own.
- **Speed:** about one page per second per site and five sites at a time, so 1,000 sites at six pages each (plus `robots.txt` and, where needed, one sitemap file) take roughly 25-35 minutes. A site that is slow to answer is cut off after a per-site time budget.
- This is an independent tool, not affiliated with any company whose website it reads. You are responsible for using the collected data in line with the law and the sites' terms.

### 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~company-contact-scraper/run-sync-get-dataset-items?token=$APIFY_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"websites": ["plausible.io", "mailerlite.com", "stumptowncoffee.com"], "maxPagesPerSite": 6, "maxItems": 20}'
```

**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~company-contact-scraper').call({
  "websites": [
    "plausible.io",
    "mailerlite.com",
    "stumptowncoffee.com"
  ],
  "maxPagesPerSite": 6,
  "maxItems": 20
});
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~company-contact-scraper').call(run_input={"websites": ["plausible.io", "mailerlite.com", "stumptowncoffee.com"], "maxPagesPerSite": 6, "maxItems": 20})
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

#### Does it guess email addresses?

No. Every address in the output is written on a page of the company's own site, and its `sourceUrl` says which page. A person's `email` is only filled when the page links an address for them. If a site publishes nothing, you get `no-contacts-found`, not a pattern like `first.last@company.com`.

#### Why was a company not charged?

Only rows with at least one email, phone or named person are charged. `no-contacts-found`, `blocked`, `unreachable` and `robots-disallowed` rows are delivered free so you can see exactly what happened to each domain; `statusReason` explains it in plain English.

#### Why is there an address on a different domain than the website?

Some companies use another domain for mail (a booking address, a group's shared domain) or a webmail address. Such an address is kept only when the site publishes it in a `mailto:` link or JSON-LD. The same address in plain text on another domain is dropped, because it is usually a partner, a vendor or a form placeholder. Example: `groupdining@theivy-collection.com` on `ivycollection.com`.

#### What is `finalDomain`?

The registrable domain the site's home page ended up on after redirects. `www.greggs.co.uk` redirects to `www.greggs.com`, so `domain` is `greggs.co.uk` and `finalDomain` is `greggs.com`. Two entries that lead to the same company are delivered once.

#### What do `generic` and `other` mean?

`generic` is a role mailbox from a fixed list (`info@`, `hello@`, `sales@`, `support@`, ...). `other` is every other address, including named people (`jason@basecamp.com`) and department boxes the list does not know. Prefer `generic` addresses for cold outreach and treat `other` ones as personal data.

#### Can I just give it a domain, or do I need the contact page URL?

A domain is enough: the home page is always read, then the contact-like and team-like pages are found from its links (and, if needed, one sitemap file). If you do give a page URL (`https://www.mailerlite.com/contact`), that page is read right after the home page.

#### Can it find the emails of everyone at a company?

No. It collects the names, job titles and contact details a company publishes about itself, and attaches an email to a person only when their team card or mailto link on the site shows one. It does not guess addresses, and it does not read staff directories behind logins, social networks or other sites.

#### Which people does it list?

The people a company names on its own team, about and leadership pages (and founders and employees declared in its JSON-LD), each with the page they were listed on. Testimonial authors, blog authors, speakers, customers and product names are not people of the company and are left out. See "People: names and job titles from team pages" above.

#### Will I be charged if a company only lists people?

Yes: a company with at least one email, phone or named person is one charged result, $0.004. If you do not want that, turn **Include the people behind the company** off.

#### Can I paste a whole column, or a list with emails and junk in it?

Yes. Paste it into **Company websites** as it is. Domains, URLs, email addresses and several per line (commas, semicolons, spaces or tabs) are all understood; an email means its company domain (`jane@acme.co.uk` reads `acme.co.uk`), free-mail addresses (gmail, outlook, hotmail, yahoo, icloud, proton and so on) are skipped, duplicates are read once, and anything else that is not a website is skipped. What was skipped and why is in the run status and in the `INPUT_REPORT` record of the key-value store (an entry per line, with where it came from). The run fails only when nothing usable is left.

#### How do I use a Google Sheet or CSV as the input?

Publish the sheet (**File > Share > Publish to web > Comma-separated values (.csv)**) and put the link in **Website list file URL**. A Dropbox shared link or GitHub raw link to a `.csv` or `.txt` works too. The file is downloaded once (5 MB and 20,000 lines at most), every cell is read like a pasted entry, and it is merged with anything in **Company websites**. It has to open without signing in. If the link returns a web page (for example a sign-in page) the run stops with a message saying so, before anything is read or charged.

#### Does it respect robots.txt when it downloads my file?

No, and that is deliberate: the file is the input you gave it, not a website being crawled, so robots.txt does not apply to that one download (it is made with the same honest User-Agent, once, and never to private or internal addresses). Every company website it then reads is checked against that site's robots.txt page by page.

#### What is the difference between company and contacts output?

`company` (default) is one row per company with `emails`, `phones` and `people` as lists. `contacts` is one flat row per email, phone or person with the company columns repeated, for importing into a CRM or spreadsheet. A person with an email is one row carrying both. A company with nothing found is one row of type `none` with the status. The data and the price are the same.

#### Am I charged per contact row in contacts mode?

No. $0.004 once per company with at least one contact, however many rows it has. Rows of a company beyond the first are free, `none` rows are free, and a restarted run never charges a company twice.

#### How do I only get companies whose contacts changed?

Set `onlyChangedSinceLastRun` to `true` and put the Actor on a schedule - see "Track contact changes on a schedule" above.

#### Do I have to give it an API token to read the Google Maps dataset?

No. Every Actor run on Apify is started with its own access token for the account that started it, and this Actor reads the dataset with that. It can therefore open the datasets of your own account (the default dataset of your Google Maps run, which is what `{{resource.defaultDatasetId}}` is). It cannot open a private dataset of someone else's account. It only reads the dataset: it never writes to it, creates storages, or touches anything else in your account.

#### Does it work with other Actors than Google Maps Scraper?

Yes, with any dataset that has a website column: another maps or business-directory scraper, a company list, a dataset you built yourself. Name the column in **Dataset: website column** if it is not one of `website`, `websiteUrl`, `web`, `url`, `domain`, `homepage`, `companyWebsite`, `company_website`, `site`, and the columns you want back in **Dataset: columns to copy into `source`**.

#### Does it scrape Google Maps?

No. It reads the dataset a Google Maps scraper already produced through the Apify API, and then opens only each business's own website (checking its `robots.txt`, one page per second, with its honest User-Agent). A Google Maps `url`, a Facebook or Instagram link and a marketplace link are never opened.

#### Why is my result smaller than the dataset?

Chains share one company (one row, `sourceRowCount` says how many), rows without a website, with only a social network or Google Maps link, with an invalid or free-mail value are skipped, and `maxItems` (default 100) caps the companies. The run status gives the counts and the `INPUT_REPORT` record lists each skipped row with its row number (counting from 0, like the dataset API's offset) and why.

#### How do I join the results back to the Google Maps rows?

On `source.placeId` (the row's Google place ID, copied automatically when the row has one), or on `source.datasetRow`. For a chain, `source.otherRows` lists the place IDs of the other branches, which share the first branch's result row.

#### What is `source` in change mode?

Nothing that counts as a change. Change mode compares emails, phones, social links and people only, so a changed rating, address or title in the source dataset never makes a company deliver again (and never costs anything); the row you do get carries the current `source`.

#### What does it cost to enrich a Google Maps run?

$0.004 per company with an email, phone or named person found, exactly as for any list; nothing for reading the dataset, for source rows without a website, or for companies where nothing was found.

### 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.
- **[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.
- **[Google Maps Email Extractor & Lead Enricher](https://apify.com/lukehunter/lead-enricher)**: emails, phones, social links, named people and the tech stack of every business in your Google Maps Scraper results, one enriched row per business, with filters such as only Shopify stores with an email.
- **[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

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

REQUIRED unless websitesFileUrl or datasetId is given. Company websites to read for emails, phones and people, as domains or URLs, e.g. \["plausible.io", "https://www.mailerlite.com"]. One per line or a pasted column; an email contributes its domain. De-duplicated by domain; up to 5,000 per run. Unusable entries are listed in INPUT_REPORT, never an error. Home page first, then contact, about and team pages.

## `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.

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

Optional. ID of an Apify dataset whose rows have a website, typically a Google Maps Scraper run (defaultDatasetId), e.g. "AbCdEf123456". Each row's website is read for contacts; `source` carries the row's fields (title, placeId ...) to join back. To chain, set it to {{resource.defaultDatasetId}} in the source Actor's Integrations tab. No token needed; up to 20,000 rows. Merged with Websites and the file URL.

## `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 wins). Google Maps, social network and marketplace links are never read as a website.

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

Optional, only with datasetId. Extra dataset columns to copy into each result's `source` object, e.g. \["title", "placeId"], max 15 columns. Leave empty for the common Google Maps columns (title, placeId, address, city, countryCode, phone, categoryName, totalScore, reviewsCount, url). Values are cut at 2,000 characters. datasetRow, sourceRowCount and otherRows are always added and reserved.

## `outputMode` (type: `string`):

"company" (default): one row per company with emails, phones and people as lists. "contacts": one flat row per email, phone or person for CRM import, e.g. type "email", "phone" or "person", with company columns on every row. Price is identical ($0.004 per company with a contact, never per row); Maximum companies counts companies, not rows.

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

"full" (default): every field. "compact": a much smaller row for AI agents, with only domain, companyName, status, emails (addresses), phones, people (name, title, email), contactPageUrl, socials found and a one-line address. Charging is identical in both formats. Example: "compact" when the result is read into an LLM context.

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

Pages read per website (home page included), integer 1-20, default 6. Contact and team pages are read first, in many languages. Raise for large sites, lower (e.g. 3) for speed. Each site is read at about one page per second.

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

Maximum companies to return, integer 1-5000, default 100. Also your cost cap: at most maxItems x $0.004, charged only for companies with an email, phone or named person. For a quick test use 5 (e.g. maxItems 5). Sites with nothing found, blocked or disallowed by robots.txt are delivered free and count toward the cap.

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

true returns a company only if its contacts changed since the previous scheduled run. Default false: a normal one-off run returns every company. Agents making one-off calls should leave it false. Example: run daily on a Schedule with true; the first run delivers everything, later runs only changes (`changedFields`); unchanged companies are not charged.

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

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

## `includePageLevelSources` (type: `boolean`):

true (default): each email and phone carries sourceUrl, the page it was found on (useful to verify a contact). Set false for smaller rows; sourceUrl is then null. Example: false for a compact CRM import.

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

true (default): also return named people with job titles from team and about pages, e.g. {"name": "Jane Doe", "title": "CEO"}, plus an email or LinkedIn link only where the page itself links one. A company with only people counts as a result and is charged $0.004. Never guesses addresses. Set false for just emails, phones and social links.

## Actor input object example

```json
{
  "websites": [
    "plausible.io",
    "mailerlite.com",
    "stumptowncoffee.com"
  ],
  "outputMode": "company",
  "outputFormat": "full",
  "maxPagesPerSite": 6,
  "maxItems": 100,
  "onlyChangedSinceLastRun": false,
  "stateStoreName": "company-contact-scraper-state",
  "includePageLevelSources": true,
  "includePeople": true
}
```

# Actor output Schema

## `companies` (type: `string`):

No description

## `contacts` (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"
    ],
    "maxPagesPerSite": 6,
    "maxItems": 100
};

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

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

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,lukehunter/company-contact-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/orypT9FGVq3Ghq1NS/builds/sJItTPULuw8Elz0XC/openapi.json
