Website Contact Scraper: Emails, Phone Numbers & Social Links avatar

Website Contact Scraper: Emails, Phone Numbers & Social Links

Pricing

from $20.00 / 1,000 results

Go to Apify Store
Website Contact Scraper: Emails, Phone Numbers & Social Links

Website Contact Scraper: Emails, Phone Numbers & Social Links

Crawl any website and extract emails, phone numbers, and social media links. Auto-discovers contact, about, and team pages. One clean record per domain. JS-rendered pages supported.

Pricing

from $20.00 / 1,000 results

Rating

0.0

(0)

Developer

Techforce Global

Techforce Global

Maintained by Community

Actor stats

1

Bookmarked

17

Total users

3

Monthly active users

5 days ago

Last modified

Share

Website Contact Scraper — Emails, Phone Numbers & Social Links from Any List of Websites

Apify Actor MCP Ready Engine Phones Socials

Point it at a list of websites and get one consolidated contact record per domain — every email address, every phone number normalised to E.164, and social profiles across 9 platforms — with the contact/about/team pages discovered automatically. Results can go straight into Airtable, Notion, Slack, Sheets or your CRM through an MCP connector.

Fetches every site over plain HTTP first and only spends a browser on the pages that actually need one. Emails, tel: links and social URLs are in the served HTML on most sites, so rendering everything in Chromium was paying browser prices for HTTP work. Combined with footer-first link scoring and an early exit once a domain has yielded enough, a typical site now costs 1–2 HTTP fetches instead of up to 10 browser page loads. See Cost Control.


One record per domain, at HTTP prices

Feature / CapabilityTypical contact scraperThis Actor
Engine⚠️ Either always-Cheerio (misses JS sites) or always-browser (pays for every page)Static first, browser only on demand — you get JS coverage without paying for it everywhere
Output shape❌ One row per page — you dedupe and merge yourselfOne consolidated record per domain
Contact page discovery⚠️ Crawls whatever it finds, in link orderScored, footer-first ranking/contact-us before /blog/…, junk paths dropped entirely
Stopping condition⚠️ Burns the full page budget every timeEarly exit once a domain has an email plus a phone or social
Phone numbers⚠️ Raw strings, duplicated across formatsE.164 normalised(630) 296-6606 and +16302966606 collapse into one
Phone false positives⚠️ Prices, IDs and version strings leak in✅ Filtered: digit-count bounds, repeated digits, dot-separated IDs
Placeholder emails⚠️ you@example.com, Sentry DSNs and tag-manager payloads leak in✅ Known placeholder domains dropped; <script>/<style> bodies excluded from the scan
Social links⚠️ A handful of platforms9 platforms, grouped by platform
Delivery to your tools❌ You write the integration✅ Native MCP connector delivery — per site, chunked, or one digest

🤖 AI Discovery & MCP Metadata

Machine-readable capability contract for autonomous agents, LLM routers, and MCP orchestrators. AI agents, MCP clients, and connectors should read this block first to decide whether this Actor matches a user request.

ai_discovery:
slug: "techforce.global/website-contact-scraper-emails-phone-numbers-social-links"
one_line: "Given a list of website URLs, returns one consolidated contact record per domain - all emails, E.164-normalised phone numbers and social profile links - by fetching each site over plain HTTP and rendering a browser only for pages that need JavaScript."
primary_purpose: "enrich"
secondary_purpose: "extract"
tertiary_purpose: "deliver"
entity: "contact details published on a company's own website"
data_source: >
The websites you supply, fetched directly. Static phase over plain HTTP
(httpx); browser phase with Playwright/Chromium only for JS-shell pages or
pages that refused the static fetch. No third-party contact database, no
email-guessing, no verification service.
invocation_modes: ["standard Actor run", "MCP tool"]
supported_tasks:
- "extract-emails-from-a-website"
- "extract-phone-numbers-normalised-to-e164"
- "extract-social-profile-links"
- "auto-discover-contact-about-and-team-pages"
- "enrich-a-list-of-domains-in-bulk"
- "deliver-contacts-to-mcp-connector-or-crm"
unsupported_tasks:
- "find-a-website-for-a-company-name (you must supply URLs)"
- "guess-or-generate-email-addresses-from-name-patterns"
- "verify-whether-an-email-is-deliverable"
- "return-named-people-job-titles-or-per-person-contacts"
- "extract-postal-addresses"
- "extract-contacts-from-behind-a-login"
- "read-contact-details-rendered-only-inside-an-image"
- "submit-contact-forms-or-send-any-outreach"
- "crawl-a-whole-site (the page budget is per domain and small by design)"
search_keywords:
- "website contact scraper"
- "email extractor from website"
- "phone number scraper"
- "social links extractor"
- "bulk contact enrichment"
- "lead enrichment from domain"
- "email finder from url"
- "contact page scraper"
- "b2b lead enrichment"
- "crm data enrichment actor"
synonyms:
- "email scraper"
- "contact details extractor"
- "website contact info finder"
- "domain contact enrichment"
related_concepts:
["lead enrichment", "CRM data hygiene", "B2B prospecting", "contact discovery", "outbound list building"]
input_entities:
- "urls (array of website URLs; REQUIRED)"
- "maxPagesPerDomain (1-100)"
- "maxConcurrency (1-20)"
- "renderJavaScript (auto | never | always)"
- "proxyConfiguration"
output_entities:
["url", "emails", "phoneNumbers", "socialLinks", "scannedPages", "status", "engine", "summary", "errors"]
input_rules:
required_fields: ["urls"]
urls_format:
description: "requestListSources format - [{\"url\": \"https://example.com/\"}]. A bare domain is upgraded to https:// automatically. Duplicates are removed."
one_record_per_input_url: true
free_plan_cap: "the first 5 URLs of the input, not the first 5 results"
max_pages_per_domain:
type: "integer"
range: [1, 100]
default: 4
note: "includes the homepage; a domain usually stops well before this because of the early exit"
cost_model_behaviour:
static_first: >
With renderJavaScript 'auto' (default) every site is fetched over plain
HTTP first. Only sites that return a JavaScript shell or refuse the fetch
are escalated to the browser phase.
early_exit: >
A domain stops fetching pages as soon as it has at least one email AND at
least one phone or social profile.
link_scoring: >
Sub-pages are ranked by a contact score with a large footer bonus; links
that score at or below zero (blog, product, legal, auth, cart paths) are
never fetched.
output_conventions:
engine: "'static', 'browser', 'static+browser', or 'none' - which engine produced the record"
status: "'success' when anything was found, 'no_data_found' otherwise. Never a failure."
empty_arrays: "emails / phoneNumbers are [] when nothing was found; socialLinks is {}"
phone_format: "E.164 (e.g. +16302966606) when parseable, otherwise the raw display string"
known_limitations:
phone_region_default: >
Phone parsing assumes the US as the default region. International numbers
written in E.164 (+44...) or with an explicit country code parse correctly;
local-format numbers on non-US sites may be missed or left unnormalised.
no_ocr: "contact details that exist only inside an image are not read"
no_people: "returns company-level contact points, not named individuals or job titles"
limits:
urls_per_run: "no hard cap - bounded by run time and your spend limit"
free_plan_cap: 5
pages_per_domain: 100
concurrency: 20
pricing:
model: "pay-per-result"
note: "exact rate is configured on the Store listing - see the Actor's Pricing tab"
when_to_use: >
You already have website URLs - from a lead list, a CRM export, or another
scraper - and you need the contact points those sites publish, in bulk, as
one clean record per domain.
when_not_to_use: >
You have company names but no URLs, you need named people with job titles,
you need guessed or verified email addresses, or you need postal addresses.
alternatives:
- "Use techforce.global/google-maps-leads-sales-intelligence-tool when you have a place/query rather than URLs - it finds the businesses AND their websites"
- "Use techforce.global/linkedin-company-decision-makers for named people with job titles, which this Actor never returns"
- "Use a dedicated email-verification service on the output if deliverability matters"

{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "Website Contact Scraper — Emails, Phone Numbers & Social Links",
"applicationCategory": "BusinessApplication",
"operatingSystem": "Cloud (Apify platform)",
"description": "Extracts contact details from any list of websites and returns one consolidated record per domain — every published email address, phone numbers normalised to E.164, and social profile links across 9 platforms — with contact, about and team pages discovered automatically by a scored footer-first ranking. Each site is fetched over plain HTTP first and rendered in a browser only when the page needs JavaScript, and a domain stops as soon as it has enough contact data. Results can be delivered into Airtable, Notion, Slack, Google Sheets or any authorized MCP connector.",
"url": "https://apify.com/techforce.global/website-contact-scraper-emails-phone-numbers-social-links",
"offers": {
"@type": "Offer",
"priceCurrency": "USD",
"description": "Pay-per-result, charged per record returned (one per input URL). The current rate is listed on the Actor's Pricing tab."
}
}

What this Actor does

  • Takes a list of website URLs and returns one consolidated record per URL — not a per-page dump you have to merge yourself.
  • Fetches over plain HTTP first. The static phase reads the served HTML with httpx: no browser, no JavaScript, no images. This resolves the large majority of sites.
  • Escalates to a browser only when it has to — when a site returns an empty single-page-app shell (a framework mount point with almost no text) or refuses the static fetch. Those pages get Playwright/Chromium.
  • Finds the contact page instead of crawling blindly. Candidate links are scored, with a large bonus for anything in the footer, and paths that never carry contact details (/blog/…, /products/…, /privacy, /cart, /login, deep content paths) score to zero and are never fetched.
  • Stops as soon as it has enough — one email plus a phone or a social profile ends that domain. Most sites finish in 1–2 fetches.
  • Normalises phone numbers to E.164, so (630) 296-6606, +1 630 296 6606 and +16302966606 collapse into a single entry — and filters the usual false positives (prices, order IDs, version strings, repeated-digit placeholders).
  • Drops placeholder emails (example.com, yourdomain.com, sentry.io, wixpress.com, …) and excludes <script>/<style> bodies from the scan, so inline analytics config and tag-manager payloads don't become fake contacts.
  • Groups social links by platform across 9 platforms.
  • Blocks images, media, fonts, stylesheets and 15 analytics hosts on the browser path, and bounds the networkidle wait — an unbounded wait meant every ad-heavy page burned the full request timeout.
  • Drives Playwright directly, with no crawler framework in the import path, so the container stays lean and cold starts stay short. The browser phase runs at most 5 pages concurrently regardless of maxConcurrency, because browser pages cost far more than HTTP requests.
  • Exits promptly when aborted, so a cancelled run stops billing compute and proxy traffic.
  • Optionally pushes records to an authorized MCP connector (Airtable, Notion, Slack, Sheets, HubSpot, or any MCP-compatible connector) per site, chunked, or as one digest. The dataset is always saved regardless of delivery settings.

ℹ️ Company-level contacts, not people. The output is what a company publishes on its own site — info@, sales@, a switchboard number, social profiles. It does not return named individuals or job titles. For named decision makers, pair with LinkedIn Decision Makers.

When to use it — and when not to

ScenarioFit
Enriching a list of domains with contact details, in bulk✅ Strong (the core use case)
Adding emails and phones to a CRM export that only has websites✅ Strong (deliveryMode: "perSite")
Finding the right inbox to route an enquiry to✅ Strong
Collecting a company's social profiles across 9 platforms✅ Strong
Deduplicating phone numbers written in mixed formats✅ Strong — E.164 normalisation
Cheap first-pass enrichment over thousands of domains✅ Strong — renderJavaScript: "never"
Contact details on a JavaScript-only site✅ Handled — auto escalates it to a browser
You have company names, not URLs❌ Supply URLs — or use Google Maps Business Leads to find them first
Named people with job titles❌ Company-level only — use LinkedIn Decision Makers
Guessed emails (firstname.lastname@…)❌ Only addresses actually published on the site
Verifying an email is deliverable❌ Extraction only — pair with a verification service
Postal addresses❌ Not extracted
Contacts behind a login❌ No authentication support
Contact details rendered inside an image❌ No OCR
Submitting contact forms / sending outreach❌ Read-only
Crawling an entire site⚠️ Not the goal — the per-domain page budget is small by design
Local-format phone numbers on non-US sites⚠️ Parsing assumes US as the default region — see Known limitations

⚡ Execution Rules (Avoid Common Agent Failures)

  1. urls is required and uses requestListSources format[{"url": "https://example.com/"}]. A bare domain (example.com) is upgraded to https:// automatically, and duplicates are removed.
  2. You get one record per input URL, always — including sites where nothing was found. Those come back with status: "no_data_found" and empty arrays, not as errors.
  3. status is never a failure signal. It is "success" or "no_data_found". Read summary.emailCount / phoneCount / socialPlatforms for what was actually found.
  4. Free Apify plans process the first 5 URLs of the input — a cap on inputs, not results. That's a successful run.
  5. maxPagesPerDomain defaults to 4, not 10. It includes the homepage, and most domains stop earlier because of the early exit. Raise it only for sites that bury contact details deep in the navigation.
  6. A domain stops early once it has an email plus a phone or social profile. So summary.pagesScanned is usually 1–2 even with a budget of 4 — that's the cost optimisation working, not a truncated crawl.
  7. renderJavaScript defaults to auto. Static first, browser only for JS shells and refused fetches. Use never for the cheapest possible bulk pass (returns nothing for JS-only sites) and always only if auto demonstrably misses data.
  8. Check engine to see what a record cost. static = no browser was used. browser / static+browser = that site needed rendering.
  9. Phone numbers are E.164 when parseable (+16302966606), otherwise the raw display string. Don't assume a leading + on every entry.
  10. Phone parsing assumes the US as the default region. +44… and other explicit country codes are fine; a UK or German number written in local format may be missed or left unnormalised.
  11. socialLinks is an object of arrays, keyed by platform ({"linkedin": ["https://…"]}) — not a flat list. It is {} when nothing was found.
  12. emails can contain several addresses and they are not ranked. There is no "primary email" field; if you need one, take the first or apply your own preference for info@/sales@/contact@.
  13. Nothing here is verified. These are addresses published on the page. Some will be stale, role-based, or monitored by nobody.
  14. No named people, no job titles, no postal addresses. Nine output fields, all company-level.
  15. deliveryMode defaults to perSite, not none. With a connector selected, a 500-URL run makes 500 connector calls. Use chunked or summary for a digest.
  16. errors appears only when something went wrong for that domain, capped at 10 entries. It's diagnostic, not a failure — a site can have errors and still return contacts.
dependencies:
- rule: "urls is required, in requestListSources format"
on_violation: "the run raises 'Input must include at least one URL in the urls array.'"
- rule: "one record per input URL, including empty ones"
note: "status 'no_data_found' with empty arrays is a valid, successful result"
- rule: "free Apify plans process the first 5 URLs of the input"
on_violation: "silently truncated input read as a scraping failure"
- rule: "maxPagesPerDomain is clamped to [1, 100] and defaults to 4"
note: "includes the homepage; the early exit usually ends a domain sooner"
- rule: "a domain stops once it has an email plus a phone or social profile"
on_violation: "pagesScanned of 1-2 misread as a truncated crawl"
- rule: "renderJavaScript 'never' skips the browser entirely"
on_violation: "JS-only sites silently return no_data_found - use 'auto' if coverage matters more than cost"
- rule: "phone parsing assumes the US default region"
on_violation: "local-format non-US numbers missed or left unnormalised"
- rule: "socialLinks is an object of arrays keyed by platform, not a list"
on_violation: "iterating it yields platform names, not URLs"
- rule: "emails are unranked and unverified"
on_violation: "treating the first address as a verified primary contact"
- rule: "delivery requires mcpConnector AND mcpTool"
on_violation: "delivery step is skipped with a warning; the dataset is still written in full"
- rule: "mcpArguments must reference {message} for the rendered template to appear anywhere"
on_violation: "message body is empty; the tool is still called"
- rule: "mcpConnector only resolves when the Actor runs on the Apify platform"
on_violation: "local runs log 'APIFY_MCP_PROXY_URL is not set' and skip delivery; the dataset is unaffected"
authentication:
actor_input: none
platform: "Apify API token required for API / SDK / MCP invocation; paid plan required for residential proxy groups"
target_site: "no credentials of any kind - the Actor only reads publicly served pages"

📥 Input Contract

.actor/input_schema.json is authoritative — if any table on this page disagrees with the schema files, the schema files win.

Parameters

FieldTypeRequiredDefaultAllowed Values / FormatExample
urlsarrayYesrequestListSources[{"url": "https://…"}]see below
maxPagesPerDomaininteger⬜ No41100, homepage included6
maxConcurrencyinteger⬜ No5120; the browser phase is additionally capped at 510
renderJavaScriptenum⬜ Noautoauto · never · always"never"
proxyConfigurationobject⬜ NoApify Proxy offApify proxy editor objectsee below

renderJavaScript — the main cost dial

ValueBehaviourUse when
auto (default)Every site fetched over plain HTTP; only JS shells and refused fetches get a browserAlmost always. Full coverage, browser cost only where it's unavoidable
neverStatic only; JS-only sites return no_data_foundCheapest possible bulk pass over thousands of domains, or a first sweep before a targeted auto re-run
alwaysEvery page rendered in a browser (the pre-optimisation behaviour)Only if auto demonstrably misses data you know is on the page

Proxy

{
"proxyConfiguration": { "useApifyProxy": true, "apifyProxyGroups": ["RESIDENTIAL"] }
}

Off by default, and usually unnecessary — these are public marketing pages fetched a couple of times each. The proxy applies to both phases. Enable RESIDENTIAL (paid plans) only if your targets block datacenter IPs.

Social platforms detected

linkedin (company + personal) · facebook · twitter / x · instagram · youtube · tiktok · pinterest · github · whatsapp (wa.me, api.whatsapp.com)

How contact pages are chosen

Sub-page candidates are scored, not just filtered. Footer links get a large bonus, because that is where contact details actually live.

SignalEffect
Link is inside a <footer>, .footer, #footer or role="contentinfo"+1000
Path or anchor text matches contact-us, contact, get-in-touch, enquiry, support, about, team, imprint, impressum, …+100 down to +84 by specificity
Path contains blog, news, product, pricing, career, privacy, terms, cart, login, search, tag, feed, …−80
Path is more than two segments deep−20 per extra segment
Final score ≤ 0Never fetched

So a site whose only links are blog posts and product pages costs exactly one fetch, not four.

Examples

Bulk enrichment — the recommended default

{
"urls": [
{ "url": "https://www.stripe.com/" },
{ "url": "https://www.notion.so/" },
{ "url": "https://www.figma.com/" }
],
"maxPagesPerDomain": 4,
"renderJavaScript": "auto",
"deliveryMode": "none"
}

Cheapest possible sweep — thousands of domains, no browser at all

{
"urls": [{ "url": "https://example-one.com/" }, { "url": "https://example-two.com/" }],
"maxPagesPerDomain": 2,
"maxConcurrency": 20,
"renderJavaScript": "never",
"deliveryMode": "none"
}

Deep dig — a site that hides contact details several clicks in

{
"urls": [{ "url": "https://www.some-large-enterprise.com/" }],
"maxPagesPerDomain": 12,
"renderJavaScript": "auto",
"deliveryMode": "none"
}

Force rendering — only if auto missed something you can see on the page

{
"urls": [{ "url": "https://app-only-spa.example/" }],
"maxPagesPerDomain": 4,
"renderJavaScript": "always",
"deliveryMode": "none"
}

MCP Delivery Configuration (Optional)

Delivery pushes contact data into your own tools. It never emails or calls anyone.

ParameterTypeDefaultDescription
mcpConnectorstring""Authorized MCP connector on your Apify account (Airtable, Notion, Slack, Sheets, HubSpot, …). Leave empty for dataset-only runs.
deliveryModeenumperSiteperSite (one call per website) · chunked (split across calls) · summary (one call with everything) · none.
mcpToolstring""Tool name on the connector: create_record, create_page, send_message, append_row. Run once with a connector selected — the log lists the connector's available tools.
mcpArgumentsobject{}Arguments passed to the tool. String leaves support {placeholders}.
mcpMessageTemplatestring""Template rendered and exposed to mcpArguments as {message}.

Per-site placeholders: {url}, {emails}, {phones}, {socials}, {primaryEmail}, {primaryPhone}, {socialPlatforms}, {emailCount}, {phoneCount}, {socialCount}, {pagesScanned}, {status}, {engine}, {site} (a formatted block), {message}.

Summary / chunked placeholders: {siteCount}, {emailTotal}, {phoneTotal}, {sites}, {part}, {partCount}, {message}.

One Airtable row per enriched website:

{
"urls": [{ "url": "https://www.stripe.com/" }],
"mcpConnector": "<your-authorized-airtable-connector>",
"deliveryMode": "perSite",
"mcpTool": "create_record",
"mcpArguments": {
"fields": {
"Website": "{url}",
"Primary email": "{primaryEmail}",
"All emails": "{emails}",
"Primary phone": "{primaryPhone}",
"Socials": "{socials}",
"Pages scanned": "{pagesScanned}"
}
}
}

One Slack digest per run:

{
"urls": [{ "url": "https://example-one.com/" }, { "url": "https://example-two.com/" }],
"mcpConnector": "<your-authorized-slack-connector>",
"deliveryMode": "summary",
"mcpTool": "send_message",
"mcpArguments": { "channel": "#lead-enrichment", "text": "{message}" },
"mcpMessageTemplate": "📇 {siteCount} sites enriched — {emailTotal} emails, {phoneTotal} phones:\n\n{sites}"
}

A large list split across Notion sub-pages:

{
"urls": [{ "url": "https://example-one.com/" }],
"mcpConnector": "<your-authorized-notion-connector>",
"deliveryMode": "chunked",
"mcpTool": "notion-create-pages",
"mcpArguments": {
"parent": { "page_id": "<your-page-id>" },
"pages": [
{
"properties": { "title": "Enriched contacts — part {part}/{partCount}" },
"content": "{sites}"
}
]
}
}

One Google Sheets row per website:

{
"urls": [{ "url": "https://example-one.com/" }],
"mcpConnector": "<your-authorized-sheets-connector>",
"deliveryMode": "perSite",
"mcpTool": "append_row",
"mcpArguments": {
"values": ["{url}", "{primaryEmail}", "{primaryPhone}", "{socialPlatforms}", "{status}"]
}
}

📤 Output Contract & Data Structure

Storage: Apify Dataset — one object per input URL, written after both phases finish. Pagination: limit & offset on the dataset items endpoint. Duplicates: input URLs are deduplicated before the run, so one record per distinct URL.

Record fields

FieldTypeEmpty valueMeaning
urlstringnever emptyThe input URL, echoed back — the join key for your own data
emailsarray of string[]Every address found, lowercased and deduplicated. Unranked and unverified
phoneNumbersarray of string[]E.164 (+16302966606) when parseable, otherwise the raw display string
socialLinksobject of arrays{}Keyed by platform: {"linkedin": ["https://…"], "twitter": [...]}
scannedPagesarray of string[] when nothing loadedExactly which pages were fetched, in order — the homepage first
statusstring"success" if anything was found, else "no_data_found". Never a failure code
enginestring"none""static", "browser", "static+browser"what this record cost
summaryobjectemailCount, phoneCount, socialPlatforms, pagesScanned
errorsarray of stringkey absentPresent only when a fetch failed for that domain; capped at 10

Example — resolved without a browser

{
"url": "https://www.acme-widgets.com/",
"emails": ["hello@acme-widgets.com", "sales@acme-widgets.com"],
"phoneNumbers": ["+16302966606"],
"socialLinks": {
"linkedin": ["https://www.linkedin.com/company/acme-widgets"],
"twitter": ["https://x.com/acmewidgets"]
},
"scannedPages": [
"https://www.acme-widgets.com/",
"https://www.acme-widgets.com/contact-us"
],
"status": "success",
"engine": "static",
"summary": { "emailCount": 2, "phoneCount": 1, "socialPlatforms": 2, "pagesScanned": 2 }
}

Example — nothing published

{
"url": "https://www.no-contact-details.example/",
"emails": [],
"phoneNumbers": [],
"socialLinks": {},
"scannedPages": ["https://www.no-contact-details.example/"],
"status": "no_data_found",
"engine": "static",
"summary": { "emailCount": 0, "phoneCount": 0, "socialPlatforms": 0, "pagesScanned": 1 }
}

Example — needed a browser, and one sub-page failed

{
"url": "https://spa-only.example/",
"emails": ["info@spa-only.example"],
"phoneNumbers": [],
"socialLinks": { "linkedin": ["https://www.linkedin.com/company/spa-only"] },
"scannedPages": ["https://spa-only.example/", "https://spa-only.example/contact"],
"status": "success",
"engine": "browser",
"summary": { "emailCount": 1, "phoneCount": 0, "socialPlatforms": 1, "pagesScanned": 2 },
"errors": ["static homepage: timeout"]
}

Flattening for a CRM

socialLinks is nested, so pick per platform rather than iterating blindly:

def flatten(record):
socials = record.get("socialLinks") or {}
emails = record.get("emails") or []
phones = record.get("phoneNumbers") or []
return {
"website": record["url"],
"email": emails[0] if emails else "", # unranked - first, not "best"
"all_emails": "; ".join(emails),
"phone": phones[0] if phones else "",
"linkedin": (socials.get("linkedin") or [""])[0],
"twitter": (socials.get("twitter") or [""])[0],
"found": record["status"] == "success",
}

Prefer role inboxes if you have a preference — the Actor deliberately doesn't guess for you:

PREFERRED = ("contact@", "hello@", "info@", "sales@", "enquiries@", "support@")
def best_email(emails):
for prefix in PREFERRED:
for email in emails:
if email.startswith(prefix):
return email
return emails[0] if emails else ""

What a successful run looks like

{
"status": "SUCCEEDED",
"defaultDatasetId": "<datasetId>",
"stats": { "itemCount": 250 }
}

itemCount equals the number of distinct input URLs processed (capped at 5 on free plans) — not the number of sites where contacts were found. Count status == "success" for that.

The run log closes with the numbers that matter for cost:

Static phase done — 231/250 site(s) resolved without a browser.
Browser phase for 19 site(s)...
Done — 250 site(s), 412 page(s) fetched, 19 site(s) needed a browser.

▶️ Invocation & Integration

Lifecycle: Discover → Understand → Configure → Execute → Retrieve → Validate. Authenticate every call with Authorization: Bearer <APIFY_TOKEN>.

Apify Console

Open the Actor → Input tab → paste your URLs → leave Max pages per domain at 4 and JavaScript rendering on auto(optional) pick an MCP connector, tool and template → Start → export from the Dataset tab (JSON, CSV, Excel, XML). The Contacts only dataset view gives you a CRM-ready table.

Model Context Protocol (MCP)

claude mcp add --transport http apify \
"https://mcp.apify.com?tools=techforce.global/website-contact-scraper-emails-phone-numbers-social-links"

Python (apify-client)

import os
from apify_client import ApifyClient
client = ApifyClient(os.getenv("APIFY_TOKEN"))
ACTOR = "techforce.global/website-contact-scraper-emails-phone-numbers-social-links"
domains = ["https://www.stripe.com/", "https://www.notion.so/", "https://www.figma.com/"]
run = client.actor(ACTOR).call(run_input={
"urls": [{"url": url} for url in domains],
"maxPagesPerDomain": 4,
"renderJavaScript": "auto", # static first, browser only where needed
"deliveryMode": "none",
})
items = client.dataset(run["defaultDatasetId"]).list_items().items
found = [item for item in items if item["status"] == "success"]
print(f"{len(found)}/{len(items)} sites yielded contacts")
# `engine` tells you what each record cost.
no_browser = sum(1 for item in items if item["engine"] == "static")
print(f"{no_browser}/{len(items)} resolved without a browser")
for item in found:
socials = item.get("socialLinks") or {}
print(f'{item["url"]} ({item["summary"]["pagesScanned"]} page(s), {item["engine"]})')
print(f' emails : {", ".join(item["emails"]) or "-"}')
print(f' phones : {", ".join(item["phoneNumbers"]) or "-"}')
print(f' socials: {", ".join(socials) or "-"}')

Two-pass pattern for large lists — cheap sweep, then render only the gaps:

urls = [{"url": url} for url in domains] # e.g. 5,000 domains
# Pass 1: static only. No browser cost at all.
cheap = client.actor(ACTOR).call(run_input={
"urls": urls, "maxPagesPerDomain": 2,
"renderJavaScript": "never", "maxConcurrency": 20, "deliveryMode": "none",
})
rows = client.dataset(cheap["defaultDatasetId"]).list_items().items
# Pass 2: only the misses, with rendering allowed.
misses = [{"url": row["url"]} for row in rows if row["status"] == "no_data_found"]
if misses:
rendered = client.actor(ACTOR).call(run_input={
"urls": misses, "maxPagesPerDomain": 4,
"renderJavaScript": "auto", "deliveryMode": "none",
})
rows += client.dataset(rendered["defaultDatasetId"]).list_items().items

JavaScript / TypeScript (apify-client)

import { ApifyClient } from 'apify-client';
const client = new ApifyClient({ token: process.env.APIFY_TOKEN });
const ACTOR = 'techforce.global/website-contact-scraper-emails-phone-numbers-social-links';
const run = await client.actor(ACTOR).call({
urls: [{ url: 'https://www.stripe.com/' }, { url: 'https://www.notion.so/' }],
maxPagesPerDomain: 4,
renderJavaScript: 'auto',
deliveryMode: 'none',
});
const { items } = await client.dataset(run.defaultDatasetId).listItems();
// socialLinks is an OBJECT of arrays, not a flat list.
const rows = items.map((item) => ({
website: item.url,
email: item.emails?.[0] ?? '',
phone: item.phoneNumbers?.[0] ?? '',
linkedin: item.socialLinks?.linkedin?.[0] ?? '',
found: item.status === 'success',
engine: item.engine,
}));
console.table(rows);

cURL — synchronous run (small lists; 300s limit)

curl -X POST \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer <YOUR_APIFY_TOKEN>' \
-d '{"urls":[{"url":"https://www.stripe.com/"}],"maxPagesPerDomain":4,"renderJavaScript":"auto","deliveryMode":"none"}' \
'https://api.apify.com/v2/acts/techforce.global~website-contact-scraper-emails-phone-numbers-social-links/run-sync-get-dataset-items'
# 1. Start execution with a hard spend limit
curl -X POST -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <YOUR_APIFY_TOKEN>' \
-d '{"urls":[{"url":"https://a.example/"},{"url":"https://b.example/"}],"renderJavaScript":"auto","deliveryMode":"none"}' \
'https://api.apify.com/v2/acts/techforce.global~website-contact-scraper-emails-phone-numbers-social-links/runs?maxTotalChargeUsd=5.00'
# 2. Poll
curl -H 'Authorization: Bearer <YOUR_APIFY_TOKEN>' \
'https://api.apify.com/v2/actor-runs/<runId>'
# 3. Retrieve when SUCCEEDED
curl -H 'Authorization: Bearer <YOUR_APIFY_TOKEN>' \
'https://api.apify.com/v2/datasets/<DATASET_ID>/items?clean=true&format=json'

⚠️ Autonomous Agent Error Handling & Resolution Matrix

Error CodeDetection ConditionUnderlying CauseDeterministic Agent Action
AUTH_INVALIDHTTP 401 from ApifyMissing or malformed APIFY_TOKENAbort & Prompt User: request a valid Apify API token.
RATE_LIMITEDHTTP 429 from ApifyApify API rate / concurrency limitsRetry: exponential backoff (2s, 4s, 8s).
SYNC_TIMEOUTHTTP 408 on the sync endpointBulk runs exceed the 300s synchronous ceilingReconfigure: async POST /runs + polling.
MISSING_URLSRun fails: Input must include at least one URL in the "urls" array.urls empty or absentModify Input: supply [{"url": "…"}].
URLS_WRONG_SHAPENo usable URLs found in the "urls" array.Objects without a url key, or blank stringsModify Input: use requestListSources shape.
NO_URL_TO_START_FROMUser has company names, not URLsThis Actor takes URLs onlyRe-route: Google Maps Business Leads to find the sites first.
FREE_PLAN_CAPOnly 5 records for a longer inputFree plans process the first 5 URLsPrompt User: upgrade for bulk runs.
NO_DATA_FOUNDstatus: "no_data_found"The site publishes no contact details, or hides themNot an error. Retry once with a higher maxPagesPerDomain; then accept.
JS_ONLY_SITE_MISSEDno_data_found and renderJavaScript was neverThe browser phase was disabledReconfigure: re-run those URLs with auto.
SHALLOW_SCAN_SUSPECTEDpagesScanned is 1–2 with a budget of 4+The early exit fired — enough was already foundNot an error. This is the cost optimisation.
CONTACT_PAGE_NOT_FETCHEDYou expected a page that wasn't in scannedPagesIts contact score was ≤ 0, or the budget ran outReconfigure: raise maxPagesPerDomain; add the page as its own input URL if it's unusual.
STATIC_FETCH_BLOCKEDerrors contains http_403 / http_429The site refused the plain HTTP fetchAlready handled — that URL was escalated to the browser. If it still failed, enable a RESIDENTIAL proxy.
STATIC_TIMEOUTerrors contains timeoutSlow origin; static budget is 20sAlready handled via browser escalation. Retry if the site is known-slow.
PHONE_NOT_E164A phoneNumbers entry has no + prefixphonenumbers could not parse it; raw string keptFix Code: treat entries as opaque strings; normalise with a region hint of your own.
PHONE_REGION_MISSNon-US site returns no phone despite one being visibleParsing assumes the US default regionKnown limitation. Extract from the page text yourself, or open an issue.
SOCIALS_NOT_A_LISTIterating socialLinks yields stringsIt's an object of arrays keyed by platformFix Code: socialLinks.linkedin?.[0].
EMAIL_NOT_RANKEDThe "wrong" address used as primaryemails is unranked by designFix Code: apply your own contact@/sales@ preference.
EMAIL_UNVERIFIEDA delivered email bouncesExtraction ≠ verificationFix Expectation: pair with a verification service.
NO_PEOPLE_DATANo names or job titlesCompany-level output onlyRe-route: LinkedIn Decision Makers.
NO_POSTAL_ADDRESSNo street address fieldNot extractedRe-route: Google Maps Business Leads.
IMAGE_ONLY_CONTACTContact visible in a browser but not in the outputIt's rendered inside an image; no OCRFix Expectation: not supported.
LOGIN_REQUIREDTarget needs authenticationNo credential supportAbort & Prompt User: public pages only.
DELIVERY_SKIPPEDLog no tool name was providedmcpConnector set but mcpTool emptyModify Input: set mcpTool; the dataset is already saved.
DELIVERY_TOOL_UNKNOWNLog Tool 'x' is not available on this connectorWrong tool name for that connectorModify Input: pick a name from the Available tools: list in the same log line.
DELIVERY_EMPTYConnector called, body emptymcpArguments omitted the {message} placeholderModify Input: map {message} inside mcpArguments.
DELIVERY_FLOODOne connector call per input URLdeliveryMode defaults to perSiteReconfigure: use chunked or summary.
MCP_PROXY_MISSINGLog APIFY_MCP_PROXY_URL is not setRunning locally instead of on the platformReconfigure: apify push and run on the platform.
CHARGE_LIMITRun aborted mid-listmaxTotalChargeUsd spend threshold reachedPrompt User: confirm a higher spend cap.

Reading run status: SUCCEEDED with an empty dataset is a valid outcome here, not a failure — only a FAILED status is a platform-level problem. Every Actor-side row, quoted message and field condition in the table above comes from this Actor’s own behaviour; the 401/408/429 rows are standard Apify API responses.


🗣️ Natural Language → Actor Mapping

User saysIntentConstructed Actor input
"Get me the contact details for these 200 websites"Bulk enrichment{"urls":[…],"maxPagesPerDomain":4,"renderJavaScript":"auto"}
"Find the email address for this company's site"Single enrichment{"urls":[{"url":"…"}],"maxPagesPerDomain":4}
"Add phone numbers to my CRM export"CRM enrichmentrun, then deliveryMode:"perSite" into Airtable/Sheets
"What social accounts does this company have?"Social discoveryrun, then read socialLinks
"Enrich 5,000 domains as cheaply as possible"Cost-first{"renderJavaScript":"never","maxPagesPerDomain":2,"maxConcurrency":20}, then re-run the misses on auto
"This site is a React app — will it work?"JS coverage{"renderJavaScript":"auto"} — it escalates automatically
"It found nothing for this site but I can see an email"Coverage gapRetry {"maxPagesPerDomain":10,"renderJavaScript":"always"}
"Which inbox should I send an enquiry to?"Routingrun, then prefer contact@/hello@/info@ from emails
"Only give me the sites where you found something"Filteringrun, then filter status == "success"
"Push each enriched site into Airtable"Delivery{"mcpConnector":"airtable","deliveryMode":"perSite","mcpTool":"create_record"}
"Post a summary of the enrichment to Slack"Digest delivery{"mcpConnector":"slack","deliveryMode":"summary","mcpTool":"send_message"}
"I have company names, not websites"Missing input➡️ Google Maps Business Leads first
"Who is the marketing director there?"Named people➡️ LinkedIn Decision Makers
"Guess the CEO's email from the pattern"Email guessing❌ Only published addresses
"Check these emails are valid"Verification❌ Extraction only — use a verification service
"Get their office address"Postal address❌ Not extracted
"Send them all an intro email"Outreach❌ Read-only — never contacts anyone
"Crawl the whole site and index it"Full crawl❌ Wrong tool — use Advanced Website Crawling Actor

Should NOT route here: finding websites from company names · named people or job titles · guessed / pattern-based email addresses · email deliverability verification · postal addresses · contacts behind a login · contact details rendered inside images · sending any outreach or submitting forms · full-site crawling or content indexing.


🧭 Agent Execution & Routing Logic

[Input User Query]
1. Do we have website URLs?
├── NO, only company names → Route to Google Maps Business Leads to find the sites first
└── YES → continue
2. What is actually wanted?
├── contact points the COMPANY publishes (email/phone/social) → stay here
├── NAMED people + job titles → Route to LinkedIn Decision Makers
├── guessed emails, or verification → Abort: neither is supported
├── postal address → Route to Google Maps Business Leads
└── full site content / indexing → Route to Advanced Website Crawling Actor
3. Pick the cost posture:
├── list is huge and cost dominates → renderJavaScript "never", maxPagesPerDomain 2,
│ maxConcurrency 20 … then RE-RUN THE MISSES on "auto"
├── normal case → renderJavaScript "auto" (default), maxPagesPerDomain 4
└── known-stubborn single site → "always" + maxPagesPerDomain 10-12
4. Proxy needed? Public marketing pages usually NO.
└── targets block datacenter IPs → useApifyProxy + RESIDENTIAL (paid plan)
5. Deliver to a connector? YES → mcpConnector + mcpTool + {message}
│ perSite (1 call per URL!) / chunked / summary
│ NO → set deliveryMode "none" (it defaults to perSite)
6. Bulk list? Use the ASYNC endpoint and set maxTotalChargeUsd
[Execute]
7. Read the records — every input URL returns one, so iterate on `status`:
├── status "success" → flatten. socialLinks is an OBJECT of arrays.
│ emails are UNRANKED and UNVERIFIED - apply your own
│ role-inbox preference; verify before sending anything.
└── status "no_data_found" → was renderJavaScript "never"?
YES → re-run that URL on "auto"
NO → retry once with a higher maxPagesPerDomain,
then accept: the site publishes nothing
8. Sanity-check cost, not just data:
`engine` per record and the closing log line
("N/M site(s) resolved without a browser") show where the money went.

💰 Cost Control & Pricing Transparency

Pay-per-result: you are charged per record returned — one per input URL — with no monthly subscription. The exact rate is on the Actor's Pricing tab in the Apify Store.

Free Apify plans process the first 5 URLs of the input, so you can validate a configuration for five results before committing to a bulk run.

Autonomous spend caps: pass maxTotalChargeUsd as a query parameter on the run endpoint for a hard per-execution ceiling.

ℹ️ Always confirm the live rate on the Actor’s Apify Store page before committing to a large run. The Store listing is what bills you; the figures above are a worked example at the advertised rate. Apify platform usage is billed separately.

Where platform usage actually goes — and what changed

Apify platform usage (compute units, plus proxy GB if enabled) is billed on top of the per-result rate, and on a contact scraper it used to dominate. Four changes cut it:

ChangeBeforeNow
EngineChromium rendered every page of every sitePlain HTTP first; a browser only for JS shells and refused fetches
networkidle waitUnbounded — on pages with streaming analytics it never fires, so each one burned the full request timeoutBounded at 8s and allowed to expire
FrameworkA crawler framework in the import path, pulling a browser-fingerprint dependency chainPlaywright driven directly — leaner image, shorter cold start
Page budgetDefault 10 pages per domain, always spentDefault 4, and a domain stops as soon as it has an email plus a phone or social
Sub-page choiceContact pages merely sorted first — blog and product pages still fetched to fill the budgetScored, footer-first; anything scoring ≤ 0 is never fetched
Browser assetsImages, media, fonts, CSS and analytics all downloadedAborted, plus 15 analytics/tracking hosts blocked

In practice a typical site now costs 1–2 plain HTTP fetches where it previously cost up to 10 browser page loads. The run log makes the split explicit:

Static phase done — 231/250 site(s) resolved without a browser.
Done — 250 site(s), 412 page(s) fetched, 19 site(s) needed a browser.

Cost levers, cheapest first:

  • renderJavaScript: "never" — no browser at all. The cheapest possible pass; combine with the two-pass pattern above so you only render the misses.
  • maxPagesPerDomain: 2 — homepage plus the single best contact link. Enough for most sites.
  • Leave renderJavaScript on auto. Setting it to always reinstates the old cost profile on every page.
  • Validate on 5 URLs first — the free-plan cap is a feature here.
  • Skip the proxy unless targets actually block you; residential traffic is metered per GB.
  • Prefer chunked/summary delivery over perSite — 1 connector call instead of one per URL.
  • Cap the spend with maxTotalChargeUsd on any agent-driven run.

🔍 Companion machine-readable files

FilePurpose
.actor/actor.jsonIdentity, title, description, version, and the input / output / dataset schema references
.actor/input_schema.jsonAuthoritative typed input contract — urls (required), maxPagesPerDomain (1–100, default 4), maxConcurrency (1–20), renderJavaScript enum, proxy, and the 5 delivery fields
.actor/dataset_schema.jsonTwo Console Output views — full Results overview (incl. engine) and a CRM-ready Contacts only view
.actor/output_schema.jsonDeclares where results are stored
my_actor/main.pyInput handling, the static phase, browser escalation, resource blocking, early exit, record assembly, MCP delivery
my_actor/extractor.pyEmail / phone / social extraction, strip_tags, JS-shell detection, and the scored footer-first link ranking
my_actor/delivery.pyMCP delivery: placeholder flattening, site-block rendering, chunk grouping, per-mode dispatch
my_actor/connector.pyMCP connector session handling through the Apify MCP Proxy

Note: this Actor has no pay_per_event.json and makes no Actor.charge() calls — billing is configured entirely on the Store listing.

If any table on this page disagrees with the schema files, the schema files win.


🛠️ Troubleshooting

#Symptom you seeMost likely causeFix
1Run fails immediately with an input errorurls is empty, or the objects have no url keyUse [{"url": "https://…"}].
2Only 5 records from a longer listFree Apify plan processes the first 5 URLsUpgrade; the log prints the upgrade banner.
3status: "no_data_found" for a site with a visible emailContact page not reached, or the page needs JSRaise maxPagesPerDomain; confirm renderJavaScript isn't never.
4Lots of no_data_found all at oncerenderJavaScript: "never" skipped every JS-only siteRe-run the misses with auto.
5pagesScanned is 1–2 though the budget is 4The early exit fired — enough was foundWorking as designed. Raise the budget only if you want more addresses.
6A page you expected isn't in scannedPagesIts contact score was ≤ 0 (blog/product/legal/auth path)Add that page directly as its own input URL.
7errors shows http_403 or http_429The site refused the plain HTTP fetchAlready escalated to a browser. If still empty, enable a RESIDENTIAL proxy.
8errors shows timeoutSlow origin (static budget is 20s)Already escalated. Re-run if the site is known-slow.
9errors shows content_type:application/pdfThe URL isn't an HTML pagePoint at the site's HTML homepage.
10A phone number has no + prefixphonenumbers couldn't parse it, so the raw string was keptExpected. Treat entries as opaque strings.
11A visible non-US phone number is missingParsing assumes the US default regionKnown limitation — see below.
12Same number appears twiceTwo formats that don't normalise to the same E.164Rare; dedupe on digits your side.
13Iterating socialLinks gives platform namesIt's an object of arrayssocialLinks.linkedin?.[0].
14An odd email like u0040@… or a Sentry addressRegex extraction from markup isn't perfectKnown placeholder domains are dropped; filter further your side.
15The "wrong" email is firstemails is unrankedApply a role-inbox preference (snippet above).
16A delivered email bouncedExtraction ≠ verificationPair with a verification service.
17No names or job titles anywhereCompany-level contacts onlyUse LinkedIn Decision Makers.
18Run is slower than expectedMany sites escalated to the browser phaseCheck the Static phase done — N/M log line; lower maxPagesPerDomain.
19One connector call per URLdeliveryMode defaults to perSiteSwitch to chunked or summary.
20Notion / Airtable timed outOne payload carrying every site is too largeUse deliveryMode: "chunked".
21Slack / Airtable received nothingDelivery needs both mcpConnector and mcpToolSet both. The dataset is still written in full.
22Connector fired but the message body is emptymcpArguments did not reference {message}Map {message} in mcpArguments and populate mcpMessageTemplate.
23Log APIFY_MCP_PROXY_URL is not setYou ran locally; connectors only resolve on the platformDeploy with apify push and run on the platform.
24HTTP 408 on run-sync-get-dataset-itemsThe synchronous endpoint has a hard 300-second ceilingUse async POST /runs → poll → fetch.
25HTTP 401 / 403 from ApifyMissing, expired or malformed APIFY_TOKENRegenerate in Apify Console → Settings → API & Integrations.
26HTTP 429 from ApifyApify account concurrency / rate limitsRetry with exponential backoff (2s → 4s → 8s).

Known limitations

  • Phone parsing assumes the US as the default region. Numbers in E.164 (+44 20 …) or with an explicit country code parse and normalise correctly. A UK, German or Indian number written in purely local format may be missed or kept unnormalised.
  • No OCR. Contact details that exist only inside an image are invisible to this Actor — a deliberate choice by some sites to defeat scrapers.
  • Emails are unranked and unverified. Extraction only; no deliverability check, no bounce prediction.
  • Company-level, not person-level. No names, no job titles, no per-person addresses.
  • Not a site crawler. The per-domain page budget is small on purpose; use Advanced Website Crawling Actor for full-site content.

Diagnostic checklist before opening an issue

  1. Baseline run. One known-good site, maxPagesPerDomain: 4, renderJavaScript: "auto", deliveryMode: "none".
  2. Contacts returned? If yes, the pipeline is fine and the earlier site simply publishes less than you expected.
  3. Check scannedPages. It tells you exactly which pages were read. If the page holding the email isn't listed, that's a budget or link-scoring issue — raise maxPagesPerDomain.
  4. Check engine and errors. static with no errors means the page was read fine and the data genuinely wasn't in the HTML.
  5. Force it. renderJavaScript: "always" with maxPagesPerDomain: 10. If that finds the contact and auto didn't, that's worth reporting.

If the issue survives all five steps, open an Issues ticket on the Actor page (or email support) with the run ID, the exact input JSON, and the URL where you can see the contact detail the Actor missed.

❓ FAQ

Setup & scope

What do I need to supply?

Just website URLs. No API keys, no credentials, no account on the target sites.

Do I need a proxy?

Usually not — these are public marketing pages fetched once or twice each. Enable Apify Proxy with the RESIDENTIAL group only if your targets block datacenter IPs.

Can I plug it into Claude, Cursor, or a LangChain agent?

Yes — it is a native MCP tool:

claude mcp add --transport http apify "https://mcp.apify.com?tools=techforce.global/website-contact-scraper-emails-phone-numbers-social-links"

I have company names, not websites. Can it find the sites?

No — it needs URLs. Run Google Maps Business Leads first to turn names and locations into websites, then feed those here.

The static/browser split

Why is it "static first"?

Because emails, tel: links and social URLs are in the served HTML on the large majority of sites. Rendering those pages in Chromium is paying browser prices for HTTP work. The static phase handles them; the browser is reserved for pages that genuinely need JavaScript.

How does it know a page needs a browser?

Two signals: the served HTML is an almost-empty single-page-app shell (a framework mount point like id="root" or id="__next" with under 500 characters of text), or the static fetch was refused outright (403/429/timeout). Either one escalates that URL to the browser phase.

Will auto miss anything that always would find?

It shouldn't — a site that renders contact details only via JavaScript is exactly what the escalation detects. If you find a case where always finds something auto misses, that's a bug worth reporting.

How do I tell whether a browser was used?

The engine field on each record: static, browser, or static+browser. The run log also prints Static phase done — N/M site(s) resolved without a browser.

Coverage & accuracy

Why did it only scan 2 pages when I allowed 4?

The early exit: once a domain has an email plus a phone or social profile, extra fetches would at best add a second address for the same company. Raise maxPagesPerDomain if you want exhaustive collection rather than a good contact point.

How does it decide which sub-pages to fetch?

By score, not link order. Footer links get a large bonus, contact/about/team/imprint paths score high, and blog/product/legal/auth/cart paths score to zero and are never fetched.

Are the emails verified?

No. They are addresses published on the page. Verify before sending anything at volume.

Which email is the primary one?

There isn't one — emails is deliberately unranked. Apply your own preference (contact@hello@info@sales@); the README has a snippet.

Why are some phone numbers not in E.164 format?

phonenumbers couldn't parse them confidently, so the raw display string is kept rather than dropping a real number. Related: parsing assumes the US as the default region, so purely local-format non-US numbers can be missed.

Does it read contact details from images?

No — there's no OCR.

Can it get named people and job titles?

No, this is company-level. Use LinkedIn Decision Makers.

Pricing

How is this billed?

Pay-per-result — one result per input URL, whether or not contacts were found — with no monthly subscription. The current rate is on the Actor's Pricing tab. Apify platform usage is billed separately.

How do I make a large run as cheap as possible?

renderJavaScript: "never" with maxPagesPerDomain: 2 and high concurrency for the first pass, then re-run only the no_data_found URLs with auto. The Python section has the two-pass snippet.

Am I charged for sites where nothing was found?

Yes — a record is produced for every input URL, including empty ones, and that record is the billable result. Validate your list before a bulk run.

How do I guarantee an agent never overspends?

Pass maxTotalChargeUsd as a query parameter on the run endpoint.


The enrichment chain

This Actor is the contact-extraction step. It needs URLs going in, and it produces company-level contact points coming out — which makes the Actors on either side of it the useful pairings.

ActorPositionWhat it adds
Google Maps Business Leads & Sales IntelligenceBefore — finds the sitesTurns a place + query into businesses with websites, plus ratings and a website grade. Feed its website field straight into this Actor
This ActorThe contact stepAll published emails, E.164 phones, 9 social platforms — one record per domain
LinkedIn Decision MakersAfter — puts names to itFounders, C-suite, VPs and directors at the company, which this Actor never returns
LinkedIn Employee ScraperAfter — staff viewEvery employee with a derived department and seniority band
GOV.UK Business ScraperAfter — verificationConfirms a UK company is real and active, with PSC / beneficial ownership
Advanced Website Crawling ActorAlternativeFull-site content crawling when you want the pages themselves, not just the contacts

Vertical lead sources that pair well

Each returns businesses with websites — exactly the input this Actor wants:

ActorFeeds this Actor with
Construction Lead ScraperContractor and developer websites
Finance Lead ScraperFinancial firm websites
Healthcare Lead ScraperPractice and clinic websites
Hospitality Lead ScraperVenue, hotel and restaurant websites
DesignRush Agency Scraper · TopDevelopers ScraperAgency and dev-shop websites

Suggested pipeline patterns

📇 Name → website → contacts Google Maps Business Leads → take the website field → this Actor → deliveryMode: "perSite" into your CRM. A complete lead record from a single place query.

🧹 CRM hygiene Export the websites you already hold → this Actor with renderJavaScript: "never" → update rows where status == "success" and flag the rest for review.

🎯 Company contact + named buyer This Actor for the company inbox and switchboard → LinkedIn Decision Makers for the person to address → merge on the company. You get both the route in and the name.

🛡️ Verified UK outreach list Vertical lead scraper → this Actor for contacts → GOV.UK Business Scraper to confirm the entity is active before anyone spends outreach time.

💸 Two-pass bulk enrichment Pass 1 renderJavaScript: "never" over the whole list → pass 2 auto over only the no_data_found URLs. Same coverage, a fraction of the browser cost.

Browse all Actors by Techforce Global at scraper.techforce.global.


🔐 Compliance & Data Privacy

This Actor reads publicly served pages on websites you supply. It does not log in, does not accept credentials, does not bypass authentication, and never submits a form or sends any message.

Some of what it returns is personal data. A role inbox like info@company.com generally is not, but firstname.lastname@company.com, a named person's direct line, and a personal LinkedIn profile URL are. Four consequences:

  • You become a data controller when you store it. GDPR, UK GDPR and CCPA/CPRA apply to the personal-data subset. Have a lawful basis (legitimate interest is the usual route for B2B contact data, and it requires a balancing assessment), record it, set a retention period, and be ready to honour access, objection and erasure requests.
  • Publication is not consent. A company publishing an address so customers can reach it has not agreed to receive marketing. That is a separate question under GDPR Art. 6/21, ePrivacy/PECR, CAN-SPAM and CASL — and several jurisdictions treat unsolicited B2B email differently from B2C.
  • Nothing here is verified, so accuracy is your obligation. GDPR Art. 5(1)(d) requires personal data to be accurate and kept up to date. Extracted addresses go stale; don't treat a scrape from months ago as current.
  • Prefer role inboxes for outreach. contact@, sales@, info@ carry far lower privacy risk than a named individual's address and are usually the correct route anyway. The emails array lets you choose deliberately.

Respect the sites you point this at: the per-domain page budget is small by default and concurrency is capped, which keeps the footprint light — don't defeat that by running many parallel runs against the same domain. You remain responsible for complying with each target site's terms of service, robots.txt expectations, and all applicable data-protection and marketing law in the jurisdictions you operate in.


🆘 Support & Custom Pipeline Engineering

Need scheduled re-enrichment with change detection, email verification wired into the pipeline, region-aware phone parsing, or a full lead-generation workflow feeding your CRM?


Made with ❤️ by Techforce Global Specialists in High-Performance B2B Contact Data Extraction and AI Automation.


🏷️ Structured data for search & AI discovery

{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "Website Contact Scraper — Emails, Phone Numbers & Social Links",
"applicationCategory": "BusinessApplication",
"operatingSystem": "Cloud (Apify platform)",
"description": "Extracts contact details from any list of websites and returns one consolidated record per domain — every published email address, phone numbers normalised to E.164, and social profile links across 9 platforms — with contact, about and team pages discovered automatically by a scored footer-first ranking. Each site is fetched over plain HTTP first and rendered in a browser only when the page needs JavaScript, and a domain stops as soon as it has enough contact data. Results can be delivered into Airtable, Notion, Slack, Google Sheets or any authorized MCP connector.",
"url": "https://apify.com/techforce.global/website-contact-scraper-emails-phone-numbers-social-links",
"offers": {
"@type": "Offer",
"priceCurrency": "USD",
"description": "Pay-per-result, charged per record returned (one per input URL). The current rate is listed on the Actor's Pricing tab."
}
}
{
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": "What do I need to supply?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Just website URLs. No API keys, no credentials, no account on the target sites."
}
},
{
"@type": "Question",
"name": "Do I need a proxy?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Usually not - these are public marketing pages fetched once or twice each. Enable Apify Proxy with the RESIDENTIAL group only if your targets block datacenter IPs."
}
},
{
"@type": "Question",
"name": "Can I plug it into Claude, Cursor, or a LangChain agent?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Yes - it is a native MCP tool:"
}
},
{
"@type": "Question",
"name": "I have company names, not websites. Can it find the sites?",
"acceptedAnswer": {
"@type": "Answer",
"text": "No - it needs URLs. Run Google Maps Business Leads first to turn names and locations into websites, then feed those here."
}
},
{
"@type": "Question",
"name": "Why is it \"static first\"?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Because emails, tel: links and social URLs are in the served HTML on the large majority of sites. Rendering those pages in Chromium is paying browser prices for HTTP work. The static phase handles them; the browser is reserved for pages that genuinely need JavaScript."
}
},
{
"@type": "Question",
"name": "How does it know a page needs a browser?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Two signals: the served HTML is an almost-empty single-page-app shell (a framework mount point like id=\"root\" or id=\"__next\" with under 500 characters of text), or the static fetch was refused outright (403/429/timeout). Either one escalates that URL to the browser phase."
}
},
{
"@type": "Question",
"name": "Will auto miss anything that always would find?",
"acceptedAnswer": {
"@type": "Answer",
"text": "It shouldn't - a site that renders contact details only via JavaScript is exactly what the escalation detects. If you find a case where always finds something auto misses, that's a bug worth reporting."
}
},
{
"@type": "Question",
"name": "How do I tell whether a browser was used?",
"acceptedAnswer": {
"@type": "Answer",
"text": "The engine field on each record: static, browser, or static+browser. The run log also prints Static phase done - N/M site(s) resolved without a browser."
}
},
{
"@type": "Question",
"name": "Why did it only scan 2 pages when I allowed 4?",
"acceptedAnswer": {
"@type": "Answer",
"text": "The early exit: once a domain has an email plus a phone or social profile, extra fetches would at best add a second address for the same company. Raise maxPagesPerDomain if you want exhaustive collection rather than a good contact point."
}
},
{
"@type": "Question",
"name": "How does it decide which sub-pages to fetch?",
"acceptedAnswer": {
"@type": "Answer",
"text": "By score, not link order. Footer links get a large bonus, contact/about/team/imprint paths score high, and blog/product/legal/auth/cart paths score to zero and are never fetched."
}
},
{
"@type": "Question",
"name": "Are the emails verified?",
"acceptedAnswer": {
"@type": "Answer",
"text": "No. They are addresses published on the page. Verify before sending anything at volume."
}
},
{
"@type": "Question",
"name": "Which email is the primary one?",
"acceptedAnswer": {
"@type": "Answer",
"text": "There isn't one - emails is deliberately unranked. Apply your own preference (contact@ -> hello@ -> info@ -> sales@); the README has a snippet."
}
},
{
"@type": "Question",
"name": "Why are some phone numbers not in E.164 format?",
"acceptedAnswer": {
"@type": "Answer",
"text": "phonenumbers couldn't parse them confidently, so the raw display string is kept rather than dropping a real number. Related: parsing assumes the US as the default region, so purely local-format non-US numbers can be missed."
}
},
{
"@type": "Question",
"name": "Does it read contact details from images?",
"acceptedAnswer": {
"@type": "Answer",
"text": "No - there's no OCR."
}
},
{
"@type": "Question",
"name": "Can it get named people and job titles?",
"acceptedAnswer": {
"@type": "Answer",
"text": "No, this is company-level. Use LinkedIn Decision Makers."
}
},
{
"@type": "Question",
"name": "How is this billed?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Pay-per-result - one result per input URL, whether or not contacts were found - with no monthly subscription. The current rate is on the Actor's Pricing tab. Apify platform usage is billed separately."
}
},
{
"@type": "Question",
"name": "How do I make a large run as cheap as possible?",
"acceptedAnswer": {
"@type": "Answer",
"text": "renderJavaScript: \"never\" with maxPagesPerDomain: 2 and high concurrency for the first pass, then re-run only the no_data_found URLs with auto. The Python section has the two-pass snippet."
}
},
{
"@type": "Question",
"name": "Am I charged for sites where nothing was found?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Yes - a record is produced for every input URL, including empty ones, and that record is the billable result. Validate your list before a bulk run."
}
},
{
"@type": "Question",
"name": "How do I guarantee an agent never overspends?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Pass maxTotalChargeUsd as a query parameter on the run endpoint."
}
}
]
}