# Contact Page & Form Finder + Website Email Extractor (`pennydata/website-contact-extractor`) Actor

Contact page finder and website email extractor: per site returns contactPageUrl, contactFormUrl, contactFormKind (hubspot, typeform, html...), emails, phones, socials. $0.002 per site with a result, first 25 per run free. Built with AI assistance (Claude).

- **URL**: https://apify.com/pennydata/website-contact-extractor.md
- **Developed by:** [Penny Data](https://apify.com/pennydata) (community)
- **Categories:** Lead generation, Automation, MCP servers
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.40 / 1,000 domain with contacts founds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

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

**Contact Page & Form Finder: contact page URL, contact form URL and form kind (HubSpot, Typeform, Google Forms, JotForm, native HTML), plus emails, phones and socials for each website, $0.002 per site with a result, first 25 per run free (empty, failed and robots-blocked sites cost nothing).**

## Contact Page & Form Finder + Website Email Extractor

Click Start: the prefilled input (3 small-business sites) returned a contact page URL for all 3 and a contact form URL with its form kind for 2 of 3 in our local run, plus public emails and phones, free under the 25-per-run allowance.

Every row also has `contactFormKind`, which says what powers the contact form: `html` (native form), `hubspot`, `typeform`, `google_forms`, `jotform`, `tally`, `wpforms`, `contact_form_7`, or `none`, detected from iframe sources, script hosts and form markup. `contactFormPageUrl` is the page where an embedded form was found when it differs from `contactFormUrl`. Rows that include emails also carry `nextStep`, a link to our [Bulk Email Validator](https://apify.com/pennydata/bulk-email-validator-no-smtp) so you can check the addresses before sending.

**Real output for the 3 prefilled sites** (public business contact data only; real local run of this build, emails and phones exactly as the sites list them, socials shown as network names):

| Website | contactPageUrl | contactFormUrl | emails | phones | socials |
|---|---|---|---|---|---|
| sunsethillvet.com | https://sunsethillvet.com/contact/ | https://sunsethillvet.com/contact/ | info@sunsethillvet.com | (206) 706-7800, (206) 706-7803 | facebook, twitter |
| barefootphysiotherapy.com.au | https://www.barefootphysiotherapy.com.au/contact/ | https://www.barefootphysiotherapy.com.au/contact/ | hello@barefootphysiotherapy.com.au | 1300842850 | instagram |
| mainstreetbakery.com | https://mainstreetbakery.com/contact | none found | none listed | none listed | none found |

**Billing:** you are charged $0.002 only for a website where something was found (contact page, contact form, email, phone or social link). A website with no contacts found, an error, or a robots.txt block costs nothing, and `maxCostUsd` caps the spend.

Give it a list of websites or bare domains. For each one it reads the homepage plus up to two same-site contact/about/impressum pages and returns the **contact page URL (`contactPageUrl`), the contact form URL (`contactFormUrl`, a page with a real message form) and the publicly listed business emails, phone numbers and social profile links**. **You pay only for domains where at least one contact page, contact form, email, phone or social link was actually found.** Domains with nothing found, failed domains, unreachable domains and robots.txt-skipped domains are never charged, and an optional `maxCostUsd` input stops the run before it spends more than your budget.

### Use from AI agents / MCP / n8n / Zapier

The Actor takes plain JSON and returns one flat row per site, so it works as a tool for AI agents (call it through the Apify MCP server at `mcp.apify.com`, or the Apify API), and as a step in n8n, Zapier or Make through the Apify integrations. Sample input:

```json
{
  "urls": [{ "url": "sunsethillvet.com" }, { "url": "barefootphysiotherapy.com.au" }],
  "maxCostUsd": 0.5
}
```

Fields an agent usually reads from each row: `contactPageUrl`, `contactFormUrl`, `contactFormKind`, `emails`, `phones`, `status`.

### Cost: what it costs you vs other contact extractors

Pay per event, charged only for domains where something was found. Rival figures are the median estimated cost of the Store contact/email scrapers with at least 10 users in the last 7 days (7 actors, prices read from the public Store API on 2026-10-20; the per-result and per-page prices are converted assuming about 1 result or 2 pages per site, so treat them as estimates).

| Domains | This Actor (max, USD) | Rival median (est., USD) |
|---|---|---|
| 100 | $0.20 | $0.40 |
| 1,000 | $2.00 | $4.00 |
| 10,000 | $20.00 | $40.00 |

"Max" means every domain returned something. Domains with nothing found, errors and robots.txt skips cost $0, so your real cost is usually lower.

### Real sample output

Three rows from real local runs of this build (apify.com, python.org and mozilla.org). Socials trimmed to the first two networks per row for length; the Actor returns all of them.

```json
[
  {
    "input": "https://www.python.org",
    "finalUrl": "https://www.python.org",
    "httpStatus": 200,
    "contactPageUrl": "https://www.python.org/about/",
    "contactFormUrl": null,
    "contactFormKind": "none",
    "emails": [],
    "phones": [],
    "socials": {
      "linkedin": ["https://www.linkedin.com/company/python-software-foundation"],
      "twitter": ["https://twitter.com/ThePSF"]
    },
    "filteredOutCandidates": 0,
    "pagesVisited": ["https://www.python.org", "https://www.python.org/about/", "https://www.python.org/about/apps/"]
  },
  {
    "input": "https://apify.com",
    "finalUrl": "https://apify.com",
    "httpStatus": 200,
    "contactPageUrl": "https://apify.com/contact",
    "contactFormUrl": "https://apify.com/contact-sales",
    "contactFormKind": "html",
    "emails": ["support@apify.com", "hello@apify.com", "john.wick@thecontinental.com"],
    "phones": [],
    "socials": {
      "linkedin": ["https://www.linkedin.com/company/apify"],
      "twitter": ["https://x.com/apify"]
    },
    "filteredOutCandidates": 1,
    "pagesVisited": ["https://apify.com", "https://apify.com/contact", "https://apify.com/contact-sales"]
  },
  {
    "input": "https://www.mozilla.org",
    "finalUrl": "https://www.mozilla.org/en-US/",
    "httpStatus": 200,
    "contactPageUrl": "https://www.mozilla.org/en-US/contact/",
    "contactFormUrl": null,
    "emails": ["trademark-permissions@mozilla.com"],
    "phones": [],
    "socials": {
      "instagram": ["https://www.instagram.com/mozilla"],
      "linkedin": ["https://www.linkedin.com/company/mozilla-corporation"]
    },
    "filteredOutCandidates": 4,
    "pagesVisited": ["https://www.mozilla.org/en-US/", "https://www.mozilla.org/en-US/contact/", "https://www.mozilla.org/en-US/about/"]
  }
]
```

The email on the apify.com row is taken verbatim from a page and is not verified as a working mailbox; the Actor returns what is printed on the site, nothing is guessed.

### Use with Google Maps scraper (Add integration)

Get emails for every place a Google Maps scraper finds, automatically, with no copy-pasting:

1. Open your Google Maps scraper (e.g. `compass/crawler-google-places`) Actor or Task and go to the **Integrations** tab.
2. Click **Add integration** and search for **website contact extractor** (or "email", "contact"). Pick **Contact Page & Form Finder + Website Email Extractor** (`pennydata/website-contact-extractor`).
3. Set **Start when** to **Run succeeded**.
4. In the integration's input set **Dataset ID** to `{{resource.defaultDatasetId}}`. You can also leave it empty: the finished run is passed in automatically and its default dataset is used.
5. Leave **Field holding the website URL** as `website` (Google Maps scraper's field), or set another field name (`url`, `websiteUrl` and `domain` are tried as fallbacks). Save.

Every finished Google Maps run now starts this Actor on its dataset. Websites are read in pages of 1,000, deduplicated by domain, and rows that already have `emails` are skipped. `sourceTitle` / `sourcePlaceId` in the output let you join results back to the places.

### Try it in one click

[Open the Actor in Apify Console with the default input (sunsethillvet.com, barefootphysiotherapy.com.au, mainstreetbakery.com)](https://console.apify.com/actors/pennydata~website-contact-extractor/input) and press Start. Three domains are inside the free 25 per run, so the first run costs $0.

### Use it from n8n (Google Sheet of domains to Sheet of contacts)

<details>
<summary>Importable n8n workflow JSON (paste into n8n: Workflows, Import from clipboard)</summary>

Create a sheet with a `domain` column and a `results` tab, set both sheet IDs, add Google Sheets credentials and an Apify token (HTTP Header Auth, `Authorization: Bearer <your token>`). No credentials are included.

```json
{
  "name": "Find business contacts for a Google Sheet of domains (Apify)",
  "nodes": [
    {
      "parameters": {
        "content": "## Domains -> contacts\nReads domains from a Sheet, runs pennydata/website-contact-extractor via the Apify API, writes contacts to a results tab.\nSetup: set both sheet IDs, add Google Sheets credentials and an Apify token (HTTP Header Auth: Authorization = Bearer <token>)."
      },
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        0,
        -200
      ]
    },
    {
      "parameters": {},
      "name": "Manual Trigger",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        0,
        0
      ]
    },
    {
      "parameters": {
        "operation": "read",
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "YOUR_SHEET_ID"
        },
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "domains"
        }
      },
      "name": "Read domains",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        220,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "return [{json:{urls: $input.all().map(i => ({url: i.json.domain})), respectRobotsTxt: true, maxConcurrency: 5}}];"
      },
      "name": "Build actor input",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        440,
        0
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.apify.com/v2/acts/pennydata~website-contact-extractor/run-sync-get-dataset-items",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json) }}",
        "options": {
          "timeout": 300000
        }
      },
      "name": "Run Apify actor",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        660,
        0
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "1",
              "name": "domain",
              "value": "={{ $json.input }}",
              "type": "string"
            },
            {
              "id": "2",
              "name": "contactPageUrl",
              "value": "={{ $json.contactPageUrl || '' }}",
              "type": "string"
            },
            {
              "id": "3",
              "name": "contactFormUrl",
              "value": "={{ $json.contactFormUrl || '' }}",
              "type": "string"
            },
            {
              "id": "4",
              "name": "emails",
              "value": "={{ ($json.emails || []).join(', ') }}",
              "type": "string"
            },
            {
              "id": "5",
              "name": "phones",
              "value": "={{ ($json.phones || []).join(', ') }}",
              "type": "string"
            },
            {
              "id": "6",
              "name": "linkedin",
              "value": "={{ ($json.socials && $json.socials.linkedin || []).join(', ') }}",
              "type": "string"
            }
          ]
        }
      },
      "name": "Flatten fields",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        880,
        0
      ]
    },
    {
      "parameters": {
        "operation": "append",
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "YOUR_SHEET_ID"
        },
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "results"
        },
        "columns": {
          "mappingMode": "autoMapInputData",
          "value": {}
        }
      },
      "name": "Write results",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        1100,
        0
      ]
    }
  ],
  "connections": {
    "Manual Trigger": {
      "main": [
        [
          {
            "node": "Read domains",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read domains": {
      "main": [
        [
          {
            "node": "Build actor input",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build actor input": {
      "main": [
        [
          {
            "node": "Run Apify actor",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Run Apify actor": {
      "main": [
        [
          {
            "node": "Flatten fields",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Flatten fields": {
      "main": [
        [
          {
            "node": "Write results",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}
```

</details>

### Low false positives: what gets filtered

Contact extractors fail most often by returning things that are not contacts. Every email candidate found on a page passes these checks before it is returned, and the number rejected is reported per domain as `filteredOutCandidates`:

| Candidate found in page source | Example | Result |
|---|---|---|
| Image / asset file names that look like emails | `logo@2x.png`, `hero@3x.jpg`, `icons@2x.webp`, `font@5.0.0.woff2` | Dropped (asset-extension TLD) |
| Not a real top-level domain | `x@y.invalidtld`, `admin@server.local`, `user@localhost` | Dropped (TLD must be a real TLD) |
| Error-tracking and website-builder system addresses | `5d3f...@sentry-next.wixpress.com`, DSN keys `...@o4504.ingest.sentry.io` | Dropped (vendor domains) |
| Template placeholders | `you@example.com`, `name@domain.com`, `email@email.com`, `john.doe@test.com` | Dropped |
| Platform or CDN support addresses on a customer's page | `support@godaddy.com`, `help@shopify.com`, `x@cloudfront.net`, `schema@schema.org` | Dropped |
| Hash, UUID and long numeric ids used as a local part | `3f2504e0...c3301@...`, `550e8400-e29b-...@...` | Dropped |
| System mailboxes | `noreply@`, `no-reply@`, `mailer-daemon@` | Dropped |
| Escaped-JSON / URL-encoded prefixes glued to a real address | `u003einfo@site.com`, `%20billing@site.com` | Prefix stripped, address kept |
| Duplicates differing only in case, `mailto:` + visible text | `INFO@Site.com` and `info@site.com` | Merged into one |
| Obfuscated and entity-encoded addresses | `sales [at] site [dot] com`, `info&#64;site.de` | Decoded and kept |

Addresses hidden from simple scrapers are recovered: Cloudflare email protection (`data-cfemail` and `/cdn-cgi/l/email-protection#...` links, decoded locally) and the `email` field of JSON-LD structured data. Phone numbers are rejected when they are all one digit, a counting sequence, a date or timestamp (`2026-10-21`, `21.10.2026`), or an IP address. The optional MX check (`validateEmails`) is off by default.

The filters are heuristics. A real mailbox on a vendor-owned domain (for example a business that genuinely uses an address at a website-builder domain) can be dropped, and a placeholder that is not in the lists can slip through. Reproduce the 20-domain fixture with `npm test` (log: `evidence/task-2019/fixture-fp-filter.log`, 35 false-positive candidates filtered, 0 kept).

### Honest scope

- Reads only the public homepage and up to two contact/about/impressum pages of each domain you supply (plain HTTP, no browser, no login, no proxies).
- **No guessing:** it never generates email patterns and never probes mail servers. It returns only what is printed on those pages (mailto links, visible addresses, `tel:` links, structured data, social links).
- **No personal enrichment:** no people search, no LinkedIn or social profile scraping, no names or job titles. If you need decision-maker emails for named people, this Actor does not do that.
- Contact forms are detected from static HTML (a non-search `<form>` with a message field); forms rendered only by JavaScript or embedded from third-party iframes are not detected. `contactPageUrl`/`contactFormUrl` are null when nothing is found.
- Emails rendered only by JavaScript or hidden behind contact forms are not found, so some domains legitimately return nothing (and cost nothing).
- Respects robots.txt (on by default). Blocks private/internal addresses.
- **GDPR / privacy:** results can include addresses that identify individuals (e.g. firstname@company.com). You are responsible for having a lawful basis to process and contact them (GDPR, ePrivacy, CAN-SPAM and similar).

### Pricing

Pay per event, no subscription. **$0.002 per domain with results (about $2 per 1,000 such domains).**

| Event | When it is charged | Price |
|---|---|---|
| `domain-processed` | One domain where at least one contact page, contact form, email, phone or social link was found. | $0.002 |
| (no event) | Domain with no contacts, HTTP error, DNS/timeout failure, robots.txt skip, blocked address. | $0 |

#### How this compares to typical bulk email finders

| | This Actor | Typical bulk finder |
|---|---|---|
| Charged when a domain returns nothing | No | Often yes (charged per domain or per run) |
| Hard spend cap inside the run (`maxCostUsd`) | Yes, stops before exceeding | Usually only the platform-level run cap |
| Source of emails | Only what is printed on the domain's own contact pages | Varies; some guess or scrape third parties |
| Per-run summary of charged vs not charged | Yes (`SUMMARY` record) | Rarely |

### Example input

```json
{
  "urls": [{ "url": "https://apify.com" }, { "url": "https://www.python.org" }],
  "maxCostUsd": 0.5
}
```

`maxCostUsd` is optional. With `0.009` the run stops after 3 charged domains and sets `stoppedByCostGuard: true` in the `SUMMARY` record; re-run the remaining domains later.

### Reproduce the billing test locally

```bash
cd actors/website-contact-extractor && npm ci
test/run-local.sh            # 20 real public domains, no cap
test/run-local.sh 0.009      # same list, stops after 3 charged domains
```

The script prints the `SUMMARY` record (`charged`, `notCharged`, `failed`, `stoppedByCostGuard`) and, per domain, whether it was BILLABLE, EMPTY (unbilled) or ERROR (unbilled). Locally Apify ignores charge calls (not pay-per-event); `charged` counts the calls the Actor makes.

### Input

`urls`, `startUrls`, `websites` or `domains` (all aliases; list of sites/domains), `datasetId` (see below), `maxPagesPerSite` (1-10, default 3; hard limit on pages fetched per website, homepage included), `maxCostUsd` (optional budget cap), `scrapeEmails` / `scrapePhones` / `scrapeSocials` (default true; disabled fields are omitted from the output), `deepScan` (5 pages per site), `timeoutPerUrl` / `timeoutSecs`, `respectRobotsTxt` (default true), `validateEmails` (default false), `maxConcurrency` (1-20), `crmCsv` (`hubspot` default, or `none`).

### Usage: one-click HubSpot import (contacts.csv)

Every run also writes `contacts.csv` to the run's key-value store (output link "HubSpot import CSV"; set `crmCsv: "none"` to skip). Header row: `Email,First name,Last name,Phone number,Additional email addresses,Company name,Website URL`, the contact property labels from HubSpot's [import file guide](https://knowledge.hubspot.com/import-and-export/set-up-your-import-file). One row per site with an email: first email in `Email`, the rest `;`-separated in `Additional email addresses`. First/last name are left empty (the actor does not guess people's names). Import via Contacts > Import > File from computer. Check consent and CAN-SPAM/GDPR rules before emailing.

### Paste-compatible input

Inputs written for other website email extractors work as they are: `startUrls` (strings or `{"url": ...}`), `scrapeEmails`, `scrapePhones`, `scrapeSocials`, `timeoutPerUrl`, `deepScan` and `maxConcurrency` use the same names and meaning. Also accepted: `websites`, `domains`, `followLinkPages` (false = given pages only), `maxContactPages` (= maxPagesPerSite minus 1), `datasetUrlField` (= urlField); `requestTimeoutSecs` (= timeoutSecs); `phoneRegion`, `defaultCountry`, `phoneCountryCode`, `proxyConfiguration` and `proxyFallback` are accepted and ignored. Any other unknown option (retry, render, residential-proxy flags and so on) does not fail the run: it is ignored and listed in one log line.

```json
{ "startUrls": [{"url": "https://example.org"}], "scrapeEmails": true, "scrapePhones": false, "timeoutPerUrl": 20 }
```

### Enrich a Google Maps scraper dataset

Pass the dataset id of a finished Google Maps scraper run as `datasetId`. Each item's `website` is read, deduplicated by domain, and processed like a URL; the place `title` and `placeId` are copied to the output as `sourceTitle` and `sourcePlaceId`, so you can join results back to the places. Items without a website are skipped and cost nothing.

```json
{ "datasetId": "YOUR_DATASET_ID", "maxPagesPerSite": 3, "maxCostUsd": 5 }
```

### FAQ: what will a run cost?

**$0.002 per website, charged only when something is found** (a contact page, contact form, email, phone or social link). Websites where nothing is found, that fail to load, time out, or are blocked by robots.txt are free. Pages per site do not change the price: the charge is per website, never per page or per result. Worst case for 1,000 websites is $2.00 (every site returns something), and `maxCostUsd` stops the run before it passes your budget.

### Switching from website-email-extractor (jurassic_jove)

Paste your existing input unchanged: `urls`, `scrapeEmails`, `scrapePhones`, `scrapeSocials`, `deepScan`, `maxConcurrency`, `timeoutPerUrl` have the same names here; `followLinkPages: false` reads only the given pages (same as `crawl: false`); it never follows Linktree/bio-link pages, so give it the real website URL).

```json
{ "urls": ["https://apify.com", "https://www.python.org"], "scrapeEmails": true, "scrapePhones": true, "scrapeSocials": true, "followLinkPages": true, "deepScan": false, "maxConcurrency": 3, "timeoutPerUrl": 20 }
```

Price comparison, as listed on the Apify Store on 2026-10-21 ([ours](https://apify.com/pennydata/website-contact-extractor), [jurassic_jove/website-email-extractor](https://apify.com/jurassic_jove/website-email-extractor), prices from the public Store API `pricingInfos`):

| Per website with results | pennydata/website-contact-extractor | jurassic_jove/website-email-extractor |
|---|---|---|
| Until 2026-10-22 03:00 UTC | $0.002 | $0.006 |
| From 2026-10-22 03:00 UTC | $0.002 | $0.02 |
| 1,000 websites with results, from 2026-10-22 | $2.00 | $20.00 |

Differences: the rival charges only for sites where an email was found; this Actor charges for any site where a contact page, contact form, email, phone or social link was found (sites with nothing, failures and robots.txt skips are free). Linktree following is not supported here. The rival also lists a small per-run start event ($0.00005 per GB). Not affiliated with jurassic_jove.

### Switching from contact-page-finder (sandy_yclept)

Paste your existing input unchanged. `startUrls` (strings or `{"url": ...}` objects) and `maxContactPages` (contact pages read besides the homepage) work as in that Actor; `phoneRegion`, `proxyConfiguration` and `proxyFallback` are accepted and ignored (this Actor uses no proxy), so they never cause an error. `websites` and `datasetUrlField` are also accepted as aliases of `urls` and `urlField`. Output field names are this Actor's own (see Output below), not identical to theirs. Price here: $0.002 per website with a result. Not affiliated with sandy_yclept.

```json
{ "startUrls": [{ "url": "https://sunsethillvet.com" }], "maxContactPages": 3, "phoneRegion": "US", "proxyConfiguration": { "useApifyProxy": true } }
```

### Switching from another contact extractor (summary)

Saved tasks and integrations from jurassic_jove/website-email-extractor, sandy_yclept/contact-page-finder, datagleaner/website-contact-details-scraper, maximedupre/website-emails-scraper and gordian/email-extractor can keep their input JSON: change only the Actor ID. Field names that match are used (`urls`, `startUrls`, `websites`, `scrapeEmails`, `scrapePhones`, `scrapeSocials`, `maxPagesPerSite`, `maxContactPages`, `followLinkPages`, `crawl`, `datasetId`, `datasetUrlField`, `timeoutPerUrl`, `requestTimeoutSecs`); everything else is ignored with a log line. Output field names are this Actor's own, not identical to theirs; check the Output section before swapping a pipeline. Not affiliated with any of these authors.

### Migrating from gordian/email-extractor input

Paste your existing gordian/email-extractor input as-is and change only the Actor ID. `urls` (strings or `{"url": ...}` objects) and `crawl` (`false` = read only the given pages, no contact/about follow or probing) are supported; `email` is accepted and ignored. robots.txt is respected by default. Output has `emails`, `finalUrl` and `pagesVisited` per site.

| gordian/email-extractor key | Here | Behaviour |
|---|---|---|
| `urls` (array, required) | `urls` | Same. Strings or `{"url": ...}` objects. |
| `crawl` (boolean, default `true`) | `crawl` | `true` also reads up to 2 same-site contact/about pages; `false` reads only the given pages. This is not an unlimited site crawl. |
| `email` (string, your own address) | `email` | Accepted and ignored. |

Differences: output field names are this Actor's own (`emails`, `phones`, `finalUrl`, `pagesVisited`, contact page and form URLs), not gordian's. Not affiliated with gordian.

```json
{
  "urls": ["https://apify.com", {"url": "https://www.python.org"}],
  "crawl": false,
  "email": ""
}
```

### Output (one item per domain)

`input`, `finalUrl`, `httpStatus`, `contactPageUrl`, `contactFormUrl` (null if none), `emails`, `emailDetails` (if validated), `phones`, `filteredOutCandidates`, `domain` (hostname without www), `socials` (facebook, instagram, linkedin, twitter, youtube, tiktok, pinterest), flat arrays `linkedIns`, `twitters`, `instagrams`, `facebooks`, `youtubes`, `tiktoks`, `pinterests` (same links as `socials`, one array per platform), `pagesVisited`, `capturedAt`, or `error`.

### Limits

Emails rendered only by JavaScript, or hidden behind contact forms, are not found. Decorative/placeholder addresses are filtered heuristically.

### Non-affiliation

Not affiliated with or endorsed by any website it reads.

### AI disclosure

Built with AI assistance (Claude); maintained by the publisher.

### Use from Claude, Cursor or any MCP client

Apify's MCP server can expose this actor as a tool. Use this URL (format from the [Apify MCP docs](https://docs.apify.com/platform/integrations/mcp), `?tools=` takes a comma-separated list of actors):

`https://mcp.apify.com/?tools=pennydata/website-contact-extractor`

Client config (`mcpServers` format; sign in with OAuth when the client asks):

```json
{"mcpServers": {"apify": {
  "url": "https://mcp.apify.com/?tools=pennydata/website-contact-extractor"
}}}
```

Example prompt: "Find the public contact emails and phone numbers on example.com and acme.io."

### Related actors

- [Bulk Email Validator (no SMTP)](https://apify.com/pennydata/bulk-email-validator-no-smtp): checks the emails this actor finds.
- [Phone Number Validator (offline)](https://apify.com/pennydata/phone-number-validator-offline): checks the phone numbers this actor finds.
- [Website Tech Stack Detector](https://apify.com/pennydata/website-tech-stack-detector): adds the site's technology to the contact record.

#### Per-row status and run summary

Every dataset row has `status`: `ok`, `error` (site unreachable, HTTP error or non-public host) or `skipped` (disallowed by robots.txt). The key-value store record `SUMMARY` counts `domains`, `ok`, `error`, `skipped`, `charged` and `notAttempted`. Only `ok` rows with results are charged.

# Actor input Schema

## `urls` (type: `array`):

Public websites or bare domains (example.com). Homepage plus up to 2 contact/about pages per site are read (up to 4 with deepScan). Strings or {url} objects. Alias: startUrls.

## `respectRobotsTxt` (type: `boolean`):

Skip pages disallowed for all bots in robots.txt (skipped domains are not charged).

## `maxConcurrency` (type: `integer`):

Maximum number of sites analyzed in parallel (1-20).

## `timeoutSecs` (type: `integer`):

Per-request timeout in seconds (5-60).

## `maxCostUsd` (type: `number`):

Optional hard cap on what this run can charge ($0.002 per site with results). The run stops before exceeding it; the rest of the list is simply not processed. Leave empty for no cap.

## `validateEmails` (type: `boolean`):

Add a DNS MX check per email (no SMTP, no mail sent).

## `crawl` (type: `boolean`):

Compatibility with gordian/email-extractor input. true (default) also reads up to 2 same-site contact/about pages; false reads only the given pages.

## `email` (type: `string`):

Compatibility field from other email extractors' inputs; accepted and ignored.

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

Alias of "urls" for paste-compatibility with other contact scrapers (anchor/email-phone-extractor, vdrmota/contact-info-scraper). Array of strings or {"url": ...}; merged with "urls".

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

Optional. ID or name of an Apify dataset, e.g. the output of compass/crawler-google-places. In the Integrations tab leave it empty or enter {{resource.defaultDatasetId}}. Websites are read from the field in `urlField` (default `website`; falls back to `url`, `websiteUrl`, `domain`), deduplicated by domain and processed like URLs; rows that already have emails are skipped; `title` and `placeId` are copied to the output as sourceTitle / sourcePlaceId.

## `urlField` (type: `string`):

Name of the dataset field that holds the website, used with Dataset ID. Defaults to `website`; `url`, `websiteUrl` and `domain` are tried as fallbacks.

## `scrapeEmails` (type: `boolean`):

false removes the emails field from the output (and email-only matches no longer count as a result for billing). Compatible with jurassic_jove/website-email-extractor.

## `scrapePhones` (type: `boolean`):

false removes the phones field from the output and phones no longer count as a result for billing.

## `scrapeSocials` (type: `boolean`):

false removes the socials field from the output and social links no longer count as a result for billing.

## `timeoutPerUrl` (type: `integer`):

Alias of "timeoutSecs" (jurassic_jove naming), 5-60 seconds. timeoutSecs wins if both are set.

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

Hard limit on pages fetched per website, homepage included (1-10). Default 3 (5 if deepScan is on). The price is per website, not per page, so this only bounds run time.

## `followLinkPages` (type: `boolean`):

Compatibility with jurassic_jove/website-email-extractor input. false = same as crawl false (read only the given pages). Leave empty for the default. This Actor does not follow Linktree or bio-link pages; pass the real website URL instead.

## `maxContactPages` (type: `integer`):

Alias for contact-page-finder input: contact/about pages read besides the homepage, i.e. maxPagesPerSite minus 1. maxPagesPerSite wins if both are set.

## `datasetUrlField` (type: `string`):

Alias of urlField for paste-compatibility; wins over urlField if set.

## `phoneRegion` (type: `string`):

Compatibility field from other contact scrapers' inputs; accepted and ignored (phones are returned as written on the page).

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

Compatibility field from other contact scrapers' inputs; accepted and ignored (no proxy is used).

## `proxyFallback` (type: `boolean`):

Compatibility field; accepted and ignored.

## `deepScan` (type: `boolean`):

true reads up to 5 pages per site (homepage + up to 4 contact/about pages) instead of 3. No extra charge: still one event per site with results.

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

Alias of "urls"; array of strings or {"url": ...}; merged with "urls".

## `domains` (type: `array`):

Alias of "urls"; array of strings or {"url": ...}; merged with "urls".

## `crmCsv` (type: `string`):

Also write contacts.csv to the run's key-value store with HubSpot contact-import column headers (one row per site with an email). 'none' skips it.

## `requestTimeoutSecs` (type: `integer`):

Alias of "timeoutSecs" (datagleaner naming), 5-60 seconds. timeoutSecs and timeoutPerUrl win if set.

## `defaultCountry` (type: `string`):

Compatibility field from datagleaner's input; accepted and ignored (phones are returned as written on the page).

## `phoneCountryCode` (type: `string`):

Compatibility field from maximedupre's input; accepted and ignored (phones are returned as written on the page).

## Actor input object example

```json
{
  "urls": [
    {
      "url": "https://sunsethillvet.com"
    },
    {
      "url": "https://barefootphysiotherapy.com.au"
    },
    {
      "url": "https://www.mainstreetbakery.com"
    }
  ],
  "respectRobotsTxt": true,
  "maxConcurrency": 5,
  "timeoutSecs": 20,
  "validateEmails": false,
  "crawl": true,
  "urlField": "website",
  "scrapeEmails": true,
  "scrapePhones": true,
  "scrapeSocials": true,
  "deepScan": false,
  "crmCsv": "hubspot"
}
```

# Actor output Schema

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

No description

## `contactsCsv` (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 = {
    "urls": [
        {
            "url": "https://sunsethillvet.com"
        },
        {
            "url": "https://barefootphysiotherapy.com.au"
        },
        {
            "url": "https://www.mainstreetbakery.com"
        }
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("pennydata/website-contact-extractor").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 = { "urls": [
        { "url": "https://sunsethillvet.com" },
        { "url": "https://barefootphysiotherapy.com.au" },
        { "url": "https://www.mainstreetbakery.com" },
    ] }

# Run the Actor and wait for it to finish
run = client.actor("pennydata/website-contact-extractor").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 '{
  "urls": [
    {
      "url": "https://sunsethillvet.com"
    },
    {
      "url": "https://barefootphysiotherapy.com.au"
    },
    {
      "url": "https://www.mainstreetbakery.com"
    }
  ]
}' |
apify call pennydata/website-contact-extractor --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,pennydata/website-contact-extractor"
        }
    }
}
```

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/q6pd2y85VPDmyBWJr/builds/IemczLclbEcTflPJ7/openapi.json
