Website Email Extractor for Google Maps Leads & Domain Lists avatar

Website Email Extractor for Google Maps Leads & Domain Lists

Pricing

from $1.60 / 1,000 processed websites

Go to Apify Store
Website Email Extractor for Google Maps Leads & Domain Lists

Website Email Extractor for Google Maps Leads & Domain Lists

Website email extractor and contact details scraper for bulk domain lists: best email per site with MX check, phones and social links. Also adds emails to Google Maps results (does not scrape Maps itself). $2/1,000 sites; nothing found = free.

Pricing

from $1.60 / 1,000 processed websites

Rating

0.0

(0)

Developer

Yukai Lin

Yukai Lin

Maintained by Community

Actor stats

0

Bookmarked

42

Total users

19

Monthly active users

5 hours ago

Last modified

Share

What does this contact scraper do?

Paste a list of websites, get one row per website with its emails, phone numbers and social profiles (LinkedIn, Facebook, Instagram, X, YouTube, TikTok and more), ready for CSV, Google Sheets or your CRM. Company names (with a city) and a Google Maps scraper's dataset work too; this Actor reads the listed websites and does not scrape Google Maps itself.

$2 per 1,000 websites, charged only when a contact is found. One price per website, however many pages are read. Websites with nothing found, unreachable websites and duplicates are free. No start fee.

You pasteYou get (one row per website)
hofbraeuhaus.debestEmail willkommen@hofbraeuhaus.de, primaryPhone +4989290136100, charged: true
stumptowncoffee.combestEmail info@stumptowncoffee.com, primaryPhone +18777113385, Instagram, Facebook, X, YouTube, TikTok and Pinterest profiles, charged: true

Real results from our checks: hofbraeuhaus.de home page only, October 19, 2026 (the prefilled example); stumptowncoffee.com with default settings, September 2026 (full row). Live sites can change.

Try it in a minute: the form is prefilled with hofbraeuhaus.de, reading only its home page (at most $0.002). Click Start, then paste 10–20 of your own domains (at most $0.04 at the Free/Bronze rate) and check the rows and SUMMARY; see First run.

Price by plan: Free and Bronze $0.002 per website with a contact ($2/1,000); Silver $0.0018; Gold and above $0.0016. With requiredContact:"email", only sites with an email are charged; failed or unreachable sites are free.

Coming from Contact Details Scraper (vdrmota/contact-info-scraper)? Paste your existing startUrls input as is: it runs without changes, and you pay per website with a contact, not per page. See Switching from another contact scraper.

Choose your input:

How to find emails from a list of domains

  1. Paste your company websites into Websites or company names, one per line (example.com or https://example.com/about; a column copied from a spreadsheet works).
  2. Optional: set Pages per website (default 8) or turn on Role mailboxes only to skip named people's addresses.
  3. Click Start. Each website gets one row with bestEmail, all emails (with an MX check), phones and socials. A website with nothing found, or that cannot be reached, is a free row that says why.
  4. Export as CSV, Excel or JSON. 1,000 websites cost at most $2.

After a completed run, the Output tab offers Leads CSV (overview columns) and Contact coverage report (Markdown). The CSV uses the existing overview columns; the report is saved as CONTACT_REPORT in the run's key-value store, including runs where every input is invalid, a platform page, or skipped by onlyMissingEmail. It counts unique websites queued, successfully read this run, found with a website email, read without one, failed by errorType, and left unprocessed. If dedupeStoreName reuses results from an earlier run, the report counts those free, older rows separately and includes their website emails in delivered coverage. Invalid and skipped input rows are separate from website coverage. For example, 100 queued websites with 70 finding an email, 20 read without an email and 10 blocked or failed are reported with separate denominators, not as 100 verified leads. Failed website rows are free. PARTIAL_RESULTS keeps completed CSV rows and points to RETRY.input for retryable rows; a charge-limit stop lists unprocessed inputs in SUMMARY.notProcessed. An MX record is a domain-level signal, not proof that an individual mailbox accepts delivery. CSV emails are publicly found addresses, not delivery-verified leads. If report storage fails, the run fails instead of claiming a usable report link; saved dataset rows, charges and SUMMARY remain.

First run

  1. Choose an input above. For domains, paste one per line. For company names, include the city or address on each line.
  2. Start with 10–20 websites: it costs at most $0.04. In our October 2026 test, 20 websites finished in 26 seconds; slow websites can take longer. Before reading anything, the log shows the most the run can cost.
  3. Before processing a larger list, check the sample's SUMMARY status and failure counts, then inspect rows with errorType and charged: false. PARTIAL_RESULTS can mean some websites could not be read (network, not_found, blocked, timeout...; 11 of 100 in our October 2026 test). Those failed rows are free; completed rows remain available. See the FAQ for failure details.
  4. What is charged: requiredContact: "any" is the default, at $0.002 per website where at least one email, phone number or social profile was found. If you only need emails, optionally choose requiredContact: "email" so a website with only a phone or social profile is free. Websites with nothing found, unreachable websites, invalid lines and duplicates are free; every row shows charged: true/false.
  5. $5 pays for 2,500 charged websites at the Free and Bronze plan price. Free rows do not count, so a longer list can cost less. To cap a bigger run, set Maximum charge per run in the run options.
  6. If later lists overlap, optionally use the same dedupeStoreName across runs: earlier stored website results are returned with fromPreviousRun, without reading or charging for them again. Failed websites are not stored, so they can be tried again. Reused results may be older than a fresh read; dedupeMaxAgeDays controls their maximum age (default 90 days; 0 means no age limit). See Fill the missing emails in a list you already have.

How to extract emails from Google Maps leads

  1. Run any Google Maps scraper (for example compass/crawler-google-places) for your search, or have your list of company websites ready.
  2. Put that run's dataset ID in Dataset ID, or paste the domains into Websites or company names, one per line.
  3. Click Start. Each place or website gets one row with bestEmail, all emails (with an MX check), phones and socials; the Maps placeId is kept so the rows merge back.
  4. Export as CSV, Excel or JSON. 1,000 places cost at most $2; source rows without a usable website and websites with nothing found are free.

It does not scrape Google Maps itself: run any Google Maps scraper first (for example compass/crawler-google-places), then point this Actor at that run's dataset. It reads the website listed for each place (home page plus contact, imprint and about pages) and returns one row per place, with the place's name, address, phone, Google Maps link and placeId kept, so the emails merge straight back into your Maps export.

For a Maps source row with no usable website listed, the free output row has errorType: "no_website" and websiteStatus: "not_listed_in_source". Filter on websiteStatus to review these candidates. This only means the dataset you supplied did not list a usable website: its scraper may have missed one, and the business may have an official website in another source. No website search is made for these Maps rows.

For API or spreadsheet workflows, mapsData accepts a pasted Maps JSON array, an { "items": [...] } object, or CSV with a website column. An Apify dataset URL can also be passed as datasetId. The optional mapsPlaceRows: true returns flat placeName, address, category, website, emails, phones, socials, source, and sourceIndex columns; the default is off, preserving this Actor's existing output. The listing phone is included in the optional phones column but does not by itself trigger a charge.

Company names instead of websites

A line that is a company name (Katz's Delicatessen) is looked up to its website. Give the city, address or phone with the name, one business per line: Joe's Pizza, 7 Carmine St, New York, NY 10014, or columns pasted from a spreadsheet (tab-separated, or with a header row such as Name,City,Phone). The same works for rows or a dataset that has a name column but no website column. With a city, the name is first looked up in our places database (the open Overture Maps places: a listing with the same name in that city, ideally the same phone or street, that gives a website); then Wikidata's official-website property (P856, strict name match; with a city or phone in the row, Wikidata's headquarters or phone must match too); then the likely domains are checked (joespizzanyc.com, the country's domains first: .co.uk, .de, .com.br, .com.tw...). A website is used only when verified: its home page names itself as the business (schema.org, og:site_name or the title), or shows the row's phone; with a city or address in the row the page must also show the city, street, postcode or phone, so a same-name business elsewhere is not taken. Parked or for-sale pages, and a guessed domain that redirects to an unrelated site, never count. When the website was found through its places listing and shows no email or phone itself, the listing's email or phone is added with source: "overture-places" and is not charged. The row shows how it was found (websiteFromName: method, evidence, listing). Names without a verified website get a free row (errorType: "company_not_found") with the reason and a hint on what to add (for example "add a city or address column"); SUMMARY companyNamesMessage says how many matched. The website is then read and charged like any other ($0.002, only when a contact is found); the lookup itself costs nothing. Our October 13, 2026 check of 40 local businesses in the US, UK, Germany, Taiwan and Brazil: names only 28% found; name + city 82%, name + address 85%, name + address + phone 87%, with no wrong website when the row had a city or address. Names alone are ambiguous (there are many "Joe's Pizza"): add the city.

Why this one

  • Made for Google Maps leads: reads the dataset of compass/crawler-google-places, compass/google-maps-extractor and similar scrapers as is (the website field is found automatically, the Maps url is never read as a website). Source rows without a usable website get a free row, so every place maps back 1:1; for places with only an Instagram or Facebook page, the company's own website is looked up by name and address.
  • One best email per business, checked for free: bestEmail picks a person's address on the company's domain, then a shared inbox (info@, sales@) on the company's domain, then a free-mail address (gmail.com...). Every email has a syntax check, an MX check (does the domain receive mail?), personal/role and free-mail flags, at no extra cost.
  • $2 per 1,000 websites, charged per website, not per page or per email (Gold and above $1.60), with no start fee. Websites with nothing found are free, and so are unreachable websites, Maps source rows without a usable website and duplicates (branches of a chain sharing one website are charged once). Every row says charged: true/false; with requiredContact: "email" you pay only for websites where an email was found.
  • Finds hidden emails: obfuscated addresses ("info [at] firm [dot] de"), Cloudflare-protected and script-encoded emails are decoded, and junk such as logo@2x.png or you@example.com is filtered out. JavaScript-only websites are read with a browser at no extra charge; if Cloudflare browser capacity or its monthly usage limit is reached, our Chrome pool is tried. No API key, login or proxy needed.
  • CSV-ready: flat columns (businessName, bestEmail, emailsText, placeId, mapsUrl...) for Sheets, CRMs and outreach tools, or one row per email for Instantly and lemlist.

Input: domain list or Google Maps results (copy and paste)

A plain list of domains (the most used input), one website per entry:

{
"domains": ["stumptowncoffee.com", "hofbraeuhaus.de"]
}

Or the results of a Google Maps run: put its dataset ID (Console: the run's Storage tab, or defaultDatasetId in the API) in Dataset ID. Nothing else is needed:

{
"datasetId": "YOUR_GOOGLE_MAPS_DATASET_ID",
"requiredContact": "email"
}

Output: one row per place (shortened)

placeName, placeAddress, placePhone, mapsUrl and placeId are copied from your Maps dataset (illustrative values here); contact fields come from the business's website when a usable one is listed. A free Maps row with no usable source website has errorType: "no_website", websiteStatus: "not_listed_in_source", charged: false and the same place columns. The status describes the supplied row only; it does not verify that the business has no official website.

{
"businessName": "Stumptown Coffee Roasters",
"placeName": "Stumptown Coffee Roasters",
"placeAddress": "128 SW 3rd Ave, Portland, OR 97204",
"placePhone": "+15032241000",
"placeId": "ChIJ...",
"mapsUrl": "https://www.google.com/maps/search/?api=1&query=...&query_place_id=ChIJ...",
"domain": "stumptowncoffee.com",
"bestEmail": "info@stumptowncoffee.com",
"bestEmailType": "role_company",
"bestEmailHasMx": true,
"emailsText": "info@stumptowncoffee.com, legal@stumptown.com, customerservice@stumptown.com",
"primaryPhone": "+18777113385",
"socialProfiles": { "instagram": "https://www.instagram.com/stumptowncoffee", "facebook": "https://www.facebook.com/stumptowncoffee" },
"charged": true
}

The website values above are the real result for stumptowncoffee.com (September 2026; MX checked October 4, 2026). It was charged once, $0.002, whatever the number of pages read. All fields, where each value was found and the pages checked: Output example.

How much does it cost?

EventPrice
Processed website$2.00 / 1,000 websites ($0.002 each)

Example: 2,000 websites, of which 1,500 have at least one contact, cost 1,500 × $0.002 = $3.00; the other 500 are free.

No start fee. One price per website, whether 1 or 20 pages are read. You pay only when at least one email, phone number or social profile is found. Websites with nothing found, unreachable websites (they carry an errorType: blocked, network, not_found, timeout…), invalid lines and duplicates are free. Every row has charged: true or false. Higher Apify plans get volume discounts: $1.80 per 1,000 websites on Silver, $1.60 on Gold and above.

Features

A bulk email extractor for a list of domains or for Google Maps results: give it company or business websites (URLs or bare domains, up to 50,000 per run) or the dataset of a Google Maps run, and get one clean record per website:

  • 📧 Emails: mailto: links, addresses written in the text, obfuscated addresses ("info [at] firm [dot] de", "info(at)firm.de", the full-width "info@firm.com.tw", addresses written backwards or put together by a script, ROT13 decryptMail(...) links, WordPress email-encoder plugins) and Cloudflare-protected emails (decoded). Lower-cased, de-duplicated, and junk is filtered out: image file names such as logo@2x.png, placeholder and example addresses (you@example.com, name@domain.com), error-tracking IDs and no-reply addresses.
  • 📞 Phone and fax numbers in international format (+12122542246), from tel: links, WhatsApp chat links (wa.me/…), schema.org data and the page text. Every number is checked with Google's libphonenumber rules for its country, and numbers in text are only taken after a label ("Tel:", "Phone", "電話"...; the next numbers of the same list too), in international format, or in a typical phone layout, so order numbers, dates, prices and example numbers are not picked up.
  • 👥 Social profiles: Facebook, X, LinkedIn, Instagram, YouTube, TikTok, GitHub, Pinterest, Threads, Trustpilot, Discord, Telegram and WhatsApp. Only real profile URLs count (share buttons, posts and videos are ignored), and default profiles from website templates (e.g. facebook.com/wix) are skipped.
  • 🏢 Organization name, logo and postal address from the site's schema.org data (JSON-LD or Microdata: Organization, LocalBusiness, Restaurant, Dentist...).
  • 📝 Contact form URL: the page with a contact form (HTML forms with a message box, or HubSpot, Contact Form 7, Typeform, Jotform and other embeds).
  • ✅ Free email quality signals: for every email a syntax check (syntaxValid), an MX check (hasMx: the domain publishes mail servers), emailType (role for shared inboxes such as info@ or sales@, personal for the rest) and freeMail (gmail.com, yahoo.com, web.de...), plus one bestEmail per website (see Best email). No mail server is contacted, so this is not a mailbox verification.
  • 📍 Google Maps columns kept: with a Google Maps dataset each row gets placeName, placeAddress, placePhone, mapsUrl, placeId, placeCategory, placeCity and placeCountryCode from its place, and businessName (the Maps name, else the website's organization name).
  • 🔎 Where each value was found: the page and the method (mailto, text, obfuscated, cloudflare, jsonld, attribute, script, tel-link, whatsapp), plus the list of pages checked.

For each website it reads the home page plus likely contact pages: contact, imprint/Impressum (including German "Rechtliche Angaben", "Rechtliches" and "Anbieterkennzeichnung" links), locations, about, team, support and legal pages (in English, German, French, Spanish, Portuguese, Italian, Dutch, Japanese, Chinese and Korean wording), up to 8 pages by default. When the site's own email and a phone number are already found, the remaining team, support and privacy/terms pages are skipped.

Use cases

  • Google Maps leads to emails: add the best email, all emails and social profiles to the places from a Google Maps scraper, merged back by placeId
  • Lead generation: turn a list of company domains into emails, phone numbers and social profiles
  • Enrich directory results: add emails and socials to businesses that only list a website
  • CRM clean-up: find current contact details and social accounts for your accounts
  • Outreach research: find the press, sales or support address of each company

Cost control and price comparison

Two billing modes (requiredContact)

ModeCharged ($0.002 per website)Free
any (default)at least one email, phone number or social profile foundnothing found, unreachable, invalid, duplicate
emailat least one email address foundeverything else, including websites with only a phone number or social profile (still returned in the dataset with charged: false)

Use email for cold-email lists where a phone number or social link is of no use to you: you pay only for rows you can email. The price per website is the same in both modes; email can only cost less.

Control your cost

  • Before anything is read, the run logs its plan: the number of websites × the price = the most the run can cost, compared with your maximum charge per run.
  • Charged: a website where at least one email, phone number or social profile was found ($0.002). With requiredContact: "email", only a website where at least one email was found is charged (same $0.002; a website with only a phone number or social profile is returned but free).
  • Free: websites with nothing found, unreachable or blocked websites, company names whose website could not be verified (company_not_found), other lines that are not a website, and duplicates: acme.com, www.acme.com and acme.com/en are one website, charged once. There is no extra charge from us for more pages, the browser fallback or our own network; only an Apify Proxy you choose yourself is billed by Apify.
  • Maximum charge per run: set it in the run options (Console) or with maxTotalChargeUsd (API). When the next website could exceed it, no new website is started; the run ends with status LIMIT_REACHED, and the SUMMARY record lists the websites not processed (notProcessed: the count and up to 100 inputs) so you can run them again with a higher limit.
  • If Apify restarts the run (server migration or Resurrect), items already finished are skipped and not charged again (SUMMARY.resumedSkipped).

Price comparison (checked September and October 2026)

ActorPriceCost for 1,000 websites at 5 pages each
Website Email Extractor (this Actor)$0.002 per website with contacts found$2 at most (websites with nothing found are free)
vdrmota/contact-info-scraper$0.002 per page scraped$10
compass/crawler-google-places, contacts add-on$0.002 per place, only inside a new Google Maps scrape (plus $0.004 per place scraped)$2 for contacts (+ $4 for the places)
lukaskrivka/google-maps-with-contact-details$0.0025 per place for contacts, only inside its own Google Maps scrape (plus $0.005 per place)$2.50 for contacts (+ $5 for the places)

vdrmota/contact-info-scraper is the most used Actor in this category (about 2,380 users in the last 30 days on October 2, 2026) and can follow links more deeply; it charges per page. This Actor charges per website where something was found and picks the likely contact pages for you.

How to use it

  1. Domain list: paste domains or website URLs into Websites or company names, one per line (a column copied from a spreadsheet works). Blank lines are ignored; a company name is looked up to its website (see Company names instead of websites); a line that is neither gets one free invalid_input row. Google Maps results: put the dataset ID of your Google Maps run in Dataset ID (see Find emails for Google Maps results). Or pick another run's dataset (see below).
  2. Optionally change Pages per website (1–20, default 8).
  3. Click Start and export the results as JSON, CSV or Excel.

Input example

This is the one-site input prefilled in the form. It reads one public homepage; the local extraction check took 4.7 seconds on October 19, 2026 (Apify run time can differ):

{
"domains": ["https://www.hofbraeuhaus.de/"],
"maxPagesPerSite": 1
}

Output example (real result, September 2026)

{
"input": "stumptowncoffee.com",
"inputIndex": 0,
"url": "https://stumptowncoffee.com/",
"domain": "stumptowncoffee.com",
"finalUrl": "https://www.stumptowncoffee.com/",
"success": true,
"organizationName": "Stumptown Coffee Roasters",
"primaryEmail": "info@stumptowncoffee.com",
"primaryPhone": "+18777113385",
"emailsText": "info@stumptowncoffee.com, legal@stumptown.com, customerservice@stumptown.com",
"phonesText": "+18777113385, +18332672844, +18557113385",
"emails": ["info@stumptowncoffee.com", "legal@stumptown.com", "customerservice@stumptown.com"],
"phones": ["+18777113385", "+18332672844", "+18557113385"],
"faxes": [],
"socialProfiles": {
"instagram": "https://www.instagram.com/stumptowncoffee",
"facebook": "https://www.facebook.com/stumptowncoffee",
"x": "https://twitter.com/stumptowncoffee",
"youtube": "https://www.youtube.com/channel/UC0yk_H5Np-uGvy98UM-jj6w",
"tiktok": "https://www.tiktok.com/@stumptowncoffee",
"pinterest": "https://www.pinterest.com/stumptowncoffee"
},
"address": "700 SW 5th Ave, 3rd Floor, #400, 97214 Portland, OR",
"logo": "https://customers.seomanager.com/knowledgegraph/logo/stumptowncoffee_myshopify_com_logo.png",
"contactFormUrl": null,
"emailDetails": [
{ "email": "info@stumptowncoffee.com", "page": "https://www.stumptowncoffee.com/", "method": "jsonld", "sameDomain": true, "mailbox": "role" },
{ "email": "legal@stumptown.com", "page": "https://www.stumptowncoffee.com/pages/terms-and-conditions", "method": "text", "sameDomain": false, "mailbox": "role" }
],
"phoneDetails": [
{ "number": "+18777113385", "display": "877-711-3385", "national": "(877) 711-3385", "country": "US", "type": "phone", "page": "https://www.stumptowncoffee.com/", "method": "jsonld" }
],
"pagesChecked": [
{ "url": "https://www.stumptowncoffee.com/", "kind": "home", "via": "direct", "httpStatus": 200 },
{ "url": "https://www.stumptowncoffee.com/pages/contact-us", "kind": "contact", "via": "direct", "httpStatus": 200 },
{ "url": "https://www.stumptowncoffee.com/pages/locations", "kind": "locations", "via": "direct", "httpStatus": 200 },
{ "url": "https://www.stumptowncoffee.com/pages/our-story", "kind": "about", "via": "direct", "httpStatus": 200 },
{ "url": "https://www.stumptowncoffee.com/pages/terms-and-conditions", "kind": "legal", "via": "direct", "httpStatus": 200 }
],
"charged": true,
"via": "direct"
}

A German restaurant (hofbraeuhaus.de) returned willkommen@hofbraeuhaus.de (mailto link), +4989290136100 (written as "+49 (0)89 – 290 136 100" in the text) and its Facebook and Instagram profiles. A Munich food retailer (dallmayr.com) returned info@dallmayr.de, decoded from Cloudflare's email protection.

For spreadsheets and CRMs, primaryEmail is an email on the website's own domain (else the first email), a role mailbox such as info@, contact@, hello@ or sales@ before a person's address, primaryPhone the first phone number (not a fax), and emailsText / phonesText hold all of them separated by commas. input is the line you gave; when several lines were the same website they are merged into one row and listed in inputs (a page path such as hofbraeuhaus.de/en is read as an extra page, kind: "given"). Emails on the website's own domain come first (sameDomain: true); others (a parent company, an agency, a data-protection officer) follow. mailbox in emailDetails is role for known shared inboxes (info@, sales@, support@, press@…) and other for the rest (often a person's address, but not always), so you can filter shared inboxes out or in. Profiles linked only once, e.g. from a blog post, are not reported as the company's own. via says where each page was read from: direct (Apify's network), backend (our servers) or browser. A SUMMARY record in the key-value store has the run status (SUCCESS, PARTIAL_RESULTS, FAILED, NO_RESULTS, LIMIT_REACHED) and counts of websites with emails, phones and social profiles, charged websites, invalid lines and merged duplicates.

Best email and email quality

Every website row has bestEmail, the one address to use for outreach, picked in this order:

bestEmailTypeMeaning
personal_companynot a known shared inbox, on the website's own domain (often a person: maria@acme.com)
role_companya shared inbox on the website's own domain (info@, contact@, sales@...)
free_maila free-mail address (gmail.com, yahoo.com, outlook.com, web.de...), common for small businesses
other_domainanother domain (a parent company or an agency)

Addresses whose domain has no mail server (no MX record, a "null MX" or a domain that does not exist) or with broken syntax are never picked. bestEmailHasMx is true when the domain publishes mail servers, false when it does not, and null when the DNS lookup failed. primaryEmail is unchanged (a shared inbox on the website's own domain first), so existing integrations keep working.

Each entry of emailDetails also has syntaxValid, hasMx, emailType (personal or role), freeMail and duplicateEmail (true when the same address was already returned for another website earlier in the run, e.g. one agency address on many client sites). personalEmailsText and roleEmailsText list them by type. These checks are free: no extra event and no extra charge. They use DNS only and never contact a mail server, so a mailbox that does not exist can still bounce; for a verdict and a ranked send list per address, run the results through Email List Cleaner (see Lead-cleaning workflow).

Match a supplied person's name to a published email pattern

For a source dataset or rows with a website and a person's full name, set targetPersonNameField to that name column. Leave it empty to look for personName, contactName or fullName in each source row; a company Name column is never used as a person's name. For example: {"rows":[{"website":"example.org","contactName":"Jane Doe"}]}. The Actor adds targetPersonName, namePatternMatchedEmail, namePatternMatch and namePatternMatchBasis to rows that have a supplied person name. namePatternMatchedEmail is either an address already present in that website's emailDetails, or null. The basis says that the email's local part (before @) matches the supplied full name as text; it does not verify identity or mailbox ownership. The original page remains in the matching emailDetails[].page (or emailFoundOn in email row mode). If the site publishes only info@, the match is null and namePatternMatch is false; no jane@ address is generated. Non-Latin names, initials, multiple matching published addresses, duplicate source people with the same name on one website and no matching address also return null/false. If no person name is supplied, these four columns are absent, as before. The website is still charged at most once under the existing contact rule.

One row per email (for Instantly, lemlist and CRM imports)

Set Output format to One row per email ("outputMode": "emailRows") to get one dataset row per email instead of one row per website, the shape outreach tools import. Each row has email, isBestEmail, emailType (personal or role), emailHasMx, emailSyntaxValid, emailFreeMail, duplicateEmail, isPrimaryEmail, emailSameDomain, emailMailbox (role or other), emailFoundOn (the page) and emailMethod, plus the website's domain, businessName, organizationName, bestEmail, primaryPhone, socialProfiles, socialProfileSources, address, source and, for Google Maps input, the place columns (placeId, placeName...). When a person name is supplied, every email row also keeps targetPersonName, namePatternMatchedEmail, namePatternMatch and namePatternMatchBasis. socialProfileSources maps each found social platform to the website page where its profile link was found; when no source page was recorded, no page is added. The mapping is also kept on the single email: null row for a website with a phone or social profile but no email. Use the One row per email table view, or export the dataset as CSV.

{ "domains": ["katzsdelicatessen.com", "hofbraeuhaus.de"], "outputMode": "emailRows" }

The price does not change: each website is still charged once, whatever the number of emails, and only its first row shows charged: true. A website with a phone or social profile but no email gives one row with email: null. The default, One row per website ("sites"), is unchanged.

Find emails for Google Maps results

Google Maps lists a website and a phone number for most businesses, but no email. This Actor reads each listed website and adds the emails, the best email and social profiles to every place: at most $2 per 1,000 places, and places with nothing found are free. It works with the datasets of compass/crawler-google-places, compass/google-maps-extractor, lukaskrivka/google-maps-with-contact-details and other scrapers with the same fields (title, website, phone, address, url, placeId), and with Maps exports that use name, site/website, full_address and place_id.

What you need to set: only the dataset ID. The website field is found automatically (website; the Maps url field is the Google Maps page and is never read as a website), and each output row gets these flat columns from its place:

ColumnFrom the Maps item
placeNametitle (or name)
placeAddressaddress (or full_address)
placePhonephoneUnformatted, else phone
mapsUrlurl (the Google Maps link)
placeIdplaceId (or place_id); also the row's rowId
placeCategory, placeCity, placeCountryCodecategoryName, city, countryCode

Website values are cleaned first: Google redirect links (google.com/url?q=...) are unwrapped, tracking parameters such as ?utm_source=gmb do not matter (websites are merged by domain), and a "website" that is itself a Google Maps link counts as no website.

Recipe 1: automatically after every Google Maps run (Apify Console integration).

  1. Open your Google Maps scraper task (or Actor) and go to the Integrations tab.
  2. Choose Connect Actor or task and pick this Actor (tidytools/website-contact-extractor).
  3. Use this input; {{resource.defaultDatasetId}} is replaced with the dataset of the Google Maps run that just finished:
{ "datasetId": "{{resource.defaultDatasetId}}", "requiredContact": "email", "dedupeStoreName": "my-maps-leads" }

requiredContact: "email" charges only places where an email was found; dedupeStoreName skips websites already read in earlier runs (not charged again), useful when your searches overlap.

Recipe 2: from your own code or a webhook. When the Google Maps run succeeds (an Apify webhook on ACTOR.RUN.SUCCEEDED, or your script polling the run), start this Actor with the run's dataset:

curl -X POST "https://api.apify.com/v2/acts/tidytools~website-contact-extractor/runs?token=YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"datasetId":"MAPS_RUN_DEFAULT_DATASET_ID","requiredContact":"email"}'

Then export this run's dataset with the googleMaps view (/items?view=googleMaps&format=csv) and join it to your Maps export on placeId.

Once, for a run you already have: set Dataset ID to the dataset of that run and start. To copy more fields of the place into the output (e.g. totalScore, reviewsCount), list them in Dataset fields to keep: they go into a source object. Any dataset with a website field works the same way, e.g. a directory or trade-show exhibitor list (set Dataset field when the field has an unusual name).

Every item of the dataset gets exactly one output row, so a Google Maps export joins back 1:1 (by placeId, rowId or datasetItemIndex):

  • A place whose source row lists no usable website gets a row with errorType: "no_website", websiteStatus: "not_listed_in_source" and its place columns (not charged). This does not establish whether the business has an official website elsewhere.
  • A LinkedIn, Facebook, Instagram, X, TikTok, YouTube, Yelp, Tripadvisor, Google Maps, OpenTable, Booking.com, Linktree or similar page, or a business directory (411.ca, Yellow Pages, PagesJaunes, Gelbe Seiten...), given as the website is never crawled as the company's website (that would return the platform's own details) and never logged into. Instead: if the same row has another website column (e.g. Domain), that website is read (websiteFromRow); otherwise the website is looked up from the row's business name, or the name in the page's address when it clearly is one (facebook.com/JoesPizzaNYC -> "Joes Pizza NYC"), together with the row's city or address (websiteFromName.platformPage); otherwise the row has errorType: "platform_url" with a hint ("this is a LinkedIn page, not the company's website — add the website, or the company name and city") and the profile in socialProfiles. These rows are free; only a real company website found this way is charged as usual, when it has contacts. SUMMARY.platformPages counts them.
  • Branches of a chain that share one website are read and charged once; each other branch gets a copy of the result with its own place columns, charged: false and duplicateOf.
  • When a website redirects to a different domain (for example an expired domain now parked on a directory), the row has redirectedTo and a warning: the details found may belong to that other site. A business directory's own mailboxes (for example help@411.ca) are never returned as the business's email, and neither are addresses of big platforms named in a privacy policy (google.com, facebook.com, paypal.com...): those go to droppedEmails.

Real example (September 30, 2026). 30 Google Maps places from compass/crawler-google-places (10 coffee shops in Portland, 10 dentists in Austin, 10 bakeries in Munich), with datasetKeepFields: ["title", "placeId"]:

ResultPlaces
Output rows (one per place, each with its placeId)30 of 30
No usable website listed in the supplied Maps source row1 (free)
Instagram or Facebook page instead of a website3 (free unless the company's own website is found)
Website no longer exists2 (free)
Websites read with contacts found (charged)24
… with an email21 (70% of all places, 88% of the websites read)
… with a social profile23

Cost: 24 × $0.002 = $0.048 (about $1.60 per 1,000 places); run time 12 seconds. Google Maps already lists a phone number for almost every place, so the email and the social profiles are what this step adds.

Lead-cleaning workflow: from Google Maps to a verified send list

For sales teams who want a list they can import into a CRM or outreach tool: Google Maps results (or a domain list) → this Actor → Email List Cleaner (tidytools/bulk-email-validator, $0.50 per 1,000 addresses) → a ranked "Ready to send" CSV → optionally Lead & Company Enrichment for industry, size and company social profiles.

  1. Run this Actor on your Google Maps dataset or domain list with "requiredContact": "email" (only websites where an email was found are charged).
  2. In this Actor's (or your task's) Integrations tab choose Connect Actor or task, pick Email List Cleaner and use this input:
{ "datasetId": "{{resource.defaultDatasetId}}" }
  1. The validator checks every address in emails (or only bestEmail if you set its Dataset field to bestEmail), keeps each row's businessName, placeId, website and rowId, and saves READY_TO_SEND: a CSV sorted deliverable → risky → undeliverable, each business's best email first. Join it back to your Maps export on placeId.

The same chain also works as an Apify webhook (ACTOR.RUN.SUCCEEDED on this Actor starting the validator with the run's defaultDatasetId) or as one n8n flow (Google Sheet of domains → this Actor → validator → a "Ready to send" sheet tab).

At the end of every run the log and SUMMARY.nextSteps give the exact input for Email List Cleaner and Lead & Company Enrichment with this run's dataset ID and the most each step can cost, ready to paste (nothing is started for you).

4. Company data for each lead (optional). Connect Lead & Company Enrichment (tidytools/company-website-enrichment, $0.006 per company) after the validator, with the same { "datasetId": "{{resource.defaultDatasetId}}" } input. Every validated address keeps its row (email, sendStatus, businessName, placeId, website) and gets what the company does, its industry, B2B/B2C, the employee band when the company states it, and the company's social profiles; several addresses of one business are read and charged once. On the test below this step added $0.108 (18 companies for 35 rows), so the whole chain cost about $0.17 for 20 websites.

Customer-owned list example (20 businesses; illustrative, not a test run). A customer who has the right to use a CSV with 20 rows (row_number,Business,Website,Email) uploads it as an Apify Dataset; Website contains each business's own site, not a Google Maps or directory page. TidyTools does not supply the source list or collect Google Maps results for you. Use the customer's Dataset ID in the contact step:

{ "datasetId": "CUSTOMER_DATASET_ID", "datasetField": "Website", "rowIdField": "row_number", "onlyMissingEmail": true, "findWebsitesForNames": false, "requiredContact": "email", "respectRobotsTxt": true }

If wanted, run Email List Cleaner on the contact result with { "datasetId": "CONTACT_RUN_DATASET_ID" }; it checks email addresses, not web pages, and has no respectRobotsTxt setting. For company data on the original 20 rows, run Lead & Company Enrichment separately with:

{ "datasetId": "CUSTOMER_DATASET_ID", "datasetField": "Website", "findWebsitesForNames": false, "respectRobotsTxt": true }

Both website-reading steps now skip pages disallowed by robots.txt. A row without a usable website stays free with errorType: "no_website" (name lookup is off); when robots.txt disallows the home page, it is not read and the free row has errorType: "robots_disallowed". A site with no email is also free in the contact step because requiredContact: "email". At current Free-plan list prices, the maximum for the two website steps is 20 × $0.002 per successful contact website + 20 × $0.006 per successfully enriched company = $0.16; Email List Cleaner adds at most $0.0005 × the number of unique addresses actually checked. For 250 distinct businesses the same ceiling is 250 × ($0.002 + $0.006) = $2.00, plus email checks. These are ceilings, not success-rate estimates; skipped, failed and duplicate websites can lower the charge. This robots-on recipe is optional: the Actors' respectRobotsTxt default remains false.

Real numbers (our test run, 4 October 2026): 20 public websites of small food businesses (bakeries, delis, restaurants). This Actor read all 20 in 26 seconds and found at least one email on 18 (35 addresses); the validator checked the 35 in 2 seconds and rated all 35 deliverable (18 were role addresses like info@). Cost: $0.04 for the websites (all 20 charged in the default mode, $0.036 with "requiredContact": "email") plus $0.0175 for the addresses, about $0.06. Well-known businesses publish more emails than average: across our 200-website benchmark an email was found on about 67% of websites. The free hasMx check on every email here already catches dead domains; the validator adds disposable, typo and no-reply flags, a verdict and score per address, and the ranked CSV with your business columns.

Fill the missing emails in a list you already have

Most lead lists (Google Maps exports, directories, CRM exports, places datasets) have a website for almost every business but an email for only some. This Actor fills the gaps and leaves everything else alone:

  • Only rows without an email (onlyMissingEmail: true): rows that already have an email are not read and not charged.
  • Your values are never overwritten: each row has a fill object with only the values for columns that are empty in your row (e.g. {"Email": "info@acme.de"}), ready to write back.
  • Every row keeps its identifier (rowId: your sheet's row_number, a Google Maps placeId, an id column). Branches of a chain that share one website are read and charged once, but each branch gets its own row.
  • Send the rows of a sheet directly with rows (from n8n, Make or the API), or point datasetId at another run's dataset. The website, email, phone and id columns are found automatically (Website, Email, row_number...), or set them with datasetField, emailField, phoneField, rowIdField.
  • No double payment across runs: with a dedupeStoreName (any name), a website (by domain) read in an earlier run in the last 90 days is returned again with fromPreviousRun, without being read or charged. Failed websites are not remembered, so they are tried again.
  • Retry list: websites that failed for a reason that may pass next time (timeout, blocked, connection error) or were not started because your maximum charge was reached are listed in the key-value store record RETRY; its input can be used as the next run's input as is. Domains that no longer exist are not in it.
  • Each row has an emailStatus: new_email, no_email_found, already_had_email or not_read (with errorType, and retryable: true when it is worth another try).
{
"rows": [
{ "row_number": 2, "Business": "Pizzeria A", "Website": "pizzeria-a.de", "Email": "" },
{ "row_number": 3, "Business": "Dentist B", "Website": "dentist-b.com", "Email": "hello@dentist-b.com" }
],
"rowIdField": "row_number",
"onlyMissingEmail": true,
"dedupeStoreName": "my-lead-sheet"
}

Real test (October 2, 2026), counted per website, not per dataset row. Two lists from Places Database (Overture Maps): 200 Berlin businesses (restaurants, hotels, dentists, sent as rows like a Google Sheet) and 165 Lisbon dental clinics (by datasetId).

Berlin, 200 rowsLisbon, 165 rows
Rows with a website199151
... of which already had an email (skipped, free)6219
Rows with a website and no email137132
Websites read (chain branches merged)129117
Websites where a new email was found8972
Rows that got a new email (incl. branches)9579
Websites charged (email, phone or social profile found)9785
Websites not reachable (free)2127

The Berlin list went from 62 to 157 rows with an email (+95). Cost: 97 × $0.002 = $0.19. Running the same sheet again with the same dedupe name reused 97 websites (not charged) and charged 1 newly found website.

Ready-made n8n workflows: Fill missing emails in a Google Sheet (reads the sheet, sends only rows without an email, writes the email and a status back to the same row, retries the retry: rows on the next run) and Add missing emails to a Google Maps or places dataset. Both are in the public n8n workflows folder, together with a third one that also verifies the emails and adds company data.

Switching from another contact scraper

Paste your existing input; it works as is. Inputs written for other popular contact scrapers are read without changes: startUrls ([{ "url": ... }] or plain strings), urls, websites and domains become the website list, maxRequestsPerStartUrl becomes Pages per website (up to 20), maxWebsitesConcurrency becomes Parallel websites, and proxyConfig or a useProxy proxy object becomes Proxy. The run log lists every field that was mapped, and names the settings that are not needed here (crawl depth, browser, retries, verification and enrichment add-ons) with the reason. Nothing in your input changes the price.

For example, this input from a page-based contact scraper starts a normal run here:

{
"startUrls": [{ "url": "https://katzsdelicatessen.com" }, { "url": "https://hofbraeuhaus.de" }],
"maxRequestsPerStartUrl": 20,
"maxDepth": 2,
"sameDomain": true,
"mergeContacts": true,
"proxyConfig": { "useApifyProxy": true }
}

The log then shows "maxRequestsPerStartUrl": 20 is used as "Pages per website" = 20 and Not needed here: "maxDepth", "sameDomain", "mergeContacts".... The likely contact pages (contact, imprint, about, team...) are read first, so the default 8 pages are usually enough. With proxyConfig, Apify Proxy traffic is billed by Apify; most websites work without it.

How the two most used website contact scrapers compare (Free/Bronze plan prices from the Apify Store, checked October 2, 2026; check their Pricing tabs for changes):

This Actorvdrmota/contact-info-scraperautomation-lab/website-contact-finder
Users in the last 30 daysnewabout 2,380about 120
Charged perwebsite with a contact foundpage scrapedwebsite scanned
Price$0.002 per website$0.002 per page$0.001 per website
Start fee per runnone$0.001 per GB of memory (4 GB default: $0.004)$0.035
1,000 websites, 5 pages each$2.00 at most$10.00 + start fee$1.035
1,000 websites, 20 pages each$2.00 at mostup to $40 + start fee$1.035
20 websites (a small run)$0.04 at most$0.20 + start fee$0.055
Website with nothing foundfreepages are chargedcharged when the scan succeeds
Unreachable websitefree—free
JavaScript-only websitesbrowser fallback, no extra chargeoptional browser (+$0.003 per page)no browser
Email checksfree: syntax, MX, role/personal, free mail, best email per site (no SMTP)add-on (paid)add-on (paid, MX/SMTP)
Named leads, social profile detailsnoadd-ons (paid)no
Read websites from another run's dataset (e.g. Google Maps)yes (datasetId)nono
One row per email (for outreach tools)yes (outputMode)one row per domain, or per pageone row per website

In short: compared with a per-page scraper, this Actor costs the same per website as one page there, and websites with nothing found are free. A per-website scraper with a start fee is cheaper per website on large runs where most websites have contacts (above about 35 websites per run at Free plan prices); this Actor is cheaper on small runs and when many websites have no contacts, and it reads JavaScript-only websites with a browser at no extra charge. Every email gets free syntax and MX checks and a role/personal flag here; if you need SMTP-verified mailboxes or named leads, the paid add-ons of the other Actors offer them.

Use with AI agents (MCP)

Connect Apify's MCP server (https://mcp.apify.com?tools=tidytools/website-contact-extractor) to Claude, Cursor or any MCP client, then ask e.g. "Find the emails, phone numbers and social profiles of these 30 companies."

{ "domains": ["stumptowncoffee.com", "hofbraeuhaus.de"] }

Websites with nothing found and unreachable websites are not charged; failed rows carry an errorType.

Use it from code

curl -X POST "https://api.apify.com/v2/acts/tidytools~website-contact-extractor/run-sync-get-dataset-items?token=YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"domains":["stumptowncoffee.com"]}'

Send websites in domains (plain strings; bare domains work). The URL-list field startUrls is also accepted, but Apify rejects the whole run (HTTP 400) when it holds a bare domain or a blank line.

Need industry, a one-line description and a country guess as well? See Company Website Enrichment by tidytools.

Schedules and integrations: run it daily or weekly with Apify Schedules, get a webhook when a run finishes, or send the results to Zapier, Make, n8n, Google Sheets, Slack and other apps with Apify integrations. Results can be exported as JSON, CSV, Excel or XML.

Advanced settings

  • Plain HTTP requests from: Auto (recommended) reads pages from Apify's network first, because the raw HTML is needed for Cloudflare-protected emails and contact forms, then from our servers, then with a real browser, and a website that refuses all of these is tried once more from our servers in other regions. If the home page is still refused with HTTP 403 or a refusal of automated clients (not a CAPTCHA or bot-check page), it is asked once more through our Cloudflare WARP network with a standard browser User-Agent that still ends with TidyToolsContacts/1.0 (input warpRetry, on by default; Auto and Our servers only); a website read this way is charged like any other, and one that refuses again or shows a bot check stays a free blocked row. JavaScript-only home pages are read with a browser automatically. A bare domain that does not answer is also tried as www. and, for a broken certificate, over http. A home page that only forwards visitors (<meta http-equiv="refresh"> or a one-line script) is followed like a redirect.
  • Pages per website (default 8): the home page and the likely contact pages (contact, imprint/Impressum, about, locations, team, support, legal) are read first. When none of them shows an email and pages are left over (for example the home page links to no contact page), the remaining pages go to the pages most likely to name a mailbox: policies and terms, catering/events, referrals and patient forms, jobs, locations, FAQ, taken from the home page's links and then from the sitemap (kind: "more" in pagesChecked). It stops at the first page with an email and never reads more than this setting; the price per website does not change. In a test on 65 small-business websites where the contact pages had no email, this found an email on 6 more of them.
  • Pages not to read (excludePaths, optional, empty by default): paths such as /blog or /privacy that are never picked from links, guessed or taken from the sitemap; the next likely page takes their place in the page budget, and a picked page that redirects there is not used. /blog also covers /blog/... but not /blogger; letters are compared regardless of case and the query (?...) is ignored. The home page and pages you give yourself (acme.com/blog) are always read. Excluding pages can mean that fewer websites have a contact found, and only those are charged. An entry that is not a path starting with / (a full URL, a wildcard *, a query) gives one free invalid_input row and nothing is read. Example: "excludePaths": ["/blog", "/privacy"].
  • How the crawler identifies itself: every request names us: the user agent is TidyToolsContacts/1.0 (+https://apify.com/tidytools) from Apify's network, and TidyToolsSmartFetch from our servers and in the browser; only the one WARP retry of a refused home page (above) sends a browser User-Agent, still ending with TidyToolsContacts/1.0. robots.txt is not used to refuse a website you asked for: the sites you list are read even where their robots.txt disallows automated crawlers, as when you open them in your browser. Turn on Skip pages robots.txt disallows (respectRobotsTxt: true) to follow it: a page that the website's robots.txt disallows for TidyToolsContacts (or for a TidyTools group, else for *) is then not read and is listed in robotsDisallowedPages, and a website whose home page is disallowed returns errorType: "robots_disallowed" and is not charged.
  • Time limit per website (default 120 seconds): after it, no further pages of that website are started and the pages read so far are returned (the skipped ones are listed in timedOutPages). If even the home page could not be read in time, the website fails with errorType: "timeout" (not charged).
  • Proxy: optional Apify Proxy for requests sent from Apify's network (billed to your Apify account).

Limitations

  • Only public pages; nothing behind a login: a website that is a sign-in page (or redirects to one, or answers HTTP 401) returns errorType: "login_required", is not charged and is never retried with credentials. Sites with strong bot protection can stay unreachable (not charged); a residential proxy may help.
  • Values are what the website shows. Phone numbers written without a country code are read with the site's likely country (address, international numbers on the site, domain ending, language), which can be wrong for multi-country sites.
  • Email addresses shown only as images, or only inside a contact form, cannot be found. This is the main reason a website has no email in the output: in a 50-site sample (October 2026, 7 countries), 11 of the 19 websites without an email had only a contact form, and none of 7 small businesses in Osaka published an email (Japanese sites use inquiry forms).
  • Store directories can list many numbers (e.g. one per branch); all of them are returned, each with its page.
  • The Actor reads a handful of pages per site, not the whole site.

Responsible use

  • The Actor reads only information that a company publishes on its own public website (business emails, phone numbers, social profiles, address). It does not guess, generate or verify personal email addresses, and it does not log in anywhere.
  • Every request identifies itself as TidyTools (the one WARP retry of a refused home page uses a browser User-Agent that still ends with our TidyTools token). robots.txt is not used to refuse a website you asked for, unless you turn on Skip pages robots.txt disallows (respectRobotsTxt). Pages behind a login are reported as login_required and never logged into, bot checks and CAPTCHAs are never solved, and when a site answers HTTP 429 with Retry-After we wait (up to 30 seconds) or leave the page for a later run.
  • You are responsible for how you use the results: follow the data-protection and anti-spam laws that apply to you and to the people you contact (for example GDPR in the EU/UK, CAN-SPAM in the US, CASL in Canada), and respect each website's terms.
  • Business contact details can still be personal data (e.g. a named employee's email). Keep only what you need and honour opt-out requests.
  • Want company-level data only? Turn on Role mailboxes only (no personal emails) (roleEmailsOnly): only shared inboxes such as info@, sales@ or support@ are returned; addresses that look like a named person are dropped before output.

FAQ

Five quick answers for common run outcomes

Why company_not_found or invalid_input? A company name without a verified website is free; add its city, address or phone, or paste its website instead. For invalid_input, check that each line is a domain or URL; a pasted table needs a header row.

Why network, not_found or timeout? Check the domain in a browser and correct typos. not_found means the homepage answered 404/410, so use the current site address. A temporary connection failure or timeout may be in the run's RETRY record; DNS and certificate failures are not retried. These failed rows are free.

Why blocked, robots_disallowed or login_required? The site refused access, your respectRobotsTxt setting skipped a disallowed page, or the page requires sign-in. These rows are free. The Actor does not log in or solve bot checks; use a public business website. respectRobotsTxt is off by default and can be turned on.

Why PARTIAL_RESULTS or failed? Other sites in the run may have succeeded. Check SUMMARY.failedByErrorType, then the row's error and RETRY.input for temporary failures. A failed row is free; rerunning can still fail if the site itself is unavailable.

When am I not charged? With the default requiredContact:"any", a site is charged only if an email, phone or social profile is found. With requiredContact:"email", only sites with an email are charged. No contact, unreachable/blocked sites, invalid lines, unverified company names and duplicates are free; each row shows charged.

More details

Why do some websites have no email in the output? Most often the site publishes no address you can read: only a contact form, an address shown as an image, or a country where inquiry forms are the norm. In a 50-site sample (October 2026), 11 of the 19 websites without an email had only a contact form. A row with a phone or social profile can still be charged under the default requiredContact:"any"; choose requiredContact:"email" to pay only when an email was found. Rows still carry any phones, social profiles and contact-page link that were found. Run the site again with more Pages per website if its contact page is deeper.

Why was a website unreachable (errorType: "network")? The website could not be reached: the domain does not exist in DNS (a typo or an expired domain), the server does not answer or drops the connection, or its HTTPS certificate is invalid (certificate checks are never skipped). The row is free, and the run still finishes with the other results (SUMMARY status PARTIAL_RESULTS). What to do: open the domain in a browser and fix typos. Domains that do not exist in DNS are not retried; the other network rows are listed in the RETRY record, and a run with its input later often works.

Why does my large run finish as partial, and how do I re-run only the failed sites? On a list of hundreds or thousands of websites, a few nearly always cannot be read: unreachable (network), refused (blocked), behind a sign-in page (login_required), disallowed by robots.txt when respectRobotsTxt is on, or a company name without a verified website (company_not_found). The run then ends as PARTIAL_RESULTS; the other rows are complete, and failed rows are free. Temporary failures, including websites that ran out of their time limit (timeout), are already tried once more near the end of the run, with half as many websites in parallel and only while the run has time left (not DNS or certificate errors, and not 403); a website is still charged at most once. SUMMARY counts the failures by type (failedByErrorType). A re-run may still fail for the same sites.

How to re-run:

  1. Open the run's key-value store and the RETRY record (byErrorType shows what failed).
  2. Copy its input object: RETRY.input.rows holds only the failed sites, with your row IDs.
  3. Start a new run with that JSON as input. For company names, add a city or country first.

Why does a row say errorType: "not_found"? The server answered, but the website's home page returned "not found" (HTTP 404 or 410), often a site that moved or a business that closed. The row is free and is not put in RETRY (the same address would fail again). What to do: open it in a browser; if the business now uses another domain, run that domain instead.

Why does a row say errorType: "failed"? The website could not be read for a reason that fits none of the other types; the row's error field has the details. The row is free and is listed in the RETRY record. What to do: run the RETRY record's input (it doubles the time limit per website, up to 900 seconds); if the same website fails again, check it in a browser or leave it out.

Why are some websites marked as refused? Some sites answer automated visitors with an error (403) or a bot check. The Actor identifies itself as TidyTools and never solves CAPTCHAs or bot checks; a plain 403 refusal gets one retry through our WARP network with a browser User-Agent (still ending with our TidyTools token), and a website that still refuses, or shows a bot check, is reported as refused (errorType: blocked) instead of being forced open (with respectRobotsTxt on, a home page robots.txt disallows is robots_disallowed; a sign-in page is login_required). These rows are free, and the run summary at the end of the run counts them (refused, blocked by robots.txt, behind a sign-in page, unreachable). Websites behind bot protection may work if you set the proxyConfiguration option (your own proxy choice and cost); the RETRY record lists the websites worth another try.

How does it find a company's website from its name? It checks, in order, our places database (Overture Maps), Wikidata's official-website property (P856, strict name match) and the likely domains, and uses a website only after verifying it on the site itself (name, and the row's city, street or phone when you give one). Parked or for-sale pages and guessed domains that redirect to an unrelated site are not used. A name without a verified website is a free company_not_found row with a hint on what to add.

Why do some rows have contacts from a places listing? When the website was found through its places listing (Overture Maps) and the website itself shows no email or phone, the listing's email or phone is added with source: "overture-places". These listing contacts are not charged: the charge depends only on what the website itself shows. SUMMARY withListingEmail counts these websites. Price unchanged: $2 per 1,000 websites where an email, phone or social profile was found; nothing found is free.

Does the Actor collect usage data? The Actor sends one anonymous usage counter per run (input type, item count range, outcome) to improve the tool; no URLs, content or account data.

Which pages does it read on each website? The home page plus likely contact pages: contact, imprint/Impressum, locations, about, team, support and legal pages, up to 8 pages by default.

Do I pay for websites where nothing is found? No. You pay only when at least one email, phone number or social profile is found. Unreachable websites, refused (blocked) websites, invalid lines, company names without a verified website (company_not_found) and duplicates are free too. With requiredContact: "email", a website with no email is free as well.

Can I give company names instead of websites? Yes. Put one business per line in Websites or company names, with the city or address after the name (Joe's Pizza, 7 Carmine St, New York, NY), or paste spreadsheet columns, tab-separated, ideally with a header row such as Name,City,Phone. Keep Find websites for company names on (default). The Actor looks up the official website, checks it belongs to that business, then reads its contacts.

Why did my company return company_not_found? No website could be verified for that name, so the row is free. Names alone are ambiguous: add the city, address or phone in the same line, or give the website directly. The row's hint says what to add. If you paste a table, include a header row so each column is understood. A find rate is not promised; see Company names instead of websites for our test.

The hint starts withWhat it meansWhat to do
the business seems to have no website of its ownThe places data has this business, but no websiteIf it has one, add a website column
the candidate website did not verify as this businessThe website we found does not match the name or locationCheck the name and location, or add its website address
the candidate website could not be readThe website is blocked by robots.txt, returns an error or is not publicCheck that it is publicly accessible, or add its website address
the places listing has no usable website addressThe places listing has no valid website addressAdd its website address
the city "<city>" was not recognisedThe city was not understoodWrite it as "City, CC" (country code), add a country column, or give the website
the places lookup was unavailableThe places lookup failed this timeRetry later, or add the website address
Tab-separated rows need a header rowPasted tab-separated rows have no header rowAdd a header row such as name
check the spelling of the nameNothing more specific appliesCheck the spelling, or add the website

Each row also has nameLookup.tried: the sources and domains that were tried, and what happened (for example "does not resolve").

How do I know the website found for a company name is the right one? Check websiteFromName.websiteVerifiedBy: places-site (the places listing's website names itself as the business), places-address (its home page gives no name, but the listing is at the row's city, street, postcode or phone), places-page (the page text names the business), site-name-location (a likely domain whose site name matches and whose page shows the row's city, street, postcode or phone), site-phone (the page shows the row's phone) or site-name (site name only, when the row gives no location). websiteConfidence is high when a second signal backs the name (the location or phone on the page, or a listing at the row's street, postcode or phone), otherwise medium. A domain that only contains the name is not accepted: the row is a free company_not_found.

What if the same company or website appears several times? The website is read and charged once. Later rows for the same website show charged: false and duplicateOf; with datasets the note says "result copied (charged once)".

Why do some websites have no email? Mostly because the business does not publish one. In our October 2026 test on 100 small-business websites from map listings (10 countries: plumbers, dentists, cafes, lawyers, shops...), 71 gave an email. Of the other 29: 11 offer only a contact form, 7 show no email on any page we could read (one-page sites, chain store pages, shops with phone and social links only); 11 could not be read at all: 3 have an invalid or expired HTTPS certificate (certificate checks are never skipped), 4 refuse automated visitors or answer with a server error, 2 home pages return "not found", 1 server does not answer, and 1 forbids crawling in robots.txt (respected at the time; since October 13, 2026 such a site is read unless respectRobotsTxt is on). Websites that cannot be read and websites with nothing found are not charged. A run with many websites therefore nearly always has a few "not reachable" rows: that is the websites, not the run. Japanese small businesses, in an earlier test, almost never publish an email (they use inquiry forms).

Can it add emails to Google Maps results? Yes, that is its main use. Pass the dataset ID of a Google Maps run (e.g. compass/crawler-google-places); the website field is found automatically and each row keeps the place's name, address, phone, Maps link and placeId. Source rows without a usable website are free and marked websiteStatus: "not_listed_in_source"; this does not prove the business has no official website. It does not scrape Google Maps itself.

Are the emails verified? Each email gets free checks: syntax, MX records (the domain receives mail), role or personal, free mail, and one bestEmail per website that skips domains without mail servers. No mail server is contacted (no SMTP check), so a mailbox can still bounce.

Can it find emails that are hidden from bots? Often, yes: obfuscated addresses such as "info [at] firm [dot] de" or "info@firm.com.tw", Cloudflare-protected emails and the common email-encoder scripts are decoded. Addresses shown only as images or only inside a contact form cannot be found.

What are the limits? Up to 50,000 websites per run and 1 to 20 pages per website (8 by default). Each website has a time limit (120 seconds by default, up to 900). Set a maximum charge per run to cap the cost: when the next website could exceed it, the run stops and lists the websites not processed.

How fast is it? Parallel websites is a global limit of 10 by default (up to 20). Websites under the same registrable domain (such as a.shop.com and b.shop.com) are read one at a time; websites on other domains can run in parallel. In our Google Maps test, 30 places took 12 seconds before this scheduling change. The one-site prefill limits reading to the homepage; our October 19 local extraction check took 4.7 seconds, but Apify run time can differ.

Is it legal to extract emails from websites? The Actor reads only public pages that a company publishes on its own website, never logs in, never solves CAPTCHAs and identifies itself as TidyTools on every request. robots.txt does not stop it from reading a website you asked for unless you turn on respectRobotsTxt (then disallowed pages are not read and a website whose home page is disallowed is skipped for free). Whether and how you may contact the people you find depends on the laws that apply to you (GDPR, CAN-SPAM, CASL...); see Responsible use. This is not legal advice.

Does it work with the API, webhooks, Make, n8n or Zapier? Yes. Start runs from the Apify API, a schedule, a webhook or the Apify apps for Make, n8n and Zapier, chain it after a Google Maps run with the Integrations tab, or use it from AI agents through MCP.

Support

Open an issue in the Issues tab with the website. Issues are checked regularly.

Found this useful? A short review on the Store helps other users find it.