# Website Contact Email Finder (`datadelight/website-contact-email-finder`) Actor

Give it website URLs; get the contact emails on each site's home and contact pages, with the page each came from. USD 0.00149 per website with a new email, nothing otherwise. New = not yet delivered in this run, or in earlier runs using the same dedupeStoreName. No deliverability check.

- **URL**: https://apify.com/datadelight/website-contact-email-finder.md
- **Developed by:** [Datadelight](https://apify.com/datadelight) (community)
- **Security:** Limited permissions
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

$1.49 / 1,000 website with a new emails

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

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

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
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.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## 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.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Website Contact Email Finder

Give it a list of website addresses. For each website it reads the home page and a few contact-style pages on that
same site and returns the email addresses written there, with the page each one came from so you can open it and
check. **You are charged only for a website where a new contact email for that business was found.** A run that finds nothing costs
nothing to run.

It works on websites you already have. It does not search Google Maps, it does not read social networks, and it
does not check whether an address accepts mail.

### What you get

One row per website with a new email:

```json
{
  "website": "https://acme-plumbing.example",
  "domain": "acme-plumbing.example",
  "finalUrl": "https://acme-plumbing.example/",
  "status": "email_found",
  "reason": null,
  "primaryEmail": "info@acme-plumbing.example",
  "emails": [
    { "email": "info@acme-plumbing.example", "foundOn": "https://acme-plumbing.example/", "source": "mailto", "onSiteDomain": true, "relation": "own_domain" },
    { "email": "sales@acme-plumbing.example", "foundOn": "https://acme-plumbing.example/contact", "source": "text", "onSiteDomain": true, "relation": "own_domain" },
    { "email": "accounts@acme-plumbing.example", "foundOn": "https://acme-plumbing.example/contact", "source": "obfuscated", "onSiteDomain": true, "relation": "own_domain" }
  ],
  "otherEmails": [],
  "pagesFetched": 3,
  "crawledAt": "2026-11-01T12:00:00Z"
}
```

`emails` holds the addresses you pay for. Each says why it counts in `relation`:

- `own_domain`: on the website's own domain (or a parent or subdomain of it).
- `structured_contact`: an `email` or `ContactPoint` in the site's own JSON-LD, even on another domain (a diocese's
  admissions office).
- `labelled_contact`: written on the page next to words that label it as a way to reach the business ("email Joe at
  ...", "Reception: ...", "Bookings: ..."), even on another domain (a business Gmail, a parent company).
- `mailto_webmail`: a mail link to a consumer mailbox (Gmail, Outlook, Yahoo ...) that is not a credit line.
- `contact_page`: unlabelled text in the main content (not header, footer or navigation) of one of the contact pages
  read.

`onSiteDomain` is false for the others. The rest go to `otherEmails` with a `reason`, are not charged and are not
remembered: `credit_line` (words like "Website by", "Template by", "Powered by", "Designed by" just before the
address, so a web agency, theme author or developer is never your contact) and `no_contact_context` (another
domain, nothing says it is the business's contact: an unlabelled address in a quote or a footer). A website whose
only addresses are of those kinds has status `only_other_domain_emails` and is saved only when `includeEmptyRows`
is on. Addresses of website builders, hosting platforms and standards bodies (for example `support@wix.com`) are
dropped. This is a judgement from the words around an address, not a guarantee: a contact in a footer with no label
is not billed, and a label near an address that is not the business's can still bill it (an agency's address after its own "Email" line is not billed when its domain name matches the studio just credited, in the same block or in the block just after the credit, in either word order and ignoring a second-level suffix (`co.uk`, `me.uk`, `ltd.uk`, `plc.uk`, `sch.uk`, `com.au`, `id.au`, `asn.au`, `geek.nz`, `uk.com`, `web.id`, `asso.fr`; a listed set, extended by the rule that for any other domain the label before its last two is compared as well, so an unlisted one- or two-label tail such as `.xyz.example` is covered but a three-label suffix such as `k12.ca.us` is not), or the hosting service for a tenant host (`lena-studio` of `lena-studio.github.io`, `lena-studio.workers.dev`), but a credited name that does not match its domain, or an address further from the credit, is judged by the label). An address on a tenant host of a shared hosting service (`name.herokuapp.com`, `name.github.io`) is judged by its context like any other address: an explicit label, structured contact data or a contact page can bill it, an unlabelled mention or a credit line cannot, and a bare suffix such as `info@github.io` or `info@me.uk` has no mailboxes and never bills. Addresses at site-builder and platform domains (`wix.com`, `wixsite.com`, `myshopify.com`, `shopify.com`, `weebly.com`, `webflow.com`, `wordpress.com`) are dropped without a row or detail, even when the website itself is hosted there: those domains do not host mailboxes, so a shop on `name.myshopify.com` that lists `owner@name.myshopify.com` is reported as having no emails. This is a words heuristic over the page text; it does not check that a mailbox exists, receives mail or belongs to the business (no SMTP, MX or ownership verification). Check `foundOn`.

`source` says how the address was found: `mailto` (a mail link), `text` (written on the page), `structured_data`
(JSON-LD), `cloudflare_protected` (Cloudflare's "\[email protected]" cloak, decoded on the page), `obfuscated`
(written as `name [at] domain [dot] com`).

The key-value store record `OUTPUT` summarises the run: websites given, invalid, duplicated, skipped as already
seen, crawled, with emails, without, unreachable (by reason), pages fetched and the number of billable events the
actor asked the platform to charge. That last number equals the number of saved rows.

### Input

| Field | Default | Meaning |
| --- | --- | --- |
| `websites` | required | Website URLs (or bare domains). One entry per website. |
| `maxResults` | 100 | Stop after this many websites with new emails. Also your spending cap for the run. |
| `maxPagesPerWebsite` | 3 | Home page plus up to this many minus one contact, about, imprint (impressum), team or support pages on the same site (1 to 8). |
| `maxEmailsPerWebsite` | 5 | Emails kept per website, best first (1 to 20). |
| `dedupeStoreName` | empty | A key-value store name. Websites and emails delivered in earlier runs with the same name are skipped and not charged again. |
| `includeEmptyRows` | false | Also save a row, never charged, for websites with no new email, with status and reason. |
| `respectRobotsTxt` | true | Skip pages robots.txt disallows. |
| `maxConcurrency`, `requestTimeoutSecs`, `siteTimeoutSecs` | 10, 15, 60 | Parallel websites, per-page wait, per-website limit. |

Duplicates are handled for you: the same website given twice (with or without `www.`, `http` or `https`, a trailing
slash or tracking parameters) is checked once, and an email already delivered, in this run or in an earlier run that
used the same `dedupeStoreName`, is not delivered or charged again.

#### If a run is interrupted

Before each billable row is saved and charged, the actor writes a "pending" record of that website to a key-value
store, and marks it done afterwards. The store is your `dedupeStoreName` store, or, with no name, the run's own default
store (which survives a platform restart of the same run, but not a new run). If the run dies in between (killed,
out of memory, a charge response that never arrived), the next start treats that website as **possibly delivered**: it
is not fetched, delivered or charged again, and `OUTPUT` lists it under `possiblyDelivered`. The cost of that safety
is that such a website may be missing from your dataset (the run died before saving it) or saved but not charged.
Check your dataset for it; to retry it deliberately, run it with a different `dedupeStoreName`. The Apify SDK gives no
idempotency key for saving and charging a row together, so this protection comes from the actor's own records, not the
platform; whether the platform ever charged a row whose response was lost is not something the actor can read. Do not
run two runs on one `dedupeStoreName` at the same time. Without a `dedupeStoreName`, separate runs do not remember each
other.

### What is charged

One event, `website-with-email`, per row saved with at least one contact email you have not been given before (see `relation` above).
Nothing is charged for a website with no email, a website whose only addresses are credit lines (web agency, site builder, template author, website credit) or unlabelled addresses on another domain, an unreachable website, a duplicate website, a website whose emails were all
delivered before, or a run that finds nothing. Your **maximum total charge** setting for the run is respected: the
actor stops instead of exceeding it.

### What it will not do

- **No Google Maps, no search.** It does not find businesses. Bring the websites (from your own list or another
  tool).
- **No social networks and no logged-in pages.** Public web pages only.
- **No JavaScript rendering.** An address that only appears after a script runs is not found. Plain HTML pages,
  including mail links, text, JSON-LD and Cloudflare-cloaked addresses, are read.
- **No deliverability checks.** An address is returned if it is written on the page and is well formed. It may
  bounce, belong to a person who left, or be a role address (`info@`).
- **Not every site has an email.** Many publish a form only; those return no row. A site whose only visible address
  is an unlabelled one on another domain returns no billable row (it is in `otherEmails` when `includeEmptyRows` is on).
- It only fetches `http` and `https` pages on ports 80 and 443. Addresses with a username or password, private or
  internal network addresses, and links to files are refused. It sends no cookies, uses no proxies and identifies
  itself as `WebsiteContactEmailFinder`. A page is read up to 1.5 MB; compressed responses (gzip, including several members, and deflate) are decoded with the
  same limit, and a page that cannot be decoded is reported as unreachable and not charged. Pages are read in one pass
  without building a document tree and scanned in time proportional to their size, so a huge or hostile page costs the
  same as any other of that size. If a site's `robots.txt` cannot be read (server error or connection failure) the site
  is skipped as `robots_unavailable`, as RFC 9309 requires, and its row says why in `reasonDetail` (for example
  `connection_error: ConnectError` (refused or unreachable), `timeout: ReadTimeout` or `timeout: ConnectTimeout`, `tls_error: SSLCertVerificationError` (untrusted certificate), `http_error: 503`; never a URL,
  address or credential); `robots_disallowed` means its rules forbid it. A home-page link is followed as a contact page
  by its address or by its visible text ("Get in touch", "Reach us", "Contáctanos", ...), within the page budget, and only on the same site.

### Using it responsibly

Emails on a public page are still personal data when they belong to a person. You are responsible for lawful use,
including your obligations when you contact people (for example PECR and GDPR in the UK and EU, CAN-SPAM in the
US). The actor does not store the emails it finds anywhere except in your run's dataset; the optional remember
store holds only hashes of delivered emails and the domains of delivered websites.

### Run it locally

```bash
uv sync
uv run pytest            # offline, fixtures/ and a local SDK simulation only; takes about a minute
apify run                # needs the Apify CLI; put the input in storage/key_value_stores/default/INPUT.json
```

# Actor input Schema

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

Websites to check, one per entry (for example https://example-bakery.com). A bare domain such as example-bakery.com also works. Only http and https pages on ports 80 and 443 are fetched; addresses with a username or password, private or internal addresses, and non-web links are refused and counted in the OUTPUT summary. Two entries for the same website (www or not, http or https, with or without tracking parameters) are checked once.

## `maxResults` (type: `integer`):

The run stops once this many websites have been saved with a new email. This is also the most you can be charged for in one run.

## `maxPagesPerWebsite` (type: `integer`):

Most pages fetched per website, including the home page. The rest are same-site links whose address or link text names contact, about, imprint (impressum), team or support. 1 reads only the home page.

## `maxEmailsPerWebsite` (type: `integer`):

Most emails kept for one website, best first: addresses on the website's own domain, then by how they were found (mailto link, page text, structured data, protected, written out as 'name \[at] domain').

## `dedupeStoreName` (type: `string`):

Optional. Name of a key-value store in your account (3 to 63 lowercase letters, digits and hyphens). Websites already delivered in earlier runs that used the same name are skipped and never charged again, and so are emails already delivered. Emails are stored as hashes, websites as domains. Leave empty to de-duplicate within this run only.

## `includeEmptyRows` (type: `boolean`):

Adds a row, never charged, for every checked website that had no new email, with its status and reason. The OUTPUT summary counts them either way.

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

Skips pages a site's robots.txt disallows for crawlers in general. A robots.txt that cannot be read because the server errors counts as disallowing the whole site, as the standard says. Switch off only for sites you are entitled to read this way.

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

Websites are checked in parallel; pages of one website are fetched one after another.

## `requestTimeoutSecs` (type: `integer`):

How long to wait for one page before giving up on it.

## `siteTimeoutSecs` (type: `integer`):

Longest time spent on one website, all its pages included.

## Actor input object example

```json
{
  "websites": [
    {
      "url": "https://apify.com"
    }
  ],
  "maxResults": 100,
  "maxPagesPerWebsite": 3,
  "maxEmailsPerWebsite": 5,
  "includeEmptyRows": false,
  "respectRobotsTxt": true,
  "maxConcurrency": 10,
  "requestTimeoutSecs": 15,
  "siteTimeoutSecs": 60
}
```

# Actor output Schema

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

Dataset rows, one per website: status, best email, every kept contact email with the page it came from, and the addresses that were not charged for.

## `summary` (type: `string`):

The OUTPUT record: website counts, failure reasons, remembered websites and emails, and billing.eventsCharged.

# 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": [
        {
            "url": "https://apify.com"
        }
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("datadelight/website-contact-email-finder").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": [{ "url": "https://apify.com" }] }

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

```

## MCP server setup

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

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/HJXo6GFVFDjOVRg4I/builds/hfLZKgbVpNIBLfP2Q/openapi.json
