If you have a list of company websites and need a contact email for each, the usual route is a headless browser plus regexes. Most contact info sits in static HTML (mailto links, schema.org markup, Cloudflare-obfuscated addresses), so a plain HTTP crawl of the home page, /contact and /about pages is enough for most sites.
I built an Apify Actor that does exactly this, and this post shows a real run. Input:
{
"startUrls": [
{
"url": "https://www.shinola.com"
}
],
"onlyWithContacts": false,
"maxPagesPerSite": 2,
"includeNamedEmails": true,
"sameDomainOnly": true,
"maxDepth": 1,
"monitorMode": false,
"maxSites": 1,
"maxConcurrency": 8,
"pageTimeoutSecs": 15,
"siteTimeoutSecs": 60,
"maxUrlsPerDomain": 2,
"monitorStateName": "website-contact-extractor-monitor",
"defaultCountry": "US"
}
One row came back per site. Here is the first row (trimmed to the first 2 items of each list):
{
"inputUrl": "https://www.shinola.com",
"domain": "shinola.com",
"url": "https://www.shinola.com/",
"status": "ok",
"error": null,
"companyName": "Shinola",
"companyNameSource": "schema.org",
"emails": [
{
"email": "customerservice@shinola.com",
"type": "role",
"sameDomain": true,
"foundOn": "https://www.shinola.com/",
"sourceUrl": "https://www.shinola.com/",
"pageTitle": "Shinola | The American Watchmaker",
"pageType": "home",
"method": "cloudflare-decoded"
}
],
"emailCount": 1,
"primaryEmail": "customerservice@shinola.com",
"phones": [],
"phoneCount": 0,
"primaryPhone": null,
"socials": {
"linkedin": "https://www.linkedin.com/company/shinola",
"facebook": "https://www.facebook.com/shinola",
"instagram": "https://www.instagram.com/shinola",
"x": "https://x.com/shinola",
"youtube": "https://www.youtube.com/c/shinola",
"tiktok": "https://www.tiktok.com/@shinola",
"pinterest": "https://www.pinterest.com/shinola",
"github": null,
"whatsapp": null,
"telegram": null,
"discord": null
},
"pageTitle": "Shinola | The American Watchmaker",
"metaDescription": "Founded in 2011 with the belief that products should be made well and made to last, Shinola began with a simple idea: to bring watchmaking back to the United States.",
"language": "en",
"contactPageUrl": null,
"address": null,
"socialSources": {
"facebook": "https://www.shinola.com/",
"instagram": "https://www.shinola.com/",
"linkedin": "https://www.shinola.com/",
"pinterest": "https://www.shinola.com/",
"tiktok": "https://www.shinola.com/",
"x": "https://www.shinola.com/",
"youtube": "https://www.shinola.com/"
},
"socialCount": 7,
"maxDepthReached": 1,
"pagesCrawled": 2,
"pagesCrawledUrls": [
"https://www.shinola.com/",
"https://www.shinola.com/pages/about"
],
"skippedByRobots": [],
"responded": true,
"scrapedAt": "2026-11-06T11:31:49.193Z",
"emailsList": "customerservice@shinola.com",
"roleEmails": "customerservice@shinola.com",
"namedEmails": null,
"phonesList": null,
"linkedin": "https://www.linkedin.com/company/shinola",
"facebook": "https://www.facebook.com/shinola",
"instagram": "https://www.instagram.com/shinola",
"x": "https://x.com/shinola",
"youtube": "https://www.youtube.com/c/shinola",
"tiktok": "https://www.tiktok.com/@shinola",
"pinterest": "https://www.pinterest.com/shinola",
"github": null,
"whatsapp": null,
"telegram": null,
"discord": null,
"addressFull": null
}
What to notice: each email carries its type (role vs named), the page it was found on, and the method (here a Cloudflare-decoded address). Sites with no public email come back with status and an empty list rather than an error, so you can count your hit rate.
Try it with the prefilled example (press Start, no setup): https://apify.com/transparent_meteorite/website-contact-extractor?utm_source=devto&utm_medium=article&utm_campaign=wce_howto
It is pay per result. If it works for you or misses, a note on the Reviews tab or an Issue with your input is welcome. I read both.
Top comments (0)