Contact Page Finder (Apify Actor)

Find the contact page and contact form of any company website, plus emails, phone numbers and socials, from a list of domains. CSV/JSON, no login. $3 per 1,000 domains with contacts. Runs as an Apify Actor, callable from MCP clients via the Apify MCP server (https://mcp.apify.com/?actors=pennydata/website-contact-extractor).

Máy chủ MCP được lưu trữ

npx add-mcp 'https://mcp.apify.com/?actors=pennydata/website-contact-extractor'

Cài vào Claude Code, Codex, Cursor và nhiều công cụ khác

Tài liệu

Website Contact & Email Scraper: Emails, Phones, Forms, $3/1k (pennydata/website-contact-extractor) Actor

Website contact and email scraper: paste a list of domains, get emails, phone numbers, socials, contact page URLs and contact form URLs per site. Click Start to test it: the first 2 sites in every run are free. CSV/JSON, no login. $3 per 1,000 sites with contacts. Built with AI assistance.

Pricing

from $2.10 / 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 section below.

Each client library has reference documentation the quickstart does not restate: JavaScript/TypeScript (npm install apify-client) and Python (pip install apify-client).

README

Website Contact & Email Scraper: emails, phones and contact forms from company sites

Paste company domains, get one row per site: emails, phones, social links, contact page URL and contact form URL. $0.003 per site where contacts are found (about $3 per 1,000; volume discounts to $0.0021 on higher Apify plans). The first 2 sites in every run are free. Sites with nothing found, failures and blocked pages cost $0.

Click Start: the prefilled input runs as is. Or paste this:

{ "urls": ["https://www.eff.org", "https://www.torproject.org", "https://www.gnu.org"], "maxCostUsd": 0.05 }

Sample rows from the real run below. Public business contact data only; you are responsible for GDPR/CAN-SPAM use. Next step: validate the emails with pennydata/bulk-email-validator-no-smtp. The Pricing tab is the source of truth for the current price.

Try it in your browser first: the Website Contact Finder demo on Hugging Face shows sample results and has a bulk runner (up to 500 URLs, billed to your own Apify account at the actor price; you supply your own Apify API token). Full runs and the API are here on Apify.

Real output of the prefilled input (3 sites, local run of this build, 2026-10-27, about 9 s; first 2 sites free):

domainstatuscontact pagecontact formemails foundsocials
eff.orgfound/about/contactnoinfo@, press@, membership@ + 3 more (6)facebook, instagram, linkedin, threads, tiktok
torproject.orgfound/contact/nosecurity@, frontdesk@, giving@ (3)facebook, github, instagram, linkedin, telegram, twitter, youtube
gnu.orgfound/contact/nowebmasters@, gnu@, web-translators@ + 5 more (8)none

Run it from a terminal (replace YOUR_APIFY_TOKEN; returns the rows as JSON when the run ends):

curl -X POST "https://api.apify.com/v2/acts/pennydata~website-contact-extractor/run-sync-get-dataset-items?token=YOUR_APIFY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"urls": ["apify.com", "mozilla.org", "python.org"], "maxCostUsd": 0.05}'

Worked example: 1,000 domains with maxCostUsd 0.50 -> the run stops pulling domains once 500 sites with contacts have been charged ($0.50, plus 2 free ones); the remaining domains get a free not_processed row and the OUTPUT record says how many. If 300 of the 1,000 domains are dead or blocked, those 300 cost $0, so 700 found sites cost at most $0.698.

Switching from Contact Page & Form Finder (sandy_yclept)? This Actor accepts the same input fields (startUrls, maxContactPages, phoneRegion, proxyConfiguration, proxyFallback), so you can paste your existing input as is. That Actor's public pricing lists $0.005 per contact page from 2026-11-02 (was $0.001). This Actor charges $0.003 per website where at least one contact page, form, email, phone or social link is found, and nothing for sites with no results (price as of 2026-10-28; in contactPageOnly mode a site where a contact form is found costs $0.006 in total, $0.003 + $0.003). Its output columns (website, contact_page, contact_form, contact_form_kind, contact_form_page, pages_crawled) are also written here under the same names; billed is not (see Coming from another contact finder?). Compare on a small run first.

Use it to find emails from a website list, extract contact info from domains, or enrich a lead list with public contact details. Paste domains (example.com or full URLs), click Start, and get one row per site: public business emails, phone numbers, social profile URLs, the contact page URL (contactPageUrl), a hasContactForm true/false flag, and the contact form URL (contactFormUrl) with its form kind (HubSpot, Typeform, Google Forms, JotForm, native HTML). Also finds WhatsApp, Telegram, Discord, Snapchat, Threads and Reddit links (same field names as vdrmota/contact-info-scraper). A site with only such a link counts as a result and is billed as one. Only what a site publishes on its own pages is returned; addresses built by the optional guessEmails mode are labeled as guesses with a confidence score and are never mixed into the published ones.

Email finder mode: guess emails from a name and a domain (guessEmails)

Set guessEmails: true and pass people instead of urls:

{ "guessEmails": true, "people": [ { "firstName": "Jane", "lastName": "Doe", "domain": "acme.com" } ] }

Per person you get bestEmail, confidence, pattern, and a ranked guesses[] list (first.last@, first@, flast@, firstlast@, first_last@, f.last@, firstl@, last@, last.first@ ...; up to maxGuessesPerPerson, default 5).

  • confidence: high = that exact address is published on the company's own homepage or contact pages. medium = the pattern matches the shape of staff addresses (e.g. bob.ray@) published there. low = a common pattern only.
  • The domain needs an MX record (DNS check); otherwise the row is no_mx and free.
  • These are guesses, not verified mailboxes. There is no SMTP probing and no data source other than DNS and the company's own public pages (robots.txt respected; turn the page read off with scanSiteForGuess: false). Run the results through a validator before sending.
  • Billing: the existing domain-processed event, $0.003 per row that has at least one guess; the first 2 rows per run, no_mx, invalid_input and not_processed rows are free. maxCostUsd is respected (rows beyond it come back as free not_processed).

What it costs

You pay per website where something was found, never per page, crawl or row. Two events, prices from .actor/pay_per_event.json:

  • domain-processed: $0.003 per site with a contact page, form, email, phone or social link. The first 2 such sites in every run are free.
  • contact-form-found: $0.003 extra, only with contactPageOnly: true, on a billed site where a contact form was found.

Worked example. 500 domains, default mode. 420 return contacts, 80 are unreachable or empty. Billed: 420 - 2 free = 418 sites x $0.003 = $1.254. The 80 failures cost $0. The same 500 domains in contactPageOnly mode where 150 of the billed sites also have a form: $1.254 + 150 x $0.003 = $1.704.

Safe defaults, so a run cannot run away:

SettingDefaultEffect
maxCostUsd5The run stops once the next billed site would pass $5 (in contactPageOnly mode each billed site counts as $0.006). Remaining sites get a free not_processed row. Raise it for bigger runs; 0 charges nothing.
maxPagesPerSite3 (5 with deepScan, max 10)Homepage plus contact pages. Pages never change the price.
maxSecondsPerSite60No further pages of a site are fetched after this.
timeoutSecs20Per-request timeout.

Live cost in the run. The status message updates during the run, for example 12/40 domains done, 31 pages crawled. Charged so far: 8 domain-processed + 3 contact-form-found events = $0.017. Cost cap $5. The same figures end up in the OUTPUT record.

When a limit stops a site. The log says which limit and how to raise it, and that site's dataset row carries limitHit (max_pages_per_site or max_seconds_per_site) and a plain-language limitMessage. Whatever was found before the limit is still returned.

Pricing at a glance

ItemPrice
First 2 sites with a result in every runFree
Each further site with a result (domain-processed event)$0.003
contactPageOnly: true only: extra contact-form-found event on a billed site where a contact form was found (not on the 2 free sites, not in other modes)$0.003
Site with nothing found, error, timeout or robots.txt block$0
10 domains (default mode)at most $0.024
500 domains (default mode)at most $1.494
1,000 domains (default mode)at most $2.994

(The first-2-free allowance was 25 per run before 2026-10-25. The event price was $0.001 until 2026-10-26 and is $0.003 now. maxCostUsd stops the run before it exceeds your cap.)

Copy-paste inputs

1. Ten domains, emails and phones (Start with this one):

{
  "urls": ["stripe.com", "mozilla.org", "python.org", "apify.com", "sunsethillvet.com",
           "cleanpro-services.com", "barefootphysiotherapy.com.au", "github.com", "wikipedia.org", "gnu.org"],
  "scrapeEmails": true,
  "scrapePhones": true
}

2. A CSV of 500 domains. Copy the domain column out of your CSV and paste it into urls as a JSON array. From a terminal: cut -d, -f1 leads.csv | tail -n +2 | jq -R . | jq -s '{urls: ., maxCostUsd: 1}' prints a ready input. Add "maxCostUsd": 1 so the run can never cost more than $1. The run also writes contacts.csv (HubSpot import columns) to the key-value store, so you can import the result straight back into your CRM:

{ "urls": ["domain1.com", "domain2.com", "...498 more..."], "maxCostUsd": 1, "crmCsv": "hubspot" }

3. Social links only (LinkedIn, Facebook, Instagram, X, YouTube, TikTok, GitHub, Pinterest per site; see Social media links only below):

{ "urls": ["stripe.com", "mozilla.org"], "socialsOnly": true }

Social media links only

Set socialsOnly (or its alias socialOnly) to true to skip email and phone extraction and get only each site's social profile links: LinkedIn company/school pages, X/Twitter, Facebook, Instagram, YouTube, TikTok, GitHub (plus Pinterest). Output per domain: socials (grouped) and flat linkedIns, twitters, facebooks, instagrams, youtubes, tiktoks, githubs, pinterests. Pricing is unchanged: $0.003 per site where at least one social link is found; sites with none, errors and robots.txt skips are free.

{ "urls": ["stripe.com", "github.com"], "socialOnly": true }

Phone country and one row per email

  • phoneCountryCode (aliases defaultCountry, phoneRegion): 2-letter ISO country such as AU, GB, US. Local numbers like 1300842850 are read in that country and the valid ones are added as E.164 in phonesE164 (+611300842850). The raw phones field stays as written on the page. Numbers already written with +CC need no country.
  • outputMode: perDomain (default, unchanged) or perEmail. perEmail returns one row per unique email with email, domain, sourceUrl (the page it was found on), isRoleAddress (info@, sales@, support@...) and the site's phones; a site with no email still gets one row. Billing does not change: one event per site with results, first 2 per run free.
{ "urls": ["barefootphysiotherapy.com.au"], "outputMode": "perEmail", "phoneCountryCode": "AU" }

phoneRegion: E.164 phone numbers

Set phoneRegion (ISO-3166 alpha-2, e.g. US) to read local numbers such as (415) 555-0100 and get them as E.164 in phonesE164. Numbers that fail to parse are dropped from phonesE164 only; phones always keeps every number as written. Leave it empty (default) to keep current behaviour: only numbers written with +CC reach phonesE164. No price change.

{ "urls": ["sunsethillvet.com"], "phoneRegion": "US" }

Output excerpt: "phones": ["(415) 555-0100"], "phonesE164": ["+14155550100"]

Bulk use

Paste 50 or more domains: {"urls": ["acme.com", "example.org", "...48 more"], "maxCostUsd": 1}. The first 2 sites with contacts per run are free; after that, sites with contacts are billed at $3 per 1,000 domains (BRONZE and higher paid plans pay less), and sites with nothing found, errors and robots.txt skips are free. So 50 domains cost about $0.14 if all have contacts, and 1,000 domains about $3 at most. Set maxCostUsd to cap the spend.

Big lists (500+ sites)

Each site is limited to maxSecondsPerSite (default 60 s) and sites run in parallel (5, or 10 automatically for 100+ sites; set maxConcurrency up to 20). If a very large list nears the run timeout, the actor stops starting new sites about 50 minutes in and writes a free not_processed row for every site it did not reach, so you can re-run just those. The final status message always shows how many sites were found, had no contact, were unreachable, blocked or timed out.

If a run fails or looks wrong, please share it with us via Console > Insights > Debugging ("share run for debugging") so we can fix it.

includePaths / excludePaths and maxRequests

includePaths and excludePaths are arrays of path prefixes applied to candidate contact links: with excludePaths: ["/blog"] a link such as /blog/contact is never fetched; with includePaths: ["/contact"] only links under /contact are followed. excludePaths wins, and it also removes matching built-in fallback paths (/contact, /contact-us ...), which are otherwise always tried. maxRequests caps total page fetches for the whole run (0 = no cap). When it is reached the run stops cleanly and every unprocessed site gets a row with "status": "skipped_cap", which is not charged. No price change.

{ "urls": ["sunsethillvet.com", "cleanpro-services.com"], "excludePaths": ["/blog", "/careers"], "maxRequests": 50 }

Validate emails: syntax_ok and mx_ok (optional)

Set validateEmails to true and every found email is marked in emailDetails (and on each row in perEmail mode):

  • syntax_ok: the address format is valid.
  • mx_ok: the email's domain has MX records, so it can receive mail (null when the syntax is bad, because no lookup is made).

DNS only: no SMTP, no mail is sent, so it cannot confirm that a mailbox exists. 3 second timeout per lookup (a timeout counts as mx_ok: false), one lookup per domain per run. Default false: no extra network calls, same output as before. No extra charge. For deeper checks (disposable domains, typos) pipe the emails into bulk-email-validator-no-smtp.

{ "urls": ["sunsethillvet.com", "barefootphysiotherapy.com.au"], "validateEmails": true }

Output excerpt: "emailDetails": [{ "email": "info@example.com", "syntax_ok": true, "mx_ok": true, "mxValid": true }] (mxValid is the older name of mx_ok, kept for compatibility). is_deliverable_guess is true when syntax_ok and mx_ok are both true (a guess: DNS only, no mailbox check). verifyEmails works as an alias of validateEmails.

Paste a Google Sheet or CSV link

Set csvUrl (alias domainsCsvUrl) to a public CSV link or a Google Sheet shared as "Anyone with the link"; the Sheet is read as CSV automatically. Domains come from a column named website, domain or url, else the first column; duplicates are removed. Billing is unchanged ($0.003 per site with results, first 2 per run free).

{ "csvUrl": "https://docs.google.com/spreadsheets/d/YOUR_SHEET_ID/edit", "maxCostUsd": 1 }

Next steps after the extract

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, the first 2 sites are free, the third is charged $0.003.

Every row also has contactPages, a ranked list of contact / support / imprint / about URLs (url, type, score, visited), and contactForm, the first native contact form found: pageUrl, action (form submit URL), method, fields (input names) and hasCaptcha / captchaType (reCAPTCHA, hCaptcha or Turnstile). Set findContactPages to false to switch both off. Example input:

{ "urls": ["sunsethillvet.com", "mozilla.org"], "findContactPages": true }

Sample from a local run: sunsethillvet.com returned contactForm with method post, 4 fields and hasCaptcha: true (recaptcha); mozilla.org returned 3 ranked contactPages, the first being /en-US/contact/. The 5-site test output is in test-output-5-sites.json in the source repo.

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 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):

WebsitecontactPageUrlcontactFormUrlemailsphonessocials
sunsethillvet.comhttps://sunsethillvet.com/contact/https://sunsethillvet.com/contact/info@sunsethillvet.com(206) 706-7800, (206) 706-7803facebook, twitter
barefootphysiotherapy.com.auhttps://www.barefootphysiotherapy.com.au/contact/https://www.barefootphysiotherapy.com.au/contact/hello@barefootphysiotherapy.com.au1300842850instagram
cleanpro-services.comhttps://cleanpro-services.com/contact-us/none foundsales@cleanpro-services.com+15169871824facebook, instagram

Billing: you are charged $0.003 only for a website where something was found (contact page, contact form, email, phone or social link). In contactPageOnly mode a site where a contact form is found is charged an extra $0.003 ($0.006 in total). A website with no contacts found, an error, or a robots.txt block costs nothing, and maxCostUsd caps the spend.

Use it as a website email extractor and contact page finder: 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.

For AI agents

Tool summary: given a list of websites, returns one JSON row per site with emails, phones, social links, the contact page URL and whether it has a contact form. Call it through the Apify MCP server (https://mcp.apify.com/?tools=pennydata/website-contact-extractor) or the Apify API. Only a list of domains is required; every other field has a default.

Input 1, minimal (a domains list is enough):

{"domains": ["apify.com", "eff.org", "mozilla.org"]}

Input 2, unattended run with a spending cap and one email per site:

{"urls": ["apify.com", "eff.org"], "maxCostUsd": 0.05, "onlyWithEmail": true, "maxEmailsPerSite": 1, "validateEmails": true}

Output fields per row: input, domain, status (found, no_email_found, no_contact_found, js_rendered_suspected, unreachable, blocked, timeout or not_processed), reasonCode, emails, bestEmail, phones, phonesE164, socials, contactPageUrl, contactFormUrl, hasContactForm, contactFormKind, companyName, homepageWords, maxPageWords, totalPageWords, jsRenderedSuspected, jsRenderedNote. Only status: found rows are charged.

Price per 1,000 sites, read from the public Apify API on 2026-10-25 (paid-plan BRONZE tier; per-page prices use about 2 pages per site, an estimate):

ActorPrice eventPer 1,000 sites
pennydata/website-contact-extractor (this one, BRONZE tier $0.0027)$0.003 per site with contacts$2.70
sandy_yclept/contact-page-finder$0.002 per site with a contact page$2.00
betterdevsscrape/contact-details-extractor$0.001 per page scrapedabout $2.00
vdrmota/contact-info-scraper$0.002 per page scrapedabout $4.00
jurassic_jove/website-email-extractor$0.02 per site with an email$20.00

Start fees and add-ons are excluded. Prices change; check each actor's page.

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:

{
  "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.

Every row also has reasonCode saying why the site gave what it gave: ok (contacts found), no_contacts_in_pages (pages read, nothing printed), js_rendered_suspected (homepage answered 200 but has fewer than 50 visible words plus JavaScript-app signs, so 0 emails is not a real answer: retry with renderJs), js_rendered_no_static_html (page builds its content with JavaScript: retry with renderJs), blocked_403 (the site refused automated requests), robots_disallowed, timeout, unreachable, http_error, max_cost_reached, max_requests_reached. The run status message starts with a summary such as "7/8 domains had contacts". If some sites were unreachable or blocked, the status message ends with "N sites unreachable/blocked (not charged); retry them with renderJs: true" (counts only). Those sites cost $0, so retrying them is free of risk.

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).

DomainsThis Actor (max, USD)Rival median (est., USD)
100$0.30$0.40
1,000$3.00$4.00
10,000$30.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.

[
  {
    "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.

Find emails for your Google Maps leads

Google Maps scrapers return each place's website, but rarely an email. This Actor reads those websites and returns emails, phones, socials and contact forms. Billing is unchanged: $0.003 per site with results, first 2 per run free, places without a website cost nothing.

Option 1: paste the dataset id of a finished Google Maps run (Run > Storage > Dataset ID):

{ "datasetId": "YOUR_MAPS_RUN_DATASET_ID", "maxCostUsd": 5 }

The field website is read (falls back to url, websiteUrl, domain; the Maps page link on google.* is never used), duplicates by domain are removed, and sourceTitle / sourcePlaceId are copied to every row so you can join back to the places. It can be combined with urls; sites listed twice are processed once.

Option 2: a CSV of domains (one domain or URL per row; first column, or a column named website, domain or url; a Google Sheet shared as "Anyone with the link" works too):

{ "csvUrl": "https://example.com/leads.csv", "maxCostUsd": 5 }

Option 3: chain it automatically (Integrations tab):

  1. Open your Google Maps scraper Actor or saved Task, go to Integrations and click Add integration.
  2. Pick pennydata/website-contact-extractor.
  3. Set Start when to Run succeeded.
  4. Leave Dataset ID empty or enter {{resource.defaultDatasetId}} (the finished run's dataset is passed in).
  5. Save. Every successful Maps run now starts this Actor on its results.

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.

Do not want to run it yourself? Done-for-you CSV for 19 USD

Send a list of up to 500 company websites (or a Google Sheet link) and get the contact CSV back within 24 hours, for a flat 19 USD: https://buy.stripe.com/fZu6oz3WF6rm07J7YU7EQ00

Public business pages only, robots.txt respected. Refund if fewer than half of the sites return contact data. Operated by an AI-assisted publisher (Claude); orders are fulfilled with this same Actor.

Try it in one click

Open the Actor in Apify Console with the default input (sunsethillvet.com, barefootphysiotherapy.com.au, cleanpro-services.com) and press Start. The first 2 sites of every run are free, so this 3-site run costs $0.003 (since 2026-10-25 the free allowance is 2 per run; it was 25 before).

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

Importable n8n workflow JSON (paste into n8n: Workflows, Import from clipboard)

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.

{
  "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
          }
        ]
      ]
    }
  }
}

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 sourceExampleResult
Image / asset file names that look like emailslogo@2x.png, hero@3x.jpg, icons@2x.webp, font@5.0.0.woff2Dropped (asset-extension TLD)
Not a real top-level domainx@y.invalidtld, admin@server.local, user@localhostDropped (TLD must be a real TLD)
Error-tracking and website-builder system addresses5d3f...@sentry-next.wixpress.com, DSN keys ...@o4504.ingest.sentry.ioDropped (vendor domains)
Template placeholdersyou@example.com, name@domain.com, email@email.com, john.doe@test.comDropped
Platform or CDN support addresses on a customer's pagesupport@godaddy.com, help@shopify.com, x@cloudfront.net, schema@schema.orgDropped
Hash, UUID and long numeric ids used as a local part3f2504e0...c3301@..., 550e8400-e29b-...@...Dropped
System mailboxesnoreply@, no-reply@, mailer-daemon@Dropped
Escaped-JSON / URL-encoded prefixes glued to a real addressu003einfo@site.com, %20billing@site.comPrefix stripped, address kept
Duplicates differing only in case, mailto: + visible textINFO@Site.com and info@site.comMerged into one
Obfuscated and entity-encoded addressessales [at] site [dot] com, info&#64;site.deDecoded 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).

Benchmark: our own test run on 20 real sites

Our own test run on 20 real small-business sites (dentists, plumbers, accountants, agencies, SaaS), 2026-10-24:

MeasureResult (20 sites)
Sites with a status row20 of 20 (no silent failures)
Sites with something found (billed)17 of 20 (3 unreachable, not billed)
At least one public email13 of 20
At least one phone9 of 20
Contact page URL found14 of 20
Contact form URL found7 of 20; cost for the run $0.015 (about $0.85 per 1,000 sites)

What was not found: 3 sites were unreachable (2 domains mistyped by us, 1 dead site, HTTP 404). "Found" means something useful (contact page, form, email, phone or social), not necessarily an email: 7 found sites returned no email. 4 of the 14 contact pages are /about pages. SaaS sites and agencies rarely publish phones (1 of 11). One site returned 15 emails that we did not audit. Small sample (n=20, one run, not random, run locally without a proxy); results vary by site mix.

How it compares

Same 20 homepages (list: commit 71dc1410), both actors run on 2026-10-24 with default settings. One sample, not a controlled study.

This Actor (run 1RVw5qoq2k2dR0DTH)delicious_zebu/contact-info-scraper-pay-per-result (run mkKa1grBPQd8SIPwz)
Cost of the run$0.0010 (at the then price of $0.001 per site)$0.1502
Domains returned20 of 20 (14 ok, 1 robots.txt skip, 1 HTTP 403, 4 errors)15 of 20
Domains with a real email8 (14 emails)6 (9 emails)
False emailsnot audited2 (image filenames, one domain)

Limits: one sample of 20 homepages, one run each, results vary by site mix. The rival returned 15 of the 20 domains, so its counts cover fewer sites. The rival returned phones on more domains (8 vs 3) and social links on more domains (11 vs 8). Prices are those charged on that day; the cost ratio (about 150x) comes from the per-result price versus our per-domain price on this sample.

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.003 per domain with results (about $3 per 1,000 such domains).

EventWhen it is chargedPrice
domain-processedOne domain where at least one contact page, contact form, email, phone or social link was found.$0.003
contact-form-foundOnly with contactPageOnly: true, in addition to domain-processed: the site has a contact form (hasContactForm / contactFormUrl set). Never charged in other modes or without a form.$0.003
(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 ActorTypical bulk finder
Charged when a domain returns nothingNoOften yes (charged per domain or per run)
Hard spend cap inside the run (maxCostUsd)Yes, stops before exceedingUsually only the platform-level run cap
Source of emailsOnly what is printed on the domain's own contact pagesVaries; some guess or scrape third parties
Per-run summary of charged vs not chargedYes (SUMMARY record)Rarely

Example input

{
  "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

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), contactPageOnly (default false; true returns only contact page, form details and mailto addresses), socialsOnly (default false; true returns only social profile URLs per site, no emails, phones or contact form fields), deepScan (5 pages per site), timeoutPerUrl / timeoutSecs, respectRobotsTxt (default true), validateEmails (default false), maxConcurrency (1-20), crmCsv (hubspot default, or none).

Usage: domains from a Google Sheet or CSV link

Set domainsCsvUrl to a public CSV or Google Sheet link (Sheets: File > Share > Publish to web > CSV, or share as "Anyone with the link"). Domains are read from the first column, or from a column named domain, website or url; up to 5000 rows, duplicates removed. If the link is not public the run stops with a clear error and charges nothing. The run log prints a download link for contacts.csv.

{ "domainsCsvUrl": "https://docs.google.com/spreadsheets/d/e/.../pub?output=csv", "maxCostUsd": 1 }

Usage: client-ready Excel report (outputXlsx)

Set "outputXlsx": true (default false) and the run also writes contacts-report.xlsx to the key-value store: a Summary sheet (domain, company, emails found, phones, socials, contact page URL) and a Contacts sheet with every email, phone and social. The run log prints its download link. No change to billing or the dataset.

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,Company Domain Name,Website URL,Source page, the contact property labels from HubSpot's import file guide. One row per site with an email: first email in Email, the rest ;-separated in Additional email addresses. Source page is the page the email was found on. 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 ({"url": ...} objects), 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 and phoneCountryCode set the phone country (see below); 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.

{ "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.

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

FAQ: what will a run cost?

$0.003 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 $3.00 in the default mode (every site returns something; contactPageOnly mode adds $0.003 per site with a contact form, so up to $6.00 if all 1,000 have one), and maxCostUsd stops the run before it passes your budget.

Will I be surprised by the bill? No. In the default mode the price is a flat $0.003 per website, never per page or per result, and maxCostUsd stops the run before it passes your cap, so 1,000 websites cannot cost more than $3.00. In contactPageOnly mode a site with a contact form costs $0.006, so 1,000 such sites cost $6.00 at most; maxCostUsd counts that too.

Do I pay for sites where nothing is found? No. Sites with status no_email_found, no_contact_found, js_rendered_suspected, unreachable, blocked (including robots.txt) or timeout are free. Only a site with status found (a contact page, contact form, email, phone or social link) is charged, and the SUMMARY record in the key-value store shows how many sites were charged and not charged.

Why did a site return no email when I can see one on it? The Actor reads static HTML and inline page data, with no browser, which keeps it cheap. Emails assembled by JavaScript in the browser or hidden behind a contact form can be missed. If no contact page, form, phone or social link is found either, the site is free; if one is found, the site counts as found and is charged $0.003, and contactPageUrl / contactFormUrl are returned so you can open the page yourself.

Does it crawl forever or return thousands of rows per site? No. It reads the homepage plus a few contact/about pages per domain (maxPagesPerSite, default 3, hard cap), returns one row per domain (or one per email with outputMode), and maxEmailsPerSite, timeoutPerUrl and maxCostUsd bound the run.

Does it return junk emails (Wix/Sentry IDs, placeholders, duplicates)? Vendor and system addresses, placeholders and invalid TLDs are dropped, and case and mailto: duplicates are merged. Each row in outputMode perEmail has emailDomainHasMx (true, false, or null when DNS failed), and onlyMxValidEmails drops addresses whose domain cannot receive mail (DNS only, no SMTP). Each perEmail row also has emailOnSiteDomain, isFreeProvider and isDisposable, so you can tell the business's own addresses from free-mail, third-party and disposable ones before you use them; onlySiteDomainEmails keeps only the site's own domain. Add excludeEmailPatterns or excludeDomains for your own rules.

Are dates or random digits returned as phone numbers? Phones are validated per country. Set phoneCountryCode (aliases phoneRegion, defaultCountry) to also get E.164 in phonesE164.

A site answers 403, 429 or 503? Set retryBlocked: true (default false) to re-read such a homepage and its contact page once in a headless browser. The browser keeps the honest ContactExtractorBot user agent and respectRobotsTxt still applies; bot-check and captcha pages are neither detected nor bypassed, so a site that still refuses stays blocked_403, unbilled. Recovered sites are charged like any other found site.

A site returned nothing, why? Every row has a status, a reasonCode and a plain-language reason. js_rendered_no_static_html means the page is built by JavaScript: retry that site with renderJs=true.

How do I tell "no email on the site" from "the page was never really read"? Every row carries homepageWords (visible words on the homepage after scripts, styles and tags are removed), maxPageWords and totalPageWords over the pages read. A site that answers 200 with fewer than 50 visible words and signs of a JavaScript app (an empty #root, #app, #__next or #__nuxt, framework markers, a noscript "enable JavaScript" message) gets jsRenderedSuspected: true, a jsRenderedNote, and, if no email was found, status: js_rendered_suspected instead of no_email_found. Those rows are free; retry them with renderJs=true.

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).

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

Price comparison (rival price as listed on the Apify Store on 2026-10-21) (ours, jurassic_jove/website-email-extractor, prices from the public Store API pricingInfos):

Per website with resultspennydata/website-contact-extractorjurassic_jove/website-email-extractor
Current price (rival as listed 2026-10-21)$0.003$0.02
1,000 websites with results$3.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)

Want only contact pages? Set "contactPageOnly": true: each row then carries contact_page, contact_form, contact_form_kind, contact_form_page (the same column names as that Actor), plus contact_form_fields (form field names) and mailto (addresses on the pages read), and no phones, socials or addresses. Billing in this mode: $0.003 per website where a contact page or form is found, plus $0.003 more for each site where a contact form is found ($0.006 in total for such a site; the first 2 sites per run are free).

Paste your existing input unchanged. All five fields of that Actor's input schema (read from its public default build on 2026-10-22) are accepted: startUrls ({"url": ...} objects) and maxContactPages (contact pages read besides the homepage) work as in that Actor; phoneRegion is used as the phone country (see Phone country and one row per email); proxyConfiguration and proxyFallback are accepted and ignored (this Actor uses no proxy), so they never cause an error. Only the sites in startUrls are read (checked 2026-10-25: pasting the input below gave 3 rows for 3 sites). Unsupported: none of its input fields; its proxy retry is not performed here. websites and datasetUrlField are also accepted as aliases of urls and urlField.

{ "startUrls": [{ "url": "https://sunsethillvet.com" }, { "url": "https://www.barefootphysiotherapy.com.au/" }], "maxContactPages": 3, "phoneRegion": "US", "proxyConfiguration": { "useApifyProxy": true }, "proxyFallback": true }

Output: the same snake_case column names are written on every row (checked by a local run on 2026-10-25): website, contact_page, contact_form (true/false), contact_form_kind, contact_form_page, pages_crawled. Differences: status here is found, no_contact_found, unreachable, blocked or timeout (map no_contact_found to not_found; see Per-row status), domain is the bare hostname, and there is no billed column (the run's SUMMARY record has charged and costUsd). Our camelCase fields (contactPageUrl, contactFormUrl, contactFormKind, contactFormPageUrl) are written too.

Price per website with a contact page found, from each Actor's public pricingInfos on 2026-10-22. The sandy_yclept listing schedules a change on 2026-11-02 17:00 UTC; tiers are Apify subscription plans:

Plan tiersandy_yclept until 2026-11-02sandy_yclept from 2026-11-02This Actor (from 2026-10-26)
Free$0.001$0.005$0.003
Bronze$0.001$0.002$0.0027
Silver$0.0009$0.0018$0.0024
Gold and above$0.0008$0.0016$0.0021

Cost per 1,000 websites with a contact page found:

Plan tiersandy_yclept until 2026-11-02sandy_yclept from 2026-11-02This Actor
Free$1.00$5.00$3.00
Bronze$1.00$2.00$2.70
Gold and above$0.80$1.60$2.10

Our figures above are for the default mode; in contactPageOnly mode a site with a contact form costs $0.006 (about $6.00 per 1,000 such sites on the Free plan; lower on paid plans). Differences: that Actor charges only when a contact page is found and lists a small per-run start event; this one charges when a contact page, contact form, email, phone or social link is found, and reads emails, phones and social links too. Check both listings before relying on these figures, since either price can change. Not affiliated with sandy_yclept.

Migrating from thenetaji/website-email-scraper

Coming from thenetaji/website-email-scraper? Your existing input works unchanged: change only the Actor ID. Input fields, checked against that Actor's public input schema on 2026-10-28:

thenetaji fieldHere
startUrlsSame name, used as is
mediaType (all, contact, email, phone, social, address)Limits the run to that kind of data; all and contact change nothing
maxUrlsToCrawlAlias of maxPagesPerSite; capped at 10, default here is 3
maxCrawlDepth, stayOnDomain, maxConcurrency, maxRequestRetries, maxBlockedRetries, useStealth, solveCloudflare, includeContactText, groupByPage, useProxyAccepted and ignored: this Actor reads the homepage plus contact and about pages of each site, not a deep crawl

Output field names differ from that Actor's, so check a small run before swapping a pipeline. Our own fields win when both are set.

Migrating from betterdevsscrape/contact-details-extractor

Saved tasks that call betterdevsscrape/contact-details-extractor ("Website Contact Extractor") can keep their input JSON: change only the Actor ID. Input fields, checked against that Actor's public input schema on 2026-10-23:

betterdevsscrape fieldHere
startUrlsSame name, used as is
maxPagesPerStartUrl (default 10)Alias of maxPagesPerSite; capped at 10, default here is 3
maxDepth, sameDomainAccepted and ignored: this Actor reads the homepage plus contact and about pages of the start URL's own domain, not a deep crawl
proxyConfigAccepted and ignored (no proxy is used)
mergeContactsNot needed: output is already one item per domain
useResidentialProxy, browserMode, useSitemap, sitemapUrl, companyNameMethod, addressExtraction, allowCrossDomainRedirectsNot supported, ignored with a log line

Output field names, compared with that Actor's public dataset view on 2026-10-23: domain, emails, phones, linkedIns, twitters, instagrams, facebooks, youtubes and tiktoks have the same names here; companyName, addresses, siteTitle and siteDescription are produced here too; extra fields here are contactPageUrl, contactFormUrl, socials, pinterests, pagesVisited and status. Other differences to check before swapping a pipeline: there is no browser mode, so sites that need JavaScript to show contacts can come back empty,. Price here is per website with a result (see Pricing); check both listings before relying on any figure. Not affiliated with betterdevsscrape.

Coming from another contact finder?

Field mapping against Contact Page & Form Finder (sandy_yclept/contact-page-finder), whose public README example (sunsethillvet.com, read 2026-10-25) lists these columns. Set "contactPageOnly": true to get ours; your existing input also works unchanged (see the switching section above).

That Actor's columnOur columnNote
websitewebsitesame
contact_pagecontact_pagesame name; falls back to the form page, as theirs does
contact_formcontact_formboolean, same name
contact_form_kindcontact_form_kindhtml, hubspot, typeform, ... (kind names are ours; compare on a small run)
contact_form_pagecontact_form_pagesame name
pages_crawledpages_crawledsame name
domaindomaindiffers: ours is the bare hostname (example.com); theirs is the full URL (use website for that)
status (found / not_found / unreachable)statusours: found, no_contact_found, unreachable, blocked, timeout, ...; map no_contact_found to not_found
billednonenot an output column here; the run's SUMMARY record has charged and costUsd

Extra columns here: contact_form_fields (form field names) and mailto addresses.

Price (as of 2026-10-25): this Actor charges $0.003 per website where a contact page, form, email, phone or social link is found; nothing for sites with no result (in contactPageOnly mode, +$0.003 per site where a contact form is found, $0.006 in total). That Actor's page states $0.001 per contact page until 2026-11-02, then $0.005 on the Free plan and $0.002 on Starter (lower on Scale and Business). Check both pricing tabs before you decide; the unit differs (their contact page found vs. our website with any contact data found). Not affiliated with sandy_yclept.

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 ({"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 keyHereBehaviour
urls (array, required)urlsSame. {"url": ...} objects, e.g. [{"url": "https://example.com"}].
crawl (boolean, default true)crawltrue 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)emailAccepted 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.

{
  "urls": [{"url": "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, emailSources and emailMethods (map of each email to how it was found: mailto link, json-ld, cloudflare (decoded protected link) or text; mailto and json-ld are the most trustworthy), phoneSources (maps of each email / phone to the first page URL it was found on, for checking a contact at its source; one row per email mode already has sourceUrl), filteredOutCandidates, domain (hostname without www), socials (facebook, instagram, linkedin, twitter, youtube, tiktok, pinterest, whatsapp, telegram, discord, snapchat, threads, reddit), flat arrays linkedIns, twitters, instagrams, facebooks, youtubes, tiktoks, pinterests, whatsapps (numbers as +digits), telegrams, discords, snapchats, threads, reddits (same links as socials, one array per platform; set scrapeMessaging to false to omit the messaging ones), companyName (schema.org name, else og:site_name, else page title; null if none), siteTitle (the page <title>, else og:title; null if none), siteDescription (meta description, else og:description, max 300 characters; null if none; both from pages already fetched, no extra requests, omitted in socialsOnly and contactPageOnly modes), addresses ("street, city, region postalCode, country" from JSON-LD, else the <address> element; [] if none), postalAddress (first JSON-LD PostalAddress as streetAddress, addressLocality, addressRegion, postalCode, addressCountry; null if none; set scrapeAddress to false to omit these three), emailsDecodedFromCloudflare (count of emails recovered from Cloudflare data-cfemail / /cdn-cgi/l/email-protection links; always decoded and listed in emails; a site with only an address counts as a result for billing), bestEmail (the single best contact: same-domain role address, ranked info@ > contact@ > sales@ > hello@ > other roles, then same-domain personal, then other domains; null if none), primaryEmail / primaryPhone / primaryContactUrl (flat copies for spreadsheets and no-code tools), source (inline-json when the contacts came from the 0-result fallback), bestPhone (first valid E.164 number, else the first phone as written; null if none), excludedEmails (count dropped by your exclusion filters, only when > 0), pagesVisited, capturedAt, or error.

Cut junk emails: excludeDomains, excludeEmailPatterns, maxEmailsPerSite

Optional inputs that clean the emails list before it is returned (and before bestEmail is picked):

  • excludeDomains: ["sentry.io", "wixpress.com"] drops emails on those domains and their subdomains, and skips input sites on those domains. Repeated domains in the input are scraped once. Built-in filters already drop placeholder, hash-like and image-file addresses, and phone numbers that are dates or years.
  • excludeEmailPatterns: regexes, case-insensitive, tested on the whole address, e.g. ["^(no-?reply|donotreply)@", "@example\\."]. An invalid regex is logged and ignored.
  • maxEmailsPerSite: keep at most N emails per site, best-ranked first (same-domain role addresses, then same-domain personal, then other domains).

bestEmail / bestPhone give one contact per site without post-processing. In perEmail mode each row has isBestEmail. Billing is unchanged: still one event per site with results.

Fallback for sites that return 0 contacts

When a site yields no emails and no phones after the normal crawl (and followLinkPages/crawl is not false), the Actor tries two more things, on by default and with no extra charge (still one domain-processed event per site with results): (1) it reads inline JSON in the pages it already fetched (__NEXT_DATA__, application/ld+json, other inline script blobs) for emails, phones and social links, which catches Next.js/React sites that never put contacts in the visible HTML; (2) it fetches /sitemap.xml and visits up to 3 same-site URLs whose path contains contact, about, impressum or team. Pages fetched this way show up in pagesVisited and respect respectRobotsTxt. Sites that already return contacts are unchanged. The fallback also reads data-phone / data-email / data-tel attributes and tel: and wa.me strings inside script data. Rows whose contacts came from this fallback carry "source": "inline-json".

FAQ: what happens when nothing is found?

  • A site with no email or phone anywhere (also not in inline JSON or the sitemap pages) comes back as no_contact_found and is free. If a contact page or form was found, you get contactPageUrl / primaryContactUrl instead and the site counts as found.
  • Contacts that only appear after clicking, in an image, or after a login are not readable without a browser. Try renderJs for those sites.
  • Contacts that sit inside an embedded iframe (booking or contact widgets) are skipped by default. Set considerChildFrames: true (same option name as vdrmota/contact-info-scraper) to read up to 3 iframes per site; ad, video, map and social frames are never read. Frame pages count towards maxRequests and appear in pagesVisited.
  • Want only usable leads? Set onlyWithEmail to true: sites without an email get no row and are not charged (counted as onlyWithEmailDropped in the SUMMARY).

People: names and job titles (extractPeople)

Set extractPeople to true to also read the site's team, about, leadership and contact pages (up to 2 extra same-site pages, robots.txt respected). Each row then gets people[] (name, title, email only if printed next to the person, sourceUrl, source: jsonld or markup) plus flat primaryPersonName and primaryPersonTitle (most senior person found). It reads schema.org Person JSON-LD, team-card markup (a name next to a title), "Name, Title" lines and mailto links next to a card. Nothing is guessed or looked up elsewhere, and no extra charge applies. Sites with no printed team page return an empty list (not charged differently: billing is unchanged). Parsing is heuristic, so spot-check before outreach.

GDPR / privacy: the output contains only what each site publishes for business contact. Names and work emails are still personal data in the EU/UK: you are the controller for how you use them, need a lawful basis (for example legitimate interest for B2B outreach), must honour opt-outs and deletion requests, and should not use them for anything unrelated to the person's business role. Leave extractPeople off if you do not need it.

Flat columns for Make / n8n / Zapier / Sheets

Every row has primaryEmail, primaryPhone and primaryContactUrl (contact form URL, else contact page URL; null if none), so you can map them without unpacking arrays:

  • Email column = primaryEmail
  • Phone column = primaryPhone
  • Contact link column = primaryContactUrl (add domain as the company key)

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, ?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):

{"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

Per-row status and run summary

Every input site gets exactly one dataset row (never silent), with status and a plain-language reason:

statusMeaningCharged
foundpage loaded and at least one contact page, form, email, phone or social link was found$0.003 (first 2 per run free)
js_rendered_suspectedhomepage loaded (200) but is a near-empty JavaScript shell (homepageWords < 50 plus app signs), 0 emails; the real page was probably never seen, retry with renderJsfree
no_email_foundpage loaded, 0 emails (and no phone) found; reason says why (junk candidates dropped, contact form only, JS-rendered: retry with renderJs)free
no_contact_foundpage loaded, nothing foundfree
unreachableDNS failure, HTTP error (404, 5xx...), non-public hostfree
blockedthe site answered 401/403/429 (also after one retry with browser-like headers) or robots.txt disallows the pagefree
timeoutno answer within timeoutSecsfree
not_processedthe run stopped at your max cost before reaching this sitefree

Cost limits: the run stops pulling sites at the lower of your maxCostUsd input and the run's "Maximum cost per run" setting. maxPagesPerSite and maxSecondsPerSite (default 60) bound the work per site.

When the run ends, the key-value store record OUTPUT (and the run's status message) shows the totals: sites, found, no_contact_found, unreachable, blocked, timeout, notProcessed, chargedSites, costUsd, stoppedAtMaxCost. The older SUMMARY and RUN_SUMMARY records remain.

Actor input Schema

urls (type: array):

WHAT TO PASS: a JSON array of websites, one per company, as bare domains or full URLs (strings or {"url": ...} objects). Example: ["apify.com", "https://www.eff.org"]. Default: 3 sample sites. Required unless you pass domains, startUrls, websites, datasetId or domainsCsvUrl. Output: one row per site (emails, phones, socials, contact page, contact form). Reads the homepage plus up to 2 contact/about pages (more with deepScan). Cost: $0.003 per site where something is found; nothing found, errors and robots.txt skips are free; first 2 sites per run free.

respectRobotsTxt (type: boolean):

true (default) skips pages that robots.txt disallows for all bots; skipped domains are free. Leave on unless you own the sites.

maxConcurrency (type: integer):

Maximum number of sites analyzed in parallel (1-20). Default 5, or 10 automatically for lists of 100+ sites.

timeoutSecs (type: integer):

Optional. Per-request timeout in whole seconds, 5 to 60 (default 20). Raise to 40 for slow sites; leave it for normal runs.

maxCostUsd (type: number):

WHAT TO PASS: a number, the hard USD spending cap for the run. Example: 0.05 stops after about 50 billed sites. Default 5. Set it when running unattended. Cost basis: $0.001 per site where contacts are found; nothing found, errors and robots.txt skips are free; first 2 per run free. 0 charges nothing: only the 2 free sites return contacts, the rest return a free not_processed row. In contactPageOnly mode a billed site can cost up to $0.004, so the cap allows fewer sites.

validateEmails (type: boolean):

WHAT TO PASS: true or false (default false). Example: true. Marks every found email with syntax_ok (valid format), mx_ok (domain has MX records) and is_deliverable_guess (both true; a guess, not a mailbox check). DNS only: no SMTP, no mail sent, 3 s timeout, one lookup per domain. Added to emailDetails (and to each row in outputMode perEmail). No extra charge.

verifyEmails (type: boolean):

Alias of validateEmails. Set true for syntax + MX check and is_deliverable_guess on every found email.

renderJs (type: boolean):

WHAT TO PASS: true or false (default false, plain HTTP only). Example: true. Re-reads sites where the plain pass found no email in a headless browser (homepage + contact page) to catch emails injected by JavaScript. Only those sites are rendered. Slower (about 3-8 s per rendered site); price per site unchanged.

retryBlocked (type: boolean):

WHAT TO PASS: true or false (default false). Example: true. When a site answers 401/403/429/503 to the plain request, re-read its homepage and contact page once in a headless browser that keeps the honest ContactExtractorBot user agent and respects robots.txt. Bot-check/captcha pages are not detected or bypassed: sites that still refuse stay blocked_403 and are not charged. Price per site unchanged (only status=found is charged).

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.

findContactPages (type: boolean):

true (default) adds contactPages and contactForm (form URL, method, field names, captcha flag) to every row. false returns only emails, phones and socials.

email (type: string):

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

startUrls (type: array):

Alias of urls (for pasting other scrapers' inputs). Pass an array of strings or {"url": ...} objects. Example: [{"url": "https://example.com"}]. Merged with urls. Same cost per site as urls.

datasetId (type: string):

WHAT TO PASS: the ID of another Apify run's dataset that lists websites, e.g. from compass/crawler-google-places. Example: "aBcDeFgHiJkLmNoPq". Use instead of urls; leave empty otherwise. Reads the field named in urlField (default website), dedupes by domain and skips rows that already have emails. Same cost per site as urls.

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.

scrapeMessaging (type: boolean):

Also finds WhatsApp, Telegram, Discord, Snapchat, Threads and Reddit links (same field names as vdrmota/contact-info-scraper: whatsapps, telegrams, discords, snapchats, threads, reddits). Needs scrape social links on. A site with only such a link counts as a result for billing.

scrapeAddress (type: boolean):

Also returns companyName (schema.org name, og:site_name or page title), addresses and postalAddress (from JSON-LD or

). A site with only an address counts as a result for billing. Cloudflare-protected emails (data-cfemail) are always decoded.

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.

maxPagesPerStartUrl (type: integer):

Alias of maxPagesPerSite for paste-compatibility with betterdevsscrape/contact-details-extractor. maxPagesPerSite wins if both are set. Values above 10 in a pasted input should be lowered to 10.

maxDepth (type: integer):

Compatibility field from betterdevsscrape/contact-details-extractor; accepted and ignored (this Actor reads the homepage plus contact/about pages, not a deep crawl).

sameDomain (type: boolean):

Compatibility field; accepted and ignored. This Actor only reads pages on the start URL's own domain.

proxyConfig (type: object):

Compatibility field from betterdevsscrape/contact-details-extractor; accepted and ignored, same as proxyConfiguration (no proxy is used).

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):

2-letter ISO-3166 country (US, GB, AU, DE...) used to read local phone numbers written without a +country prefix. Valid numbers are added as E.164 in phonesE164 (e.g. (415) 555-0100 with US becomes +14155550100); numbers that fail to parse are left out of phonesE164 only, never from phones. Empty (default) keeps current behaviour. Same as phoneCountryCode (lowest priority).

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):

WHAT TO PASS: true or false (default false). Example: true. Reads up to 5 pages per site instead of 3, for when default runs miss contacts. No extra charge: still one event per site with results.

websites (type: array):

Alias of urls. Pass an array of strings or {"url": ...} objects. Example: ["example.com"]. Merged with urls. Same cost per site as urls.

domains (type: array):

WHAT TO PASS: a JSON array of bare domains, same as urls. Example: ["apify.com", "eff.org", "mozilla.org"]. A call with only domains works. Cost: $0.001 per site where something is found; nothing found, errors and robots.txt skips are free; first 2 sites per run free.

domainsCsvUrl (type: string):

WHAT TO PASS: one public link to a CSV or Google Sheet (published to the web or shared with anyone with the link) with one domain or URL per row. Example: "https://docs.google.com/spreadsheets/d//export?format=csv". Use instead of urls. Reads the first column or a column named domain, website or url; up to 5000 rows, deduplicated. Fails with a clear error if the link is not public. Same cost per site as urls.

csvUrl (type: string):

Alias of "domainsCsvUrl": public link to a CSV or Google Sheet with one domain or URL per row (first column, or a column named website / domain / url). domainsCsvUrl wins if both are set.

outputXlsx (type: boolean):

true also writes contacts-report.xlsx to the run's key-value store: a Summary sheet (one row per domain) and a Contacts sheet (every email, phone and social), ready to hand to a client. Default false.

crmCsv (type: string):

hubspot (default) also writes contacts.csv to the run's key-value store in HubSpot import format, 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):

Alias of phoneCountryCode (emastra/datagleaner naming): 2-letter ISO country code, e.g. US, GB, AU.

phoneCountryCode (type: string):

2-letter ISO country (US, GB, AU, DE...) used to read local phone numbers that have no +country prefix. Valid numbers are added as E.164 in phonesE164. Leave empty if sites write numbers with +CC.

outputMode (type: string):

WHAT TO PASS: "perDomain" (default) or "perEmail". Example: "perEmail". perDomain gives one row per website with an emails array. perEmail gives one row per unique email with sourceUrl, isRoleAddress and phones (sites with no email still get one row). Price is the same: one event per site with results.

contactPageOnly (type: boolean):

WHAT TO PASS: true or false (default false). Example: true. Returns only each site's contact page URL, whether it has a contact form (contact_form, contact_form_kind, contact_form_page, contact_form_fields) and any mailto addresses on it. No phone, social or address extraction. Same column names as sandy_yclept/contact-page-finder. Cost: $0.001 per site with a contact page or form, plus $0.003 when a contact form is found ($0.004 total); other sites are free.

socialsOnly (type: boolean):

WHAT TO PASS: true or false (default false). Example: true. Returns only social profile URLs (LinkedIn, Facebook, Instagram, X, YouTube, TikTok, GitHub, Pinterest) per site, no emails or phones. Cost: $0.001 per site with a social link; other sites are free.

socialOnly (type: boolean):

WHAT TO PASS: true or false (default false). Same as socialsOnly. Example: true. Skips email and phone extraction and returns per site only social profile links: LinkedIn, X/Twitter, Facebook, Instagram, YouTube, TikTok, GitHub (plus Pinterest). Cost: $0.001 per site with a social link; other sites are free.

excludeDomains (type: array):

Emails on these domains (and subdomains) are dropped and input sites on them are skipped, e.g. ["sentry.io","wixpress.com"]. Use to remove junk addresses. Leave empty for none.

excludeEmailPatterns (type: array):

Optional. Emails matching any of these regular expressions (case-insensitive, tested on the full address) are dropped, e.g. ["^(no-?reply|donotreply)@","@example\."]. Invalid regexes are logged and ignored.

extractPeople (type: boolean):

Also read team / about / leadership pages and add people[] ({name, title, email if printed}) plus primaryPersonName and primaryPersonTitle. Only what the site itself publishes; nothing is guessed. No extra charge. Business contacts only: you are responsible for lawful use (GDPR).

onlyWithEmail (type: boolean):

WHAT TO PASS: true or false (default false). Example: true. Drops sites where no email was found: no row is written and the site is not charged. Use it to get a clean lead list.

onlyMxValidEmails (type: boolean):

Drop emails whose domain has no MX record (cannot receive mail). Emails whose DNS lookup failed or timed out are kept. DNS only, no mail sent. Every email row in outputMode perEmail carries emailDomainHasMx (true / false / null when unknown) either way.

onlySiteDomainEmails (type: boolean):

Drop emails that are not on the site's own domain (free-mail like gmail, third-party agency or webmaster addresses, disposable). A site left with no email is not charged. Every email row in outputMode perEmail carries emailOnSiteDomain, isFreeProvider and isDisposable either way.

maxEmailsPerSite (type: integer):

WHAT TO PASS: an integer 1-1000. Example: 3. Keeps at most this many emails per site, best-ranked first (role addresses like info@/contact@ on the site's own domain, then personal, then other). Empty = no limit. Use 1-3 for outreach lists. No extra charge.

maxSecondsPerSite (type: integer):

Wall-clock limit per website. When it is reached no further pages of that site are fetched and the row is returned with what was found so far. Default 60.

includePaths (type: array):

Only follow contact-page links whose path starts with one of these prefixes, e.g. ["/contact","/about"]. Empty = all contact-like links. Built-in fallback paths (/contact, /contact-us ...) are still tried unless excluded.

excludePaths (type: array):

Never fetch links whose path starts with one of these prefixes, e.g. ["/blog","/careers"]. Also applies to the built-in fallback paths. Wins over includePaths.

maxRequests (type: integer):

Cap on total page fetches (homepages and contact pages) for the whole run. When it is reached the run stops cleanly: sites not yet processed get a status=skipped_cap row and are not charged. 0 = no cap.

guessEmails (type: boolean):

true switches to the name+domain mode: give people ({firstName,lastName,domain}) and get ranked pattern guesses (first.last@, flast@, first@ ...) per person, cross-checked against emails the company's own site publishes. MX check only: no SMTP probing, results are guesses, not verified mailboxes. Billed $0.001 per row that has a guess (first 2 per run free). Ignores urls.

people (type: array):

Only used with guessEmails=true. List of objects: {"firstName":"Jane","lastName":"Doe","domain":"acme.com"}. Rows with no valid domain or name are free.

maxGuessesPerPerson (type: integer):

guessEmails mode: how many ranked address guesses to return per person (1-12).

scanSiteForGuess (type: boolean):

guessEmails mode: read the homepage and up to 2 contact pages of each domain (robots.txt respected) to confirm a guess or learn the staff address pattern. Off = pattern guesses + MX only.

considerChildFrames (type: boolean):

Also read up to 3 embedded iframes (booking or contact widgets) on the pages visited and add their emails and phones to the row. Ads, video, maps and social widgets are skipped. Same option name as vdrmota/contact-info-scraper.

maxUrlsToCrawl (type: integer):

Alias of maxPagesPerSite for paste-compatibility with thenetaji/website-email-scraper. maxPagesPerSite and maxPagesPerStartUrl win if set. Capped at 10 pages per site.

mediaType (type: string):

Accepted so inputs saved for thenetaji/website-email-scraper run unchanged: limits the run to emails, phones, social links or addresses ('all' and 'contact' change nothing). Our scrape* switches still apply on top.

maxCrawlDepth (type: integer):

Accepted so inputs saved for thenetaji/website-email-scraper run unchanged. Not used: this Actor reads the homepage plus contact and about pages, not a deep crawl.

maxRequestRetries (type: integer):

Accepted so inputs saved for thenetaji/website-email-scraper run unchanged. Not used: this Actor reads the homepage plus contact and about pages, not a deep crawl.

maxBlockedRetries (type: integer):

Accepted so inputs saved for thenetaji/website-email-scraper run unchanged. Not used: this Actor reads the homepage plus contact and about pages, not a deep crawl.

stayOnDomain (type: boolean):

Accepted so inputs saved for thenetaji/website-email-scraper run unchanged. Not used: this Actor reads the homepage plus contact and about pages, not a deep crawl.

useStealth (type: boolean):

Accepted so inputs saved for thenetaji/website-email-scraper run unchanged. Not used: this Actor reads the homepage plus contact and about pages, not a deep crawl.

solveCloudflare (type: boolean):

Accepted so inputs saved for thenetaji/website-email-scraper run unchanged. Not used: this Actor reads the homepage plus contact and about pages, not a deep crawl.

includeContactText (type: boolean):

Accepted so inputs saved for thenetaji/website-email-scraper run unchanged. Not used: this Actor reads the homepage plus contact and about pages, not a deep crawl.

groupByPage (type: boolean):

Accepted so inputs saved for thenetaji/website-email-scraper run unchanged. Not used: this Actor reads the homepage plus contact and about pages, not a deep crawl.

useProxy (type: object):

Accepted so inputs saved for thenetaji/website-email-scraper run unchanged. Not used.

Actor input object example

{
  "urls": [
    {
      "url": "https://www.eff.org"
    },
    {
      "url": "https://www.torproject.org"
    },
    {
      "url": "https://www.gnu.org"
    }
  ],
  "respectRobotsTxt": true,
  "maxConcurrency": 5,
  "timeoutSecs": 20,
  "maxCostUsd": 5,
  "validateEmails": false,
  "verifyEmails": false,
  "renderJs": false,
  "retryBlocked": false,
  "crawl": true,
  "findContactPages": true,
  "urlField": "website",
  "scrapeEmails": true,
  "scrapePhones": true,
  "scrapeSocials": true,
  "scrapeMessaging": true,
  "scrapeAddress": true,
  "deepScan": false,
  "outputXlsx": false,
  "crmCsv": "hubspot",
  "outputMode": "perDomain",
  "contactPageOnly": false,
  "socialsOnly": false,
  "socialOnly": false,
  "extractPeople": false,
  "onlyWithEmail": false,
  "onlyMxValidEmails": false,
  "onlySiteDomainEmails": false,
  "maxSecondsPerSite": 60,
  "maxRequests": 0,
  "guessEmails": false,
  "maxGuessesPerPerson": 5,
  "scanSiteForGuess": true,
  "considerChildFrames": false
}

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

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://www.eff.org"
        },
        {
            "url": "https://www.torproject.org"
        },
        {
            "url": "https://www.gnu.org"
        }
    ]
};

// 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

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://www.eff.org" },
        { "url": "https://www.torproject.org" },
        { "url": "https://www.gnu.org" },
    ] }

# 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

echo '{
  "urls": [
    {
      "url": "https://www.eff.org"
    },
    {
      "url": "https://www.torproject.org"
    },
    {
      "url": "https://www.gnu.org"
    }
  ]
}' |
apify call pennydata/website-contact-extractor --silent --output-dataset

MCP server setup

{
    "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/S7yN6vYZaRtzx0R1I/openapi.json