# Website Contact & Email Extractor — Verified Emails + Socials (`vonsensey/website-contact-email-extractor`) Actor

Turn a list of website URLs into clean CRM rows: verified emails (MX-checked, role-tagged), E.164 phones, and social profiles — one de-duplicated row per company. You pay only per company we find a real contact for; empty and failed sites are free. Junk addresses and numbers are filtered out.

- **URL**: https://apify.com/vonsensey/website-contact-email-extractor.md
- **Developed by:** [Blackcube Agency AB](https://apify.com/vonsensey) (community)
- **Categories:** Lead generation, Social media, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $8.00 / 1,000 contact records

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/platform/actors/running/actors-in-store#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

## Website Contact & Email Extractor — Verified Emails + Socials

**Feed it a list of websites. Get back one clean CRM row per company — verified emails, phone numbers, and social profiles.**

Point it at any list of company websites (a Google Maps export, a Shopify store list, a jobs-board scrape, or a CSV your sales team already has) and it returns a deduplicated contact record for each one: every email it can find, MX-verified and tagged by role, phone numbers normalized to E.164, and canonical LinkedIn / X / Facebook / Instagram / YouTube / TikTok / GitHub profile URLs.

> **You pay per company we actually reach a contact for.** Not per page crawled. A website that returns nothing, times out, or no longer exists costs you **nothing** — it still appears in your results with a reason, so gaps in your list stay auditable. A record has to carry a real **email or phone number** to be billable: if all we could find was a social profile link, you still get the record, **free**.

> **Unofficial — not affiliated with, endorsed by, or connected to any of the websites it visits.** It reads only data those sites already publish publicly to any browser.

***

### Why this one

#### 1. Predictable billing — per company, not per page

Most contact scrapers bill **per page crawled**, so a headline like "$2 per 1,000" turns into a much larger invoice once a single site with deep navigation gets crawled for hundreds of pages. That mismatch is the most common complaint in this category.

Here, the unit you are billed for is the unit you care about: **one company record**. A company whose contact details take 12 pages to find costs exactly the same as one that gives them up on the homepage. And because the total page budget is bounded by `websites × maxPagesPerDomain` before the run even starts, a runaway crawl is **structurally impossible**.

| | This actor | Typical per-page scrapers |
|---|---|---|
| Billing unit | **1 company record** | 1 page crawled |
| Website with no contacts | **Free** | Charged for every page |
| Dead / timed-out domain | **Free**, with a reason | Charged for the attempt |
| Record with no email or phone | **Free** | Charged as a result |
| Cost of a deep site | **Unchanged** | Multiplies without warning |
| Email verification | **Included** | Usually a paid add-on |

#### 2. Clean data — the junk is filtered out, not shipped

Regex-only extractors are notorious for reporting things like `//cdn.jsdelivr.net/npm/bootstrap@5.2.3` as an email address, and for emitting "uncertain" phone columns full of digit noise like `035500000000002`. This actor validates before it delivers:

- **Emails** are checked against the public suffix list, so image filenames (`logo@2x.png`), CDN package specs (`bootstrap@5.2.3`), Sentry DSNs, build hashes, and placeholder addresses (`you@example.com`, `firstname.lastname@…`) never reach your dataset.
- **Phone numbers** are parsed with Google's libphonenumber and returned only if they are genuinely valid, normalized to E.164 (`+14155550132`). Order numbers, dates, and prices are dropped. There is no "uncertain" column, because uncertain data is not a deliverable.
- **Social links** are canonicalized to real profile URLs; share and intent widgets (`facebook.com/sharer`, `twitter.com/intent/tweet`) are discarded.

#### 3. It finds addresses other scrapers miss

Contact details are deliberately hidden from naive scrapers. This one reads all of it:

- `mailto:` links and **Cloudflare-obfuscated** addresses (`data-cfemail` / `/cdn-cgi/l/email-protection`, XOR-decoded)
- **HTML-entity obfuscation** (`info&#64;acme&#46;com`) and bracketed forms (`info [at] acme [dot] com`)
- **JSON-LD structured data** — `Organization`, `LocalBusiness`, `ContactPoint`, and `sameAs` blocks, which carry the authoritative company name, email, phone, and postal address
- Plain text and footers

And it looks in the right *places*. With a limited page budget, **which** pages get crawled decides whether you get a decision-maker's address or nothing, so pages are ranked before fetching: legally-mandated imprint pages first (`/impressum`, `/mentions-legales` — in the EU these always carry a real address), then contact, about, team, support, press. Non-English paths are recognized, so a German or French site does not come back empty.

#### 4. Verified emails, honestly described

Every address is checked and labelled — **included in the per-record price, not a paid add-on**:

| `status` | Meaning |
|---|---|
| `valid` | The domain publishes a working mail exchanger and the address is well-formed. |
| `invalid` | The domain has no mail exchanger, or the address is malformed. It will bounce. |
| `disposable` | A throwaway-inbox provider (mailinator, guerrillamail, …). Worthless as a lead. |
| `risky` | Real, but lower confidence: a free consumer provider (gmail, outlook) or a shared role inbox (`info@`, `support@`). |
| `unknown` | The DNS lookup itself failed. Never reported as invalid — you are not told an address is dead when we simply could not check. |

**What this check is:** syntax validation, MX/DNS resolution, and disposable / free-provider / role-address classification.
**What it is not:** a per-mailbox SMTP probe. Apify's infrastructure blocks outbound port 25, so *no* actor running natively on the platform can perform a true RCPT-TO handshake — and even where it is possible, Gmail, Outlook, and Yahoo tarpit those probes from datacenter IPs. Tools that advertise "SMTP verified" from inside Apify are describing a check they cannot run. This one tells you exactly what it did, and the `reason` field on every result says why it reached that status.

#### 5. Role tagging and decision-maker filtering

Every email is classified: `executive` (founder, CEO, owner), `personal` (a named individual), `sales`, `marketing`, `support`, `hr`, `finance`, `legal`, `info` (generic inbox), or `other`. Use **Only these contact roles** to keep just the ones you want, or flip **Decision-makers only** to drop every generic inbox in one click.

Each record also carries a **`primaryEmail`** — the single best contact, picked by ranking the company's own domain above third-party addresses, then executives above named people above sales above generic inboxes. When you need exactly one address per company for a mail merge, that field is it.

#### 6. One row per company, already deduplicated

Paste `acme.io`, `https://www.acme.io/`, and `https://acme.io/pricing?utm_source=news` and you get **one** record, charged **once**. Deduplication happens by registrable domain, across the whole run, on both input and output. No second "merge & deduplicate" actor required.

***

### Input

| Field | Type | Default | What it does |
|---|---|---|---|
| **startUrls** | array | — | The company websites. Full URLs or bare domains both work. Several URLs for one company collapse into one row. |
| **domains** | array of strings | *(empty)* | Alternative plain-text list of domains, merged with the above. |
| **maxPagesPerDomain** | integer | `10` | Hard cap on pages per company (1–50). Total run pages can never exceed `websites × this`. |
| **verifyEmails** | boolean | `true` | Run the MX/DNS deliverability check. Included in the price. |
| **roleFilter** | array | *(empty)* | Keep only these roles. Empty returns every address. |
| **excludeGenericEmails** | boolean | `false` | Decision-makers only: keep `executive` and `personal`, drop every generic inbox. |
| **skipDomainsWithoutContacts** | boolean | `false` | Omit the free "nothing found" rows entirely. They are never charged either way. |
| **respectRobotsTxt** | boolean | `false` | Skip pages disallowed by robots.txt. Off by default, because many sites disallow crawlers wholesale for pages any browser can open. |
| **maxConcurrency** | integer | `20` | Pages fetched in parallel across all websites. |
| **proxyConfiguration** | object | Apify Proxy (auto) | Automatic selection picks a datacenter IP — enough for ordinary company sites, and what keeps the price low. Switch to residential only for hardened targets. |

***

### Output

One JSON record per company. See [`.actor/sample-output.json`](.actor/sample-output.json) for complete records from a real run.

```jsonc
// abridged from a real run — see .actor/sample-output.json for full records
{
  "domain": "fastmail.com",
  "companyName": "Fastmail",
  "primaryEmail": "press@fastmail.com",
  "emails": [
    {
      "address": "press@fastmail.com",
      "role": "marketing",
      "foundOn": "https://www.fastmail.com/company/about/",
      "verification": {
        "status": "risky",
        "hasMx": true,
        "isDisposable": false,
        "isFreeProvider": false,
        "isRoleAddress": true,
        "reason": "roleAddress"
      }
    }
  ],
  "phones": ["+61294750859"],
  "socials": {
    "linkedin": "https://www.linkedin.com/company/fastmail",
    "twitter": "https://x.com/Fastmail",
    "facebook": "https://www.facebook.com/Fastmail",
    "github": "https://github.com/fastmail"
  },
  "emailCount": 5,
  "phoneCount": 1,
  "pagesCrawled": 8,
  "errorReason": null
}
```

| Field | Type | Notes |
|---|---|---|
| `domain` | string | Registrable domain. The dedup key **and** the billing unit. |
| `startUrl`, `inputUrls` | string, array | Where the crawl began, and every input line that mapped to this company. |
| `companyName`, `description`, `address` | string | null | From JSON-LD, `og:site_name`, or the page title/meta. |
| `primaryEmail` | string | null | The single best contact. Identical to `emails[0].address`. |
| `emails` | array | `{ address, role, foundOn, verification }`, best contact first. |
| `phones` | array | Valid numbers only, E.164. |
| `socials` | object | Canonical profile URLs: `linkedin`, `twitter`, `facebook`, `instagram`, `youtube`, `tiktok`, `github`. |
| `emailCount`, `phoneCount` | integer | For filtering a CSV export without parsing arrays. |
| `pagesCrawled`, `crawledUrls` | integer, array | Exactly which pages were fetched, in order. |
| `scrapedAt` | string | ISO-8601. |
| `errorReason`, `errorMessage` | string | null | `null` on a billed record; set on a free row. |

#### Dataset views

**Contacts** and **Skipped & failed (free)**. Views project columns but cannot filter rows, so both kinds of row appear in both views with the other's columns blank. To separate them in code, split on `errorReason`: it is `null` on a contact record and set on a free row.

***

### Why a website returns no contacts

Every one of these is a **free** row — never billed — and tells you what happened, so a gap in your lead list is explained rather than silent.

| `errorReason` | Meaning |
|---|---|
| `noContactsFound` | Crawled fine, but the site publishes no contact details (or your filters removed them all). |
| `domainNotResolved` | The domain does not resolve — expired, parked, or mistyped. |
| `connectionFailed` | Connection refused, or an unusable TLS certificate. |
| `timeout` | The site did not respond in time. |
| `notFound` | The start URL returned 404/410. |
| `accessDenied` | The site refused the request (401/403). |
| `serverError` | The site returned a 5xx. |
| `blocked` | Rate-limited or stopped by bot protection. Try residential proxies. |
| `invalidInput` | That input line is not a usable website URL or domain. |
| `unknown` | Something else; `errorMessage` carries the detail. |

***

### Tips for better results

- **Raise `maxPagesPerDomain` for stubborn sites.** The default of 10 is tuned for cost. Large corporate sites that bury contact details behind several navigation levels do better at 20–30. The cost per company does not change.
- **Use `excludeGenericEmails` for outreach, leave it off for coverage.** Generic inboxes are often the *only* published address for small businesses.
- **Switch to residential proxies** only if you see `blocked` rows. Datacenter is cheaper and sufficient for the large majority of company websites.
- **Chain it after any list-building actor.** Google Maps, Shopify, jobs boards, directories — anything that produces website URLs feeds straight in.
- **Raise the run timeout for very large lists.** The default is 1 hour, which comfortably covers roughly 1,500–2,000 websites at the default page budget. Results are written and billed **per company as each one finishes**, so even if a run does hit its timeout you keep every company already completed — you never lose (or pay twice for) finished work.

***

### Legal & responsible use

This actor collects **only publicly published business contact information** — the addresses and numbers companies put on their own websites specifically so customers can reach them. It does not log in, bypass authentication, or touch private data.

Contact data about identifiable people is personal data under the GDPR and similar laws. **You** are the controller for how you use it: you are responsible for having a lawful basis (commonly legitimate interest for B2B outreach), for honoring opt-outs and suppression lists, and for meeting the disclosure and marketing rules in your recipients' jurisdictions. The `role` and `isRoleAddress` fields exist partly to help you target shared business inboxes rather than individuals where that is the safer choice.

***

*Unofficial project. Uses only publicly available data. Not affiliated with any website it visits.*

# Actor input Schema

## `startUrls` (type: `array`):

The company websites to extract contacts from. Paste full URLs or bare domains — both work. Several URLs for the same company collapse into ONE result row, and you are charged once for it.

## `domains` (type: `array`):

Optional alternative to the field above for pasting a plain list of domains (e.g. acme.io, berg.se). Merged with Websites and de-duplicated by company domain.

## `maxPagesPerDomain` (type: `integer`):

Hard cap on pages crawled per company. The crawler spends this budget on the highest-value pages first (imprint, contact, about, team) rather than crawling blindly, so a low number still finds the good addresses. Total pages for the run can never exceed websites × this number — runaway crawls are impossible.

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

Check every email's domain for a working mail exchanger (MX/DNS) and flag disposable, free-provider, and shared role inboxes. Included in the per-result price — there is no extra charge. Note: this is a domain-level deliverability check, not a per-mailbox SMTP probe.

## `roleFilter` (type: `array`):

Keep only emails classified as these roles. Leave empty to return every address. A company whose contacts are all filtered out becomes a free, unbilled row.

## `excludeGenericEmails` (type: `boolean`):

Shortcut that keeps only executive and named-person addresses, dropping every generic inbox (info@, support@, noreply@). Overrides the role list above.

## `skipDomainsWithoutContacts` (type: `boolean`):

By default a website with no contacts still returns a free row explaining why (dead domain, blocked, nothing published), which makes gaps in your list auditable. Turn this on to omit those rows entirely. Either way, they are never charged.

## `respectRobotsTxt` (type: `boolean`):

Skip pages disallowed by each site's robots.txt. Off by default because many sites disallow crawlers wholesale, which would return no contacts for pages that are publicly visible in any browser.

## `maxConcurrency` (type: `integer`):

How many pages to fetch in parallel across all websites. Lower it if you are crawling a small number of sites and want to be gentler on them.

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

Apify Proxy on automatic selection is the default: it picks a datacenter IP, which is enough for ordinary company websites and is what keeps the price low. It also works on every Apify plan — pinning a specific proxy group would fail for accounts that do not have it. Switch to residential only if you see 'blocked' rows from sites behind aggressive bot protection.

## Actor input object example

```json
{
  "startUrls": [
    {
      "url": "https://apify.com"
    },
    {
      "url": "https://www.ycombinator.com"
    }
  ],
  "maxPagesPerDomain": 10,
  "verifyEmails": true,
  "roleFilter": [],
  "excludeGenericEmails": false,
  "skipDomainsWithoutContacts": false,
  "respectRobotsTxt": false,
  "maxConcurrency": 20,
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}
```

# Actor output Schema

## `contacts` (type: `string`):

No description

# 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 = {
    "startUrls": [
        {
            "url": "https://apify.com"
        },
        {
            "url": "https://www.ycombinator.com"
        }
    ],
    "proxyConfiguration": {
        "useApifyProxy": true
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("vonsensey/website-contact-email-extractor").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 = {
    "startUrls": [
        { "url": "https://apify.com" },
        { "url": "https://www.ycombinator.com" },
    ],
    "proxyConfiguration": { "useApifyProxy": True },
}

# Run the Actor and wait for it to finish
run = client.actor("vonsensey/website-contact-email-extractor").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 '{
  "startUrls": [
    {
      "url": "https://apify.com"
    },
    {
      "url": "https://www.ycombinator.com"
    }
  ],
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}' |
apify call vonsensey/website-contact-email-extractor --silent --output-dataset

```

## MCP server setup

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

```

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/OVAQRdJrL0MLbQbyu/builds/1bNbl9O12jbKngJR0/openapi.json
