# Website Contact Scraper (`garnet_puppet/website-contact-scraper`) Actor

Extract e-mail addresses, phone numbers and social profiles from any list of websites. Every address is MX-verified at no extra charge, and every value reports where on the site it was found.

- **URL**: https://apify.com/garnet\_puppet/website-contact-scraper.md
- **Developed by:** [Mehmet](https://apify.com/garnet_puppet) (community)
- **Categories:**
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $4.00 / 1,000 contact details founds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

**B2B lead generation from a list of websites.** Give it the domains, get
back one record per site with **e-mail addresses, phone numbers and social
profiles** — and, for every address, **where on the site it was found** and
**whether the domain accepts mail**.

Built for lead generation and CRM enrichment: feed it a prospect list, an
export from your CRM, or the output of another scraper, and it returns
outreach-ready contact data with the evidence attached.

No browser. No residential proxy. No login.

***

### What makes this one different

**1. E-mail verification is included, not an add-on.**
Every address found is checked against the domain's MX records, with the
mail provider identified (Google Workspace, Microsoft 365, …), disposable
domains flagged, and likely typos suggested. Other Actors on this shelf
charge separately for verification. Here it is part of the base price.

**2. Every value says where it came from.**
Each address carries `method` and `sourcePath`:

- `mailto` — taken from a `href="mailto:"` link. The site **declares** this address.
- `jsonld` — taken from structured data (`schema.org`).
- `cfemail` — a `mailto:` link that Cloudflare obfuscated, decoded back.
- `text` — found in the page body. Useful, but weaker evidence.
- `guessed` — **not found anywhere on the site.** See below.

In our measurement **87% of addresses and 79% of phone numbers came from
structural markup**, not body text. You can filter on that.

**3. A 404 does not cost you a page.**
The crawl budget counts pages that actually loaded, not requests attempted.
Trying `/contact`, `/kontakt` and `/impressum` on a site that has none is
nearly free — an empty 404 has no body — so a long path list is no longer a
penalty. In our measurement this alone recovered 5 more websites.

**4. It finds the contact page instead of guessing its address.**
Most scrapers try a fixed list of paths (`/contact`, `/about`, …). This one
reads the site's own navigation and follows the contact link it finds. In
our measurement **19 of 67 sites** published their address on a path no
fixed list would have tried — `/pages/contact`, `/outreach`,
`/company/contact`, `/en/contacts`, `/information/contact-us/`, and plain
`/contact/` with a trailing slash.

**5. It does not invent precision.**
A phone number written in national format (`(555) 123-4567`) is returned
exactly as written, with `e164: null` — because converting it without
knowing the country would be a guess. International numbers get a proper
E.164 value.

**6. Share buttons are not social profiles.**
When a website links to its own social accounts, those links are collected
and the noise around them is not: `facebook.com/sharer`,
`twitter.com/intent/tweet`, `pinterest.com/pin/…`, `t.me/s/…` and platform
navigation links are all excluded, so you get the company's actual profile
URL rather than the share widget in the page footer.

**To be clear about what this is:** the Actor reads *your* list of company
websites. It recognises links pointing to twelve social networks and
returns those URLs — it does **not** log into, crawl or scrape any social
platform itself.

***

### Measured coverage — real numbers, not claims

We measured on **two** samples, because the answer depends heavily on what
kind of list you feed it.

**Sample A — real businesses (100 sites).** Restaurants, law firms, dental
practices, accountants, estate agents, ad agencies and architects, taken
from OpenStreetMap entries that publish a website (London and Berlin).
This is what a lead list actually looks like.

**Sample B — a random slice of the web (200 sites).** Drawn systematically
from the Tranco top-1M. Deliberately hard: it contains parked domains,
adult and gambling sites, CDN hosts and things that serve no HTML at all.

| | **A: businesses** | B: random web |
|---|---|---|
| Sites that responded | **84%** | 64% |
| **Contact details found** (of responding) | **82%** | 64% |
| **E-mail address found** (of responding) | **71%** | 53% |
| Phone number found (of responding) | **64%** | 35% |
| With a pattern-guessed address included | **95%** | 91% |
| Blocked by `robots.txt` | **0** | 5 |
| Contact details found (of the whole sample) | **69%** | 41% |

Both were scraped from Apify's datacenter IP with no proxy, 29–30 August
2026, at a platform cost of about **$0.0006 per website**.

We ran sample B four times while developing this. Three consecutive builds
scored 76, 77 and 79 sites — that spread is run-to-run noise, so treat
these numbers as ±2 rather than as decimals. The table reports the **last**
run, not the best one.

**One caveat on sample A, stated plainly:** it is London- and
Berlin-weighted and restaurant-heavy, so it represents Western-European
small business rather than the whole world.

**Why two denominators, stated plainly.** We checked every unreachable
domain against a neutral DNS resolver:

| | |
|---|---|
| Domain resolves, host is alive | **66%** |
| Registered, but no web server at all | 29% |
| Genuinely gone | **5%** |

So the sites we miss are mostly **alive but not serving us** — they block
datacenter traffic or refuse the request — not dead. Measured against every
domain that actually resolves, coverage is **46%** of the whole sample.

A list you supply is normally made of sites you already care about and can
reach, so the "responded" column is the one that matches real use.

**One more honest limit:** about **26%** of the sites we could not reach
from Apify were reachable from an ordinary connection — those sites block
datacenter IP **ranges**. We measured whether Apify's datacenter proxy
helps: across the same 200 sites it recovered **2**. It does not help,
because that pool is datacenter IP space too. Only residential proxies
would, and they cost enough to defeat the point of an HTTP-only scraper.
The proxy input stays available, but it is off by default and we are not
going to tell you it fixes this.

***

### Input

```json
{
  "websites": ["example-company.com", "https://another-company.co.uk"],
  "maxPagesPerSite": 6,
  "verifyEmails": true,
  "includePhones": true,
  "includeSocials": true,
  "includeGuessedEmails": true,
  "onlyWithContact": false
}
```

| Field | Default | What it does |
|---|---|---|
| `websites` | — | URLs or bare domains. Each is reduced to its root. |
| `maxPagesPerSite` | `6` | Contact-intent pages to try: `/`, `/contact`, `/contact-us`, `/about`, `/about-us`, `/impressum`, then `/kontakt`, `/imprint`, `/legal`, … |
| `verifyEmails` | `true` | MX lookup for every address. No extra charge. |
| `includePhones` | `true` | From `tel:` links, JSON-LD, and international-format text. |
| `includeSocials` | `true` | Collect links the site publishes to its own social profiles (12 networks recognised). Share buttons excluded. |
| `includeGuessedEmails` | `true` | When a site publishes nothing, add `info@<domain>` if the domain has mail servers. Marked `method: guessed`. |
| `onlyWithContact` | `false` | Skip records for sites with no e-mail and no phone. |
| `maxSites` | `0` | 0 means no limit. |
| `proxyConfiguration` | none | Optional. |

`maxPagesPerSite` is a **budget, not a list**: the homepage is fetched
first, its contact links are followed next, and the fixed paths fill any
remaining budget. The default of 6 is measured, not guessed.

***

### Output

One record per website — including sites where nothing was found, so your
input list and output list line up.

```json
{
  "input": "https://eff.org",
  "url": "https://eff.org",
  "domain": "eff.org",
  "scrapedAt": "2026-08-28T18:33:23.916Z",
  "emails": [
    {
      "email": "press@eff.org",
      "method": "mailto",
      "sourcePath": "/contact",
      "isRole": false,
      "isFreemail": false,
      "verification": {
        "status": "deliverable_domain",
        "mxFound": true,
        "provider": "Microsoft 365",
        "isDisposable": false,
        "didYouMean": null
      }
    }
  ],
  "phones": [
    {
      "e164": "+15102438020",
      "raw": "+1-510-243-8020",
      "method": "text",
      "sourcePath": "/contact"
    }
  ],
  "socials": [
    { "network": "facebook", "url": "https://www.facebook.com/eff", "sourcePath": "/" }
  ],
  "pagesScanned": 3,
  "requests": 4,
  "skipped": null,
  "hasContact": true,
  "hasGuessedEmail": false,
  "contactFormUrl": null
}
```

#### `contactFormUrl` — when a site publishes no address at all

Some websites deliberately publish no address and only offer a form. For
those, the Actor reports the page where the form lives:

```json
{ "emails": [], "phones": [], "contactFormUrl": "https://example.com/contact" }
```

**This is not a contact detail and is not billed as one.** It does not set
`hasContact`. It exists because "nothing found" is an incomplete answer
when there *is* a way to reach the company — we just cannot turn it into an
address, because the site never published one.

Search boxes, newsletter sign-ups and login forms are excluded.

#### Guessed addresses — what they are, and what they are not

If a website publishes no address anywhere we looked, and its domain has
mail servers, the Actor adds `info@<domain>` with `method: "guessed"` and
sets `hasGuessedEmail: true`.

**This address was not observed on the site.** It is a naming-convention
guess that the domain can at least receive mail. It never sets
`hasContact`, it is billed under its own cheaper event, and you can turn it
off completely with `includeGuessedEmails: false`.

In our measurement it raised coverage from 64% to 91% of responding
sites — but those extra 27 points are guesses, and the output says so on
every single record.

#### `verification.status`

| Value | Meaning |
|---|---|
| `deliverable_domain` | The domain accepts mail. **The mailbox itself was not verified.** |
| `no_mx` | The domain has no mail server. Definitively undeliverable. |
| `disposable` | Temporary / throwaway mail provider. |
| `invalid_syntax` | Not a valid address. |
| `unknown` | The MX lookup failed or was not run. |

**What this Actor will never claim:** that a specific mailbox exists.
SMTP handshakes are not possible from this infrastructure (port 25 is
closed), so mailbox-level verification cannot be performed — and we will
not report a number we cannot measure. Verification here stops at the
domain's mail server, and the field names say so.

#### `skipped`

| Value | Meaning |
|---|---|
| `null` | The site was scraped normally. |
| `robots` | `robots.txt` disallows crawling. **Nothing was requested and nothing was charged.** |
| `budget` | The page or request budget ran out before a contact was found. |
| `unreachable` | The homepage could not be fetched. |

***

### Pricing

Pay per event, with volume tiers.

| Event | When it is charged |
|---|---|
| `site-scraped` | Once per website, if at least one request was made. |
| `contact-found` | Once per website, if at least one e-mail **or** phone number was **found on the site**. |
| `contact-guessed` | Once per website, only when nothing was found and a pattern-guessed address was added instead. Cheaper than `contact-found`. |

A site that disallows crawling in `robots.txt` is **never charged** — no
request was made, so no work was done. A site that responds but has no
contact details is charged only the cheaper `site-scraped` event. A guess
is never billed as a find.

***

### Frequently asked questions

**How many of my websites will return contact details?**
On real business websites we measured **82% of the sites that responded** (69% of the whole
list). On a random slice of the web it drops to 64%, because a random slice contains parked
domains, CDN hosts and sites that publish nothing. Your own list will behave like the first
number, not the second.

**A site I know publishes an e-mail returned nothing. Why?**
Three usual causes. The page renders its contact details with JavaScript, and this Actor is
HTTP-only. The site blocks datacenter traffic — about a quarter of the sites we could not
reach were reachable from an ordinary connection. Or the address is inside an image, which no
text scraper can read.

**Do you verify that the mailbox exists?**
No, and no Actor running on this infrastructure can. Port 25 is closed here, so an SMTP
handshake is impossible. Verification stops at the domain's mail servers, and the status value
says exactly that: `deliverable_domain`, never `deliverable`.

**What is a `guessed` address and can I switch it off?**
When a site publishes nothing anywhere we looked, and its domain has mail servers, the Actor
adds `info@<domain>` marked `method: "guessed"`. It was never observed on the site, it does not
set `hasContact`, and it is billed under a separate cheaper event. Set
`includeGuessedEmails: false` to turn it off entirely.

**Is this Actor affiliated with the websites it scrapes?**
No. It reads public pages only, respects `robots.txt` per path, and never signs in. A site that
disallows crawling is reported with `skipped: robots` and is not charged.

**Can I use it to enrich a CRM export?**
Yes — that is the main use. Feed the domain column, and match the output back on `input`, which
echoes exactly what you passed in. Sites with no contact details still return a row, so the two
lists line up.

### Limits, stated plainly

- **HTTP only.** There is no headless browser, so a contact page that draws
  itself with JavaScript comes back empty. This is also exactly why the
  Actor is cheap and fast — it is a deliberate trade, not an oversight.
- **Datacenter IPs are blocked by some sites.** About a quarter of the sites
  we could not reach were reachable from an ordinary connection. We measured
  whether Apify's datacenter proxy fixes this: across 200 sites it recovered
  **2**. It does not, because that pool is datacenter space too.
- **The mailbox is never verified.** Port 25 is closed on this
  infrastructure, so verification stops at the domain's mail servers. The
  status value says so: `deliverable_domain`, never `deliverable`.
- **An address inside an image cannot be read.** No text scraper can.
- **`robots.txt` is respected** per path, not just per site — and a site
  that disallows crawling is reported with `skipped: robots` and never
  charged.
- **Third-party addresses are filtered out** (comment authors, review
  authors, affiliate links). An address is returned only when it belongs to
  the site, or when the site itself declares it in structural markup.

### Notes

- **Both `www.` and the bare domain are tried**, and `http` as a last
  resort. In our measurement this alone recovered 1 site in 10 that would
  otherwise have been reported unreachable.

# Actor input Schema

## `websites` (type: `array`):

Website URLs or bare domains. Each entry is reduced to its root, so https://example.com/contact and example.com are the same site.

## `maxPagesPerSite` (type: `integer`):

How many contact-intent pages to try per site (/, /contact, /contact-us, /about, /about-us, /impressum, ...).

## `verifyEmails` (type: `boolean`):

Look up MX records for every address found. Included in the base price - this Actor does not sell verification as an add-on.

## `includePhones` (type: `boolean`):

Phone numbers from tel: links, JSON-LD, and international-format text. National-format numbers are returned exactly as written, without guessing a country code.

## `includeSocials` (type: `boolean`):

LinkedIn, X, Facebook, Instagram, YouTube, TikTok and GitHub profile links. Share buttons and platform navigation links are excluded.

## `onlyWithContact` (type: `boolean`):

Skip records for sites where no e-mail address and no phone number was found.

## `includeGuessedEmails` (type: `boolean`):

When a website publishes no address anywhere, add info@<domain> if the domain has mail servers. Clearly marked as method: guessed - it was not observed on the site - and billed separately at a lower rate.

## `maxSites` (type: `integer`):

0 means no limit.

## `proxyConfiguration` (type: `object`):

Optional. This Actor is HTTP-only and works without a proxy.

## Actor input object example

```json
{
  "websites": [
    "apify.com"
  ],
  "maxPagesPerSite": 6,
  "verifyEmails": true,
  "includePhones": true,
  "includeSocials": true,
  "onlyWithContact": false,
  "includeGuessedEmails": true,
  "maxSites": 0,
  "proxyConfiguration": {
    "useApifyProxy": false
  }
}
```

# Actor output Schema

## `results` (type: `string`):

One record per website: e-mail addresses with MX verification, phone numbers, social profiles, and where on the site each one was found.

## `runSummary` (type: `string`):

Counts for the whole run: websites processed, how many returned contact details, and how many e-mail addresses were delivered.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "websites": [
        "apify.com"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("garnet_puppet/website-contact-scraper").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = { "websites": ["apify.com"] }

# Run the Actor and wait for it to finish
run = client.actor("garnet_puppet/website-contact-scraper").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "websites": [
    "apify.com"
  ]
}' |
apify call garnet_puppet/website-contact-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,garnet_puppet/website-contact-scraper"
        }
    }
}

```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/7SXJuMjYj2KQu2ftI/builds/aa8Re9BCVnbK9hGfq/openapi.json
