Extract Website Contacts for B2B Lead Generation (2026)
Turn a list of company domains into emails, phones, socials, and postal addresses with Thirdwatch's Website Contact Scraper. Python + CRM recipes inside.

Thirdwatch's Website Contact Scraper turns a list of company domains into structured contact data — emails, phone numbers, social profile links, and a postal address — read from each company's own public website. It crawls the homepage plus contact, about, team, and imprint pages, deobfuscates
(at)/(dot)emails, and returns one clean row per domain. Built for B2B lead generation, sales prospecting, and CRM enrichment teams who start from a domain list and need contact points without manual page-clicking.
Why scrape company websites for lead generation
A company's own website is the most authoritative public source of its contact details — and the one your prospects keep current. Before any outreach, a sales rep ends up on the same pages anyway: homepage footer, /contact, /about, /team, the imprint or legal page. The job-to-be-done is mechanical: visit those pages, copy the support and press emails, grab the phone number, note the LinkedIn and Twitter links, and read off the head-office address. Done by hand it scales to maybe 20-30 domains an analyst-hour, and the copy-paste step is where transcription errors creep into your CRM.
That manual cost is why so much pipeline data is stale. According to HubSpot's guidance on CRM data hygiene, B2B contact databases degrade by roughly 22-30% a year as people change roles and companies rebrand, so records need continual refreshing. Re-deriving contact points straight from the live website is the cheapest way to keep a list fresh. Feed the actor a column of domains — from a trade-show export, a Google Maps sweep, or an existing CRM segment — and it returns the same five things a rep would have copied by hand: emails, phones, socials, address, and a pagesCrawled count, as clean JSON ready for pandas, HubSpot, or Salesforce.
How does this compare to the alternatives?
Three common ways to get contact data off company websites at scale:
| Approach | Cost | Reliability | Setup time | Maintenance |
|---|---|---|---|---|
| DIY Python crawler (requests + regex) | Free + your engineering time | Brittle on anti-bot, breaks on obfuscation | Days to build and tune | You maintain crawl + parse logic |
| Generic email-finder API | Subscription, per-lookup credits | Email-only, often guessed | Hours | Vendor-controlled, credit caps |
| Thirdwatch Website Contact Scraper | Transparent pay-per-result | Production-tested, anti-bot fallback built in | 5 minutes | Thirdwatch tracks site changes |
A DIY crawler looks easy until you hit obfuscated emails, tel: parsing, asset false-positives like logo@2x.png, and the first site that 403s your script. Email-only finder APIs guess firstname.lastname@ patterns and ignore phones, socials, and addresses entirely. The Website Contact Scraper actor page returns all four contact types from what the site actually publishes, with the obfuscation handling and anti-bot fallback already solved.
How to extract website contacts for lead generation in 4 steps
Step 1: How do I authenticate against Apify?
Sign in at apify.com (free tier, no credit card), open Settings → Integrations, and copy your personal API token. Every example below assumes the token is in APIFY_TOKEN:
export APIFY_TOKEN="apify_api_xxxxxxxxxxxxxxxx"Step 2: How do I enrich a single domain?
Pass one or more domains in the domains array. Bare domains, full URLs, and www. prefixes all work — each entry produces one result row.
import os, requests, pandas as pd
ACTOR = "thirdwatch~website-contact-scraper"
TOKEN = os.environ["APIFY_TOKEN"]
resp = requests.post(
f"https://api.apify.com/v2/acts/{ACTOR}/run-sync-get-dataset-items",
params={"token": TOKEN},
json={
"domains": ["basecamp.com"],
"maxPagesPerDomain": 10,
},
timeout=300,
)
row = resp.json()[0]
print(row["emails"], row["phones"], row["socials"], row["address"])A single-domain enrichment finishes in well under a minute. maxPagesPerDomain controls depth: leave it at the default 10, drop it to 3 for a fast homepage-plus-contact-page pass, or raise it toward 30 for thorough coverage of sprawling sites.
Step 3: How do I build a prospecting list from a domain list?
Pass the whole list in one run. The actor crawls each domain independently and returns one row per domain in the dataset.
domains = (
pd.read_csv("tradeshow_exhibitors.csv")["website"]
.dropna()
.str.strip()
.tolist()
)
resp = requests.post(
f"https://api.apify.com/v2/acts/{ACTOR}/run-sync-get-dataset-items",
params={"token": TOKEN},
json={"domains": domains, "maxPagesPerDomain": 8},
timeout=3600,
)
df = pd.DataFrame(resp.json())
print(f"{len(df)} domains, "
f"{df.emails.map(bool).sum()} with email, "
f"{df.phones.map(bool).sum()} with phone")For very large lists, split into runs of a few hundred domains and append the datasets — that keeps each run inside the sync timeout and makes failures easy to retry per batch.
Step 4: How do I push enriched contacts to HubSpot?
Filter to rows that have an actionable contact point, then map each to your CRM's Company schema and POST. The socials field is an object keyed by platform, so pull individual networks by key.
import requests as r
HUBSPOT_TOKEN = os.environ["HUBSPOT_TOKEN"]
QUALIFIED = df[df.emails.map(bool) | df.phones.map(bool)].copy()
for _, row in QUALIFIED.iterrows():
socials = row["socials"] or {}
r.post(
"https://api.hubspot.com/crm/v3/objects/companies",
headers={"Authorization": f"Bearer {HUBSPOT_TOKEN}"},
json={"properties": {
"domain": row["domain"],
"email": (row["emails"] or [""])[0],
"phone": (row["phones"] or [""])[0],
"address": row["address"] or "",
"linkedin_company_page": socials.get("linkedin", ""),
"twitterhandle": socials.get("twitter", ""),
"lifecyclestage": "lead",
"source": "Website Contact Scraper",
}},
timeout=10,
)
print(f"{len(QUALIFIED)} enriched companies pushed to HubSpot")Schedule the run on Apify's scheduler and forward each completed dataset to your CRM with a webhook for a self-maintaining enrichment loop.
Sample output
Each dataset row is one domain's contact profile. Fields mirror the actor's dataset exactly — domain, emails, phones, socials, address, pagesCrawled, and sourceUrls. Two redacted records:
[
{
"domain": "basecamp.com",
"emails": ["support@basecamp.com", "press@basecamp.com"],
"phones": ["+1-312-261-9899"],
"socials": {
"twitter": "https://twitter.com/basecamp",
"youtube": "https://youtube.com/basecamp",
"github": "https://github.com/basecamp"
},
"address": "30 N Racine Ave, Chicago, IL, 60607, US",
"pagesCrawled": 6,
"sourceUrls": {
"address": "https://basecamp.com/about"
}
},
{
"domain": "examplestudio.io",
"emails": ["hello@examplestudio.io"],
"phones": [],
"socials": {
"linkedin": "https://www.linkedin.com/company/examplestudio"
},
"address": null,
"pagesCrawled": 4
}
]domain is the normalized key (www stripped) — use it for dedup and CRM upsert. emails and phones are deduplicated arrays, so a company with no published phone returns [] rather than a guess. socials is an object keyed by platform (linkedin, twitter, facebook, instagram, youtube, tiktok, github); read individual networks by key. address is best when the site exposes structured data and may be null otherwise. sourceUrls tells you exactly which page each derived field came from — handy for auditing the second record above, where no address was published at all.
Common pitfalls
Three things to plan for in website-contact pipelines. Coverage is never 100% — some sites publish no email at all and route everything through a contact form, so the emails array can come back empty; treat phones and socials as fallback contact points and don't filter the whole row out on missing email. JavaScript-only contact widgets — emails rendered entirely client-side may not appear in a pure-HTTP crawl, which is the right trade-off for speed and cost on the vast majority of sites that ship contact details in HTML. Format drift — phones arrive in each site's own display format and addresses in their natural form, so normalize phones to a single digit string before handing them to a dialer and parse addresses downstream rather than expecting clean city/state columns.
Thirdwatch handles each of these at the crawl layer: obfuscated (at)/(dot) emails are deobfuscated, asset false-positives like logo@2x.png are filtered out, social share and tracking widgets are dropped so only real profile links survive, and a 403 or anti-bot challenge triggers an automatic retry with built-in anti-bot handling. You also stay on the right side of the rules — the actor only reads contact details a company publishes on its own public website, never private or gated data, so always respect each site's terms and applicable anti-spam and data-protection law, such as the FTC's CAN-SPAM rules for outreach, when using what you extract.
Related use cases
- Scrape Google Maps businesses for lead generation — get the domain list this actor enriches
- Scrape JustDial for local business leads in India
- Enrich your CRM with LinkedIn profile data
- Build account-based marketing from LinkedIn companies
- The complete guide to scraping business data
- All Thirdwatch use-case guides
Frequently asked questions
What contact fields does the actor return per domain?
Five core fields: emails (deduplicated list), phones (deduplicated list), socials (object keyed by platform), address (postal address string), and pagesCrawled. It also returns sourceUrls, a map showing which page each derived field came from. Every entry is read from the company's own public website HTML.
Does it only extract publicly published contact details?
Yes. The actor reads contact details that a company publishes on its own public website — homepage, contact, about, team, and imprint pages. It does not bypass logins, scrape gated member areas, or access private or personal data. If a site publishes no contact email, the emails array comes back empty.
Will every domain return an email?
No. Coverage depends on what each site publishes. Many companies route inquiries through a contact form and never expose an address in HTML, and JavaScript-only emails may not appear in a pure-HTTP crawl. Expect strong fill on phones and socials, with emails and postal address more variable by site.
Can it find emails hidden as name (at) company (dot) com?
Yes. The actor deobfuscates the common (at) / (dot) / ' at ' / ' dot ' patterns that sites use to dodge naive scrapers, and normalizes them to real addresses. It also harvests mailto: and tel: links and filters out asset noise like logo@2x.png so the output stays clean.
How many pages does it crawl per domain?
Up to maxPagesPerDomain (default 10, max 30). For each domain it fetches the homepage plus common contact-bearing pages — contact, about, team, imprint, legal — and same-domain contact links discovered on the homepage. Lower the cap for a fast shallow pass, raise it for thorough coverage.
Does it need a proxy or API key?
No. The actor is pure HTTP and needs no API key. When a site returns a 403 or anti-bot challenge it automatically retries with built-in anti-bot handling, so most sites resolve without any configuration on your side. You only supply a list of domains.
Related
100 free credits, no credit card.
About 30 real searches. Add the MCP to Claude or Cursor in two minutes.