Website Contact Scraper - Email & Phone Finder
Pricing
from $2.50 / 1,000 website with contacts founds
Website Contact Scraper - Email & Phone Finder
Find emails, phones and social profiles on any list of websites. $5 per 1,000 websites with a contact: blocked, dead or empty sites are free. One clean row per domain, with the source page for every contact.
Pricing
from $2.50 / 1,000 website with contacts founds
Rating
0.0
(0)
Developer
Sumitr Mardy
Maintained by CommunityActor stats
1
Bookmarked
2
Total users
1
Monthly active users
9 days ago
Last modified
Categories
Features
Share
Website Contact Finder — pay only for sites with contacts
For local-business prospecting, first collect websites with Google Maps Scraper, pass its website values as domains here, then send collected emails to Email Verifier.
Give it a list of domains. Get back one row per domain with the emails, phone numbers and social profiles published on that website, plus the business's own structured data when it has any.
You are charged only for domains where a contact was actually found. A domain that is blocked, dead, or simply publishes no contact details costs you nothing.
Why that one sentence matters
Every contact scraper hits the same ceiling: roughly a third of small-business websites publish an email address, and a third of them sit behind a bot wall. That is not a quality difference between tools — it is the web.
The difference is what you pay for the misses. The usual model bills per page opened, so a domain that turns out to be empty still costs you three or four pages. This actor bills per domain that yields a contact.
Measured on the same 100 random SME domains:
| pages billed | domains with a contact | you pay for | |
|---|---|---|---|
| per-page scraper | 336 | 74 | 336 pages |
| this actor | — | 77 | 77 contacts |
Both find a contact on about three domains in four — the yield is level. What differs is that one bills 336 pages for it and the other bills 77 results.
Cost per contact found, at an equal page budget:
| Free | Gold | |
|---|---|---|
| per-page scraper ($2.00 → $1.05 / 1k pages) | $0.0091 | $0.0048 |
| this actor | $0.0060 | $0.0030 |
About 1.5× cheaper per contact, and the gap is wider on lists with a lot of dead or protected domains, because those are free here.
What you get per domain
{"domain": "mud-dock.co.uk","url": "https://www.mud-dock.co.uk/","status": "contacts_found","sub_status": "email_and_phone","emails": [{"email": "cafe@mud-dock.co.uk","type": "generic", // generic | personal | unknown"is_free_provider": false, // gmail/yahoo/..."on_site_domain": true, // on the domain we crawled"source_url": "https://www.mud-dock.co.uk/contact-us/"}],"phones": [{"e164": "+441179292151", // normalised, dialable"raw": "0117 929 2151","country": "GB","confidence": "high", // high = tel: link or structured data"source_url": "https://www.mud-dock.co.uk/"}],"socials": [{ "network": "facebook", "url": "https://www.facebook.com/muddock" }],"organization": { // from schema.org data on the page"name": "Mud Dock Cycleworks", "legal_name": "Mud Dock Ltd","street": "40 The Grove", "city": "Bristol", "region": "","postal_code": "BS1 4RB", "country": "GB", "type": "LocalBusiness"},"checks": { "dns_resolved": true, "homepage_ok": true, "bot_challenge": false,"json_ld_found": true, "mailto_found": true,"obfuscated_email_decoded": false, "tel_link_found": true,"contact_page_reached": true, "email_on_site_domain": true,"has_contact_form": true },"pages": ["https://www.mud-dock.co.uk/", "https://www.mud-dock.co.uk/contact-us/"],"pages_crawled": 2,"found_at": "2026-10-19T08:00:00.000Z","duration_ms": 1840}
The schema is a contract
- Exactly one row per input domain, including domains that returned nothing. Join on your input list without wondering which rows went missing.
statusandsub_statusare never null and never empty.emails,phones,socialsandpagesare always arrays.organizationalways has all 8 string keys; unknown fields are"".checksalways has all 10 boolean keys; a check that was never reached isfalse, never null. The reason lives insub_status.
Status values
status | sub_status | meaning |
|---|---|---|
contacts_found | email_and_phone / email_only / phone_only | charged |
no_contacts | no_contact_published | pages read fine, the site publishes no address or number |
blocked | bot_challenge / http_error | a bot wall or a 4xx answered us. Worth retrying later |
unreachable | dns_error / tls_error / timeout | domain is dead, mis-configured, or too slow |
unreachable | internal_error | our fault; error says what happened |
Only contacts_found is ever charged, plus Apify's standard start event ($0.00005 per GB of memory, $0.0001 per run at the default 2 GB), in return for which Apify covers the first 5 seconds of compute.
How it finds things
- Homepage first. On the measured sample it carried most of the finds on its own, so most domains cost one page.
- Then the site's own contact links, then
/contact,/contact-us,/about,/about-us,/team,/impressum. - Crawling stops as soon as both an email and a phone number are found.
- schema.org/JSON-LD is read first where present — an address the owner put there for machines is better than one scraped out of a footer.
- Obfuscated addresses are decoded: Cloudflare's
data-cfemail, HTML entities, andname [at] example [dot] com. - Phone numbers are normalised to E.164. The dialling plan comes from
structured data, then the ccTLD. On a
.comor.orgthe page decides: its language region and any UK or Canadian postcode nominate a country, and the numbers on the page vote between that and yourdefaultCountry— so a Bristol business on.comis read as UK, while a stray UK postcode on a US page loses to the US numbers around it. Company and VAT numbers are rejected by validation rather than by guesswork, and two numbers on two lines are never read as one. - Apify Proxy (datacenter) is on by default. Six domains in the 100-domain sample answered a direct connection with 403 or nothing and were fine through rotating IPs. A blocked page is retried once on a fresh IP.
What it does not do
- No headless browser. Measured: exactly one domain in 100 was a JavaScript shell with contact details only in the rendered DOM. A browser would multiply the cost to fix one row in a hundred.
- No guessing. If a site publishes no address, you get
no_contacts, notinfo@invented from the domain. - No person-level enrichment. This reads what the website publishes.
Expected yield, stated honestly
On 100 random SME domains (shops, trades and offices in the US and UK, sampled from OpenStreetMap), measured 2026-10-22 on Apify with the datacenter proxy on:
a contact (email or phone) : 77%an email address : 51%a phone number : 73%a social profile : 73%publishes no contact at all: 13%blocked by a bot wall : 7%dead or unreachable : 3%pages read : 3.33 per domain
Run side by side with the leading per-page scraper over the same 100 domains:
| phone | contact (email or phone) | ||
|---|---|---|---|
| per-page scraper | 38 | 73 | 74 |
| this actor | 51 | 73 | 77 |
Compared domain by domain rather than by totals:
- Email: this actor is ahead. 18 domains where only it found an address, 5 the other way.
- Phone: level. 4 against 4. Until build 0.1.10 the other scraper led here
by 15 against 5: this actor read a UK business on a
.comdomain with US dialling rules and dropped every number, and read two numbers on two lines as one long invalid one. Both are fixed. - Any contact: level. 5 against 2 — not enough to call a difference.
The other scraper also returns "uncertain" numbers that include year ranges, decimals and Unix timestamps; counting those it reaches 77 phones and 77 contacts. The figures above leave out anything that could not be dialled. Every phone this actor returns is validated and normalised to E.164.
The other scraper's run is from 2026-10-19 and this one from 2026-10-22. Sites change between days, so treat differences of a few domains as noise.
Roughly a third of the misses on both sides are sites that publish no address anywhere, which no scraper can fix.
Input
| field | default | notes |
|---|---|---|
domains | — | domains or URLs; duplicates on the same host collapse to one row and one charge |
startUrls | — | the same thing in Apify's URL-list format, for chaining |
concurrency | 15 | cost follows total runtime, so raising this does not raise the bill |
maxPagesPerDomain | 5 | a budget, not a target; crawling stops early on success |
timeoutMs | 8000 | a slow host is reported, not waited on |
useProxy | true | turn it off and expect more blocked rows |
allowInsecureTls | true | small business sites let certificates expire; only public pages are read and nothing is ever submitted |
defaultCountry | US | the fallback dialling plan for local-format numbers, used only when neither the domain, the structured data nor the page itself points to a country |
Verifying the emails
Addresses found on a website are not proof that the mailbox exists. To filter bounces, pass the output through Email Verification API — SMTP, MX and honest catch-all detection, with the same no-null output contract.
Use it from AI agents (MCP)
This Actor is a tool on the Apify MCP server, so Claude, Cursor, VS Code or any MCP client can call it. Load only the tools you need: a short tool list keeps the agent from picking the wrong one.
Recommended: hosted server, OAuth sign-in (nothing secret on disk). This config loads the contact finder together with the email verifier, so an agent can find contacts and then check them in one conversation:
{"mcpServers": {"contact-finder": {"url": "https://mcp.apify.com?tools=sumitr_mardy/website-contact-finder,sumitr_mardy/email-verifier"}}}
With an API token instead (clients without OAuth, or unattended setups), add
"headers": { "Authorization": "Bearer YOUR_APIFY_TOKEN" } next to url.
Local server (stdio):
{"mcpServers": {"contact-finder": {"command": "npx","args": ["-y", "@apify/actors-mcp-server@latest", "--tools", "sumitr_mardy/website-contact-finder"],"env": { "APIFY_TOKEN": "YOUR_APIFY_TOKEN" }}}}
Get a token at Apify Console → API & Integrations, and never commit it. Runs are billed to the Apify account the agent signs in with, and only domains where a contact is found are charged.
Tips for agents:
- Pass all domains in one call. Every input domain comes back as exactly one row, so results join back to the input list without guessing.
- Read
statusfirst:contacts_foundis the only charged status; anything else says why nothing came back. emails[].typeseparatespersonalfromgenericinboxes, and every contact carries thesource_urlit was found on, so an agent can cite it.- The MCP
call-actortool waits up to 45 seconds. A long list keeps crawling after that: fetch the results withget-dataset-itemsand the run'sdatasetId.
Legal
Email addresses that identify a person are personal data under the GDPR and similar laws. This actor reads only what a website publishes to the public, but what you do with it is your responsibility: use it for business-to-business contact, honour opt-outs, and have a lawful basis before you send anything.