I run a visa agency in Bali. The other half of my week goes into Apify Actors, and one of them does something that sounds trivial until you try it: you give it a list of websites, it gives you back one row per website with the company's e-mail addresses, phone numbers, socials, contact page and postal address, each contact carrying the page it was found on.
Fetching the pages is the easy part: a business site is a handful of HTML documents and no login. The part that took the most work — and still isn't finished — is that marketing pages are full of contact data that is completely fake and completely well-formed. Not malformed strings you can regex away. Valid E.164 phone numbers. Addresses on the company's own domain that pass a DNS deliverability check.
Here are three of them, from real runs on 13–14 October 2026, all reproducible against the live pages as I write this.
A calendar is ten digits long
basecamp.com, home page only, one page read, 568 ms. Two phone numbers came back:
+1 301 516 1718 fixed_line_or_mobile source: text
+1 301 213 1415 fixed_line_or_mobile source: text
Basecamp does not publish a phone number. These came out of the page text, and I only found out where by printing the matched substring instead of the parsed number:
+13015161718 MATCHED=> "30 15 16 17 18"
+13012131415 MATCHED=> "30 12 13 14 15"
Basecamp's home page shows off its calendar view, and that screenshot is not an image — it is rendered HTML. Strip the tags and a month grid collapses into a run of day numbers:
… 29 30 31 Sep 1, 2026 Team Sync 11:00 - 11:30 2 3 Product Meeting 10:00 - 11:00 …
30 12 13 14 15 is ten digits with plausible spacing. I was running candidates through libphonenumber-js, which is the right tool and gave the right answer: in the North American plan, area code 301 (Maryland) with exchange 213 is a valid number. It is a valid number. It is just not anybody's.
My guard against this was a length floor — a number found in prose needs at least seven national digits, so that order IDs and dates don't become phones:
if (source === 'text' && String(pn.nationalNumber).length < 7) return;
Ten digits sails past it. A length floor cannot distinguish a phone number from a calendar, and neither can a numbering-plan validator. The only thing that separates them is where the digits were standing, which is why every phone in the output carries source and sourceUrl:
tel: 4 a tel: link — a human put this here to be dialled
schema.org: 3
whatsapp: 3
text: 1 digits that happened to be shaped like a phone
Rank 1 means "shaped like". Basecamp's real contact on that same page is rank 4 and unambiguous: mailto:jason@basecamp.com.
Stripe's mock checkout, and a filter that fired four times out of five
stripe.com, same run. One e-mail address came back:
{ "email": "jane.diaz@stripe.com", "type": "personal", "isOwnDomain": true,
"source": "text", "sourceUrl": "https://stripe.com/" }
That is not a Stripe mailbox. Grep the home page for every address on it and the story is immediate:
2 jane.smith@example.com
1 taro.yamada@example.com
1 damian.michelfelder@example.com
1 billing@example.com
1 jane.diaz@stripe.com
They all live in one place — the i18n bundle for the mock checkout graphic on the home page:
mkt-ssr.homepage.modular-solutions.payment-graphic.checkout.email-address-us → "jane.diaz@stripe.com"
mkt-ssr.homepage.modular-solutions.payment-graphic.checkout.email-address-de → "damian.michelfelder@example.com"
Whoever wrote the German string used example.com, the domain reserved by RFC 2606 for exactly this. Whoever wrote the US string used stripe.com. My placeholder filter caught four of the five:
const PLACEHOLDER_DOMAIN = new Set(['example.com', 'example.org', 'yourdomain.com', 'test.com', …]);
const PLACEHOLDER_LOCAL = /^(example|sample|test|yourname|john\.?doe|jane\.?doe|demo|dummy|…)$/i;
jane.doe is in there. jane.diaz is not, and never will be — the list is of placeholder conventions, and a convention nobody agreed on is not detectable.
Then the ranking promoted it. The score is deliberately biased toward the company's own domain, because for lead data that is almost always right:
const emailScore = (e) =>
(e.isOwnDomain ? 100 : 0) + (e.isFree ? -20 : 0) + e.rank * 5
+ (PRIMARY_LOCAL.has(e.email.split('@')[0]) ? 15 : 0)
+ (e.type === 'personal' ? 4 : 0) + (e.type === 'role' ? 1 : 0);
Here that bias is exactly backwards. The one fake address on the page is the one on stripe.com, so it scores 100 while its four honest siblings have already been dropped, and bestEmail is a person who does not exist.
The deliverability add-on does not save you either:
{ "email": "jane.diaz@stripe.com", "status": "deliverable",
"provider": "google", "mxHost": "aspmx.l.google.com", "isRole": false }
That verdict is correct and useless at once. A DNS-level check asks whether the domain accepts mail, and stripe.com obviously does. Nothing short of sending mail knows whether the mailbox exists, and I am not doing that from a scraper.
The honest answer for Stripe is in a different run. Pointed straight at https://stripe.com/contact — three pages read, including support.stripe.com — the row came back with zero e-mails and one phone:
{ "domain": "stripe.com", "status": "ok", "emails": [],
"phones": [{ "phone": "+18889262289", "kind": "toll_free", "source": "text",
"sourceUrl": "https://stripe.com/contact" }],
"summary": "Stripe: +18889262289." }
Stripe publishes a support line and no e-mail address. The home page's only "e-mail" is a prop. Both rows are accurate reports of what the page said; only one of them is a contact.
Five real addresses, and the wrong desk wins
Not every miss is fake data. zendesk.com, three pages, five addresses — all of them genuine mailto: links off the contact page:
ask.philippines@zendesk.com personal mailto
ask.thailand@zendesk.com personal mailto
ask.indonesia@zendesk.com personal mailto
ask.malaysia@zendesk.com personal mailto
ask.gcr@zendesk.com personal mailto
bestEmail is the Philippines desk — for no reason at all, just first in page order among five identical scores. And all five are typed personal, which is wrong: these are regional team mailboxes. The classifier is two lines long:
if (ROLE_PREFIXES.has(l) || ROLE_PREFIXES.has(l.replace(/[._-]/g, ''))) return 'role';
if (/^[a-z]{2,}[._][a-z]{2,}$/.test(l)) return 'personal'; // jane.diaz, j_smith
return 'generic';
ask is in ROLE_PREFIXES. ask.philippines collapses to askphilippines, which is not, so the "two words joined by a dot" rule wins and calls it a person. The same rule is what typed jane.diaz as personal and handed it the +4. Telling a person's name from a department name without a names list is guesswork, and I document it as guesswork rather than pretending otherwise.
Compare apify.com in the same run — four addresses, all role, all mailto, hello@apify.com on top because hello is in PRIMARY_LOCAL. That is what the scoring is built for, and on a site that publishes contacts like a normal business it works.
The other failure mode: nothing at all
Worth saying plainly, because lead-gen tooling rarely does. Out of the big SaaS names I tested, these returned status: "no_contacts" after reading three to six pages each:
monzo.com notion.com zapier.com vercel.com ycombinator.com bbc.co.uk
Vercel's row read every door — /contact, /contact/sales, /about — and came back "Vercel: no e-mail or phone; linkedin, facebook, instagram, x, youtube." There is no scraping trick behind that. A company that routes everything through a form and a support portal has no e-mail address to find, and a tool that returns one for them is making it up. Those rows are also free in this Actor — you are billed per website where a contact was actually found, not per website submitted — which is the only honest way to price a job whose hit rate depends on the list.
What I'd tell you to do with the output
Use source as a trust column, not decoration. For cold outreach, keep mailto and schema.org and treat text as a lead to check by hand — every contact ships with sourceUrl, so checking is one click. The same goes for phones: tel is dialled by a human somewhere, text is a pattern match. If you are enriching a CRM rather than mailing, isOwnDomain and isFree matter more, since a sole trader's only address really is on gmail.com.
Calling it is a list of domains and nothing else:
curl -X POST "https://api.apify.com/v2/acts/lergassy~website-contacts-api/run-sync-get-dataset-items?token=$APIFY_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"domains":["basecamp.com","zendesk.com","apify.com"],"maxPagesPerSite":3,"stopEarly":true}'
stopEarly stops reading a site once a page has yielded both an own-domain e-mail and a valid phone — which is why Basecamp's row says pagesScannedCount: 1. Turn it off and you get every address on every page; the price does not change with pages read either way.
The Actor is website-contacts-api on Apify. Where it does not fit: it reads HTML, so a site whose contact block only exists after JavaScript runs will come back thin (the row says so — note: "javascript_rendered_site"); it verifies e-mails at the domain level and never probes a mailbox; and it will not find a person's direct address that the company has not published. It finds what a website publishes, which — as Basecamp's calendar demonstrates — occasionally includes things that merely look published.
Top comments (0)