# Website Contact Finder — Pay only for sites with contacts (`sumitr_mardy/website-contact-finder`) Actor

Find emails, phones and social profiles on any list of websites. One clean row per domain, with the source page for every contact. You pay only for websites where a contact is found — blocked, dead or empty sites are free.

- **URL**: https://apify.com/sumitr_mardy/website-contact-finder.md
- **Developed by:** [Sumitr Mardy](https://apify.com/sumitr_mardy) (community)
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 1 bookmarks
- **User rating**: No ratings yet

## Pricing

from $2.50 / 1,000 website with contacts 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?

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 Finder — pay only for sites with contacts

Give it a list of domains. Get back **one row per domain** with the emails,
phone numbers and social profiles published on that website, plus the business's
own structured data when it has any.

**You are charged only for domains where a contact was actually found.** A domain
that is blocked, dead, or simply publishes no contact details costs you nothing.

***

### Why that one sentence matters

Every contact scraper hits the same ceiling: roughly a third of small-business
websites publish an email address, and a third of them sit behind a bot wall.
That is not a quality difference between tools — it is the web.

The difference is **what you pay for the misses.** The usual model bills per page
opened, so a domain that turns out to be empty still costs you three or four
pages. This actor bills per domain that yields a contact.

Measured on the same 100 random SME domains, same day:

| | pages billed | domains with a contact | you pay for |
|---|---|---|---|
| per-page scraper | 336 | 72 | **336 pages** |
| this actor | — | 74 | **74 contacts** |

Cost per contact found, at an equal page budget:

| | Free | Gold |
|---|---|---|
| per-page scraper ($2.00 → $1.05 / 1k pages) | $0.0087 | $0.0046 |
| **this actor** | **$0.0060** | **$0.0030** |

About **1.5× cheaper per contact**, and the gap is wider on lists with a lot of
dead or protected domains, because those are free here.

### What you get per domain

```jsonc
{
  "domain": "mud-dock.co.uk",
  "url": "https://www.mud-dock.co.uk/",
  "status": "contacts_found",
  "sub_status": "email_and_phone",
  "emails": [
    {
      "email": "cafe@mud-dock.co.uk",
      "type": "generic",              // generic | personal | unknown
      "is_free_provider": false,      // gmail/yahoo/... 
      "on_site_domain": true,         // on the domain we crawled
      "source_url": "https://www.mud-dock.co.uk/contact-us/"
    }
  ],
  "phones": [
    {
      "e164": "+441179292151",        // normalised, dialable
      "raw": "0117 929 2151",
      "country": "GB",
      "confidence": "high",           // high = tel: link or structured data
      "source_url": "https://www.mud-dock.co.uk/"
    }
  ],
  "socials": [{ "network": "facebook", "url": "https://www.facebook.com/muddock" }],
  "organization": {                   // from schema.org data on the page
    "name": "Mud Dock Cycleworks", "legal_name": "Mud Dock Ltd",
    "street": "40 The Grove", "city": "Bristol", "region": "",
    "postal_code": "BS1 4RB", "country": "GB", "type": "LocalBusiness"
  },
  "checks": { "dns_resolved": true, "homepage_ok": true, "bot_challenge": false,
              "json_ld_found": true, "mailto_found": true,
              "obfuscated_email_decoded": false, "tel_link_found": true,
              "contact_page_reached": true, "email_on_site_domain": true,
              "has_contact_form": true },
  "pages": ["https://www.mud-dock.co.uk/", "https://www.mud-dock.co.uk/contact-us/"],
  "pages_crawled": 2,
  "found_at": "2026-10-19T08:00:00.000Z",
  "duration_ms": 1840
}
```

#### The schema is a contract

- Exactly **one row per input domain**, including domains that returned nothing.
  Join on your input list without wondering which rows went missing.
- `status` and `sub_status` are **never null and never empty**.
- `emails`, `phones`, `socials` and `pages` are **always arrays**.
- `organization` **always has all 8 string keys**; unknown fields are `""`.
- `checks` **always has all 10 boolean keys**; a check that was never reached is
  `false`, never null. The reason lives in `sub_status`.

#### Status values

| `status` | `sub_status` | meaning |
|---|---|---|
| `contacts_found` | `email_and_phone` / `email_only` / `phone_only` | **charged** |
| `no_contacts` | `no_contact_published` | pages read fine, the site publishes no address or number |
| `blocked` | `bot_challenge` / `http_error` | a bot wall or a 4xx answered us. Worth retrying later |
| `unreachable` | `dns_error` / `tls_error` / `timeout` | domain is dead, mis-configured, or too slow |
| `unreachable` | `internal_error` | our fault; `error` says what happened |

Only `contacts_found` is ever charged.

### How it finds things

1. **Homepage first.** On the measured sample it carried most of the finds on its
   own, so most domains cost one page.
2. **Then the site's own contact links**, then `/contact`, `/contact-us`,
   `/about`, `/about-us`, `/team`, `/impressum`.
3. **Crawling stops as soon as both an email and a phone number are found.**
4. **schema.org/JSON-LD is read first** where present — an address the owner put
   there for machines is better than one scraped out of a footer.
5. **Obfuscated addresses are decoded**: Cloudflare's `data-cfemail`, HTML
   entities, and `name [at] example [dot] com`.
6. **Phone numbers are normalised to E.164** with the country taken from
   structured data, then the ccTLD, then your `defaultCountry`. Company and VAT
   numbers are rejected by validation rather than by guesswork.
7. **Apify Proxy (datacenter) is on by default.** Six domains in the 100-domain
   sample answered a direct connection with 403 or nothing and were fine through
   rotating IPs. A blocked page is retried once on a fresh IP.

### What it does *not* do

- **No headless browser.** Measured: exactly one domain in 100 was a JavaScript
  shell with contact details only in the rendered DOM. A browser would multiply
  the cost to fix one row in a hundred.
- **No guessing.** If a site publishes no address, you get `no_contacts`, not
  `info@` invented from the domain.
- **No person-level enrichment.** This reads what the website publishes.

### Expected yield, stated honestly

On 100 random SME domains (shops, trades and offices in the US and UK, sampled
from OpenStreetMap), measured 2026-10-19 on Apify with the datacenter proxy on:

```
a contact (email or phone) : 74%
an email address           : 50%
a phone number             : 64%
a social profile           : 74%
publishes no contact at all: 15%
blocked by a bot wall      :  8%
dead or unreachable        :  3%
pages read                 : 3.63 per domain
```

Run side by side with the leading per-page scraper over the same 100 domains on
the same day:

| | email | contact (email or phone) |
|---|---|---|
| per-page scraper | 37 | 72 |
| **this actor** | **50** | **74** |

Paired, that is 18 domains where only this actor found an address against 5 the
other way. Roughly a third of the remaining misses are sites that publish no
address anywhere, which no scraper can fix.

### Input

| field | default | notes |
|---|---|---|
| `domains` | — | domains or URLs; duplicates on the same host collapse to one row and one charge |
| `startUrls` | — | the same thing in Apify's URL-list format, for chaining |
| `concurrency` | 15 | cost follows total runtime, so raising this does not raise the bill |
| `maxPagesPerDomain` | 5 | a budget, not a target; crawling stops early on success |
| `timeoutMs` | 8000 | a slow host is reported, not waited on |
| `useProxy` | true | turn it off and expect more `blocked` rows |
| `allowInsecureTls` | true | small business sites let certificates expire; only public pages are read and nothing is ever submitted |
| `defaultCountry` | `US` | used to read local-format phone numbers when the site's country cannot be determined |

### Verifying the emails

Addresses found on a website are not proof that the mailbox exists. To filter
bounces, pass the output through
[Email Verification API](https://apify.com/sumitr_mardy/email-verifier) — SMTP,
MX and honest catch-all detection, with the same no-null output contract.

### Use it from AI agents (MCP)

This Actor is a tool on the [Apify MCP server](https://docs.apify.com/platform/integrations/mcp), so Claude, Cursor, VS Code or any MCP client can call it. Load only the tools you need: a short tool list keeps the agent from picking the wrong one.

**Recommended: hosted server, OAuth sign-in (nothing secret on disk).** This config loads the contact finder together with the email verifier, so an agent can find contacts and then check them in one conversation:

```json
{
  "mcpServers": {
    "contact-finder": {
      "url": "https://mcp.apify.com?tools=sumitr_mardy/website-contact-finder,sumitr_mardy/email-verifier"
    }
  }
}
```

**With an API token instead** (clients without OAuth, or unattended setups), add
`"headers": { "Authorization": "Bearer YOUR_APIFY_TOKEN" }` next to `url`.

**Local server (stdio):**

```json
{
  "mcpServers": {
    "contact-finder": {
      "command": "npx",
      "args": ["-y", "@apify/actors-mcp-server@latest", "--tools", "sumitr_mardy/website-contact-finder"],
      "env": { "APIFY_TOKEN": "YOUR_APIFY_TOKEN" }
    }
  }
}
```

Get a token at [Apify Console → API & Integrations](https://console.apify.com/settings/integrations), and never commit it. Runs are billed to the Apify account the agent signs in with, and only domains where a contact is found are charged.

**Tips for agents:**

- Pass all domains in one call. Every input domain comes back as exactly one row, so results join back to the input list without guessing.
- Read `status` first: `contacts_found` is the only charged status; anything else says why nothing came back.
- `emails[].type` separates `personal` from `generic` inboxes, and every contact carries the `source_url` it was found on, so an agent can cite it.
- The MCP `call-actor` tool waits up to 45 seconds. A long list keeps crawling after that: fetch the results with `get-dataset-items` and the run's `datasetId`.

### Legal

Email addresses that identify a person are personal data under the GDPR and
similar laws. This actor reads only what a website publishes to the public, but
what you do with it is your responsibility: use it for business-to-business
contact, honour opt-outs, and have a lawful basis before you send anything.

# Actor input Schema

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

Domains or URLs to search for contact details. One row is returned per domain, and only domains where a contact is found are charged.

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

Same thing in Apify's URL-list format, for chaining after another actor. Merged with `domains`.

## `concurrency` (type: `integer`):

How many domains to crawl at once. 15 is a good default; raising it does not reduce cost, because cost follows total runtime.

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

Crawling stops as soon as both an email and a phone number are found, so most domains use one or two pages of this budget.

## `timeoutMs` (type: `integer`):

A slow host is reported as unreachable rather than holding up the run. Raising this trades runtime for a few more slow sites.

## `domainBudgetMs` (type: `integer`):

Wall-clock cap for one domain across all its pages.

## `useProxy` (type: `boolean`):

Strongly recommended. Measured on a 100-domain sample: six domains that answered a direct connection with 403 or nothing were reachable through rotating datacenter IPs.

## `allowInsecureTls` (type: `boolean`):

Small business sites let certificates expire. Only public pages are read and nothing is ever submitted, so the risk is bad data rather than exposed data.

## `defaultCountry` (type: `string`):

ISO country code (GB, US, AU...) used to read local-format phone numbers when the site's own country cannot be determined from its domain or structured data.

## `proxyGroups` (type: `array`):

Leave empty for Apify's default datacenter pool. Set \["RESIDENTIAL"] only if datacenter IPs are being blocked on your lists — residential traffic is billed per gigabyte.

## Actor input object example

```json
{
  "domains": [
    "angelnumbercodex.com",
    "stripe.com",
    "mud-dock.co.uk",
    "violinshop.com"
  ],
  "concurrency": 15,
  "maxPagesPerDomain": 5,
  "timeoutMs": 8000,
  "domainBudgetMs": 45000,
  "useProxy": true,
  "allowInsecureTls": true,
  "defaultCountry": "US",
  "proxyGroups": []
}
```

# Actor output Schema

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

One row per input website: emails, phones, source pages and status. Only rows with contacts are charged.

# 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 = {
    "domains": [
        "angelnumbercodex.com",
        "stripe.com",
        "mud-dock.co.uk",
        "violinshop.com"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("sumitr_mardy/website-contact-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 = { "domains": [
        "angelnumbercodex.com",
        "stripe.com",
        "mud-dock.co.uk",
        "violinshop.com",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("sumitr_mardy/website-contact-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 '{
  "domains": [
    "angelnumbercodex.com",
    "stripe.com",
    "mud-dock.co.uk",
    "violinshop.com"
  ]
}' |
apify call sumitr_mardy/website-contact-finder --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,sumitr_mardy/website-contact-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/fglA0EtwfujTQbvUr/builds/m3snZA0h7ExeLOj9E/openapi.json
