Website Email & Contact Scraper: Emails, Phones, Socials avatar

Website Email & Contact Scraper: Emails, Phones, Socials

Pricing

from $2.10 / 1,000 websites

Go to Apify Store
Website Email & Contact Scraper: Emails, Phones, Socials

Website Email & Contact Scraper: Emails, Phones, Socials

Paste company websites, get emails, phone numbers, social links and contact form URLs, one row per site. $3 per 1,000 sites with contacts ($0.003 each). Nothing found = free. First 2 sites per run free. Click Start: the example runs as is. CSV/JSON export.

Pricing

from $2.10 / 1,000 websites

Rating

0.0

(0)

Developer

Penny Data

Penny Data

Maintained by Community

Actor stats

1

Bookmarked

1

Total users

1

Monthly active users

14 days ago

Last modified

Features

Limited permissions

Share

Website Contact Extractor: 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.001 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-24; in contactPageOnly mode a site where a contact form is found costs $0.004 in total, $0.001 + $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.001 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.001 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.001 = $0.418. The 80 failures cost $0. The same 500 domains in contactPageOnly mode where 150 of the billed sites also have a form: $0.418 + 150 x $0.003 = $0.868.

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.004). 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.001
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.008
500 domains (default mode)at most $0.498
1,000 domains (default mode)at most $0.998

(The first-2-free allowance was 25 per run before 2026-10-25. Event price is unchanged. 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 }

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

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

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.001 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.004 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)$0.001 per site with contacts$1.00
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.10$0.40
1,000$1.00$4.00
10,000$10.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.001 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.001 (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)

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@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$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.001 per domain with results (about $1 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.001
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).

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.001 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 $1.00 in the default mode (every site returns something; contactPageOnly mode adds $0.003 per site with a contact form, so up to $4.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.001 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 $1.00. In contactPageOnly mode a site with a contact form costs $0.004, so 1,000 such sites cost $4.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.001, 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.001$0.02
1,000 websites with results$1.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.001 per website where a contact page or form is found, plus $0.003 more for each site where a contact form is found ($0.004 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-23)
Free$0.001$0.005$0.001
Bronze$0.001$0.002$0.0009
Silver$0.0009$0.0018$0.0008
Gold and above$0.0008$0.0016$0.0007

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$1.00
Bronze$1.00$2.00$0.90
Gold and above$0.80$1.60$0.70

Our figures above are for the default mode; in contactPageOnly mode a site with a contact form costs $0.004 (about $4.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.001 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.004 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."

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.001 (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.

Feedback

If this Actor worked for you or didn't, please leave an honest rating on its Store page or open an issue from the Issues tab. We read both and fix reported problems.