DEV Community

Nikita Iakovlev
Nikita Iakovlev

Posted on

Basecamp's Home Page Gave Me Two Valid Phone Numbers. Both Were Calendar Day Numbers

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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 …
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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/" }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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." }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}'
Enter fullscreen mode Exit fullscreen mode

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)