# Website Contact Finder: Emails, Phones & Socials (`southern-data/website-contact-finder`) Actor

Find emails, phone numbers and social profiles on any list of websites. Reads each homepage plus its contact, about and impressum pages. One flat price per website, never per page; unreachable sites are free.

- **URL**: https://apify.com/southern-data/website-contact-finder.md
- **Developed by:** [Southern Data](https://apify.com/southern-data) (community)
- **Categories:** Lead generation, SEO tools
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $3.00 / 1,000 website with contacts

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 Finder: Emails, Phones & Socials

Give it a list of websites. For each one you get **one clean row** with:

- **Emails**: from `mailto:` links, plain text, Cloudflare "email protection", `info [at] shop [dot] com`-style spelling, and schema.org data. The address on the site's own domain comes first.
- **Phone numbers**: from `tel:` links, schema.org data, labelled text ("Phone:", "Call us on", "Tel.", "Telefon") and clearly phone-shaped local numbers like `(02) 4929 1234`, converted to international **E.164** format (`+61249291234`) when the country is known. Fax, ABN, VAT and company numbers are left out.
- **Social profiles**: Facebook, Instagram, LinkedIn, X (Twitter), YouTube, TikTok and Pinterest. Share buttons, the website builder's own accounts and staff members' personal profiles on team pages are ignored.
- **The contact page URL**, whether the site uses a contact form, and a **status** for every site (read, dead, blocked, parked…).
- **Where each item was found** (page and method), so you can check any result in a click.

**One flat price per website, never per page.** We read the homepage plus up to 4 likely pages (contact, impressum, about, team), and **sites we can't read cost nothing**.

### Pricing

| Event | Price | When |
|---|---|---|
| `website-checked` | **$0.0005** | The website was reached and read (any number of pages up to your limit) |
| `contacts-found` | **$0.003** | At least one email or phone number was found on it |

So **1,000 websites cost at most $3.50**, and less when sites have no contacts. A site with only social links is charged $0.0005. **Dead, unreachable, blocked, parked and robots.txt-disallowed sites, and invalid input, are free.** The log shows the maximum cost before the run starts, and every row has a `chargedEvents` column.

Why flat? Per-page pricing makes the bill depend on how many pages the crawler decides to open, and users of per-page contact scrapers often report surprise bills. Here you know the cost before you start.

### Quick start

1. Click **Try for free**.
2. Paste websites, one per line: `acme.com.au`, `https://www.acme.com/`, or even `jane@acme.com` (its website is read). **Or** paste a link to a CSV / TXT file or a Google Sheet (shared as "Anyone with the link can view") in **websitesFileUrl**. A column named `website`, `url`, `domain` or `email` is used automatically, so exports from Google Maps or OpenStreetMap scrapers work as they are.
3. Optional: set **Default country** (e.g. `AU`) so local numbers on `.com` sites become `+61…`.
4. Click **Start**, then open the **Output** tab: *Overview*, *Emails & phones*, *Social profiles*, *Where found (audit)*, *Not readable / skipped* or *All fields*. Download as CSV, Excel or JSON.

Via API:

```bash
curl -X POST "https://api.apify.com/v2/acts/southern-data~website-contact-finder/run-sync-get-dataset-items?token=$APIFY_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"websites":["acme.com.au","example.de"],"defaultCountry":"AU"}'
```

### Example row

```json
{
  "input": "harbourplumbing.com.au",
  "rowStatus": "checked",
  "siteStatus": "ok",
  "primaryEmail": "info@harbourplumbing.com.au",
  "emails": "info@harbourplumbing.com.au; accounts@harbourplumbing.com.au",
  "primaryPhone": "+61249291234",
  "phones": "+61249291234; +61412345678",
  "phonesAsWritten": "(02) 4929 1234; 0412 345 678",
  "facebook": "https://www.facebook.com/HarbourPlumbingNewcastle",
  "instagram": "https://www.instagram.com/harbour.plumbing",
  "contactPageUrl": "https://harbourplumbing.com.au/contact",
  "hasContactForm": true,
  "emailSources": "info@harbourplumbing.com.au — https://harbourplumbing.com.au/contact (cloudflare); …",
  "pagesVisited": 3,
  "chargedEvents": "website-checked; contacts-found"
}
```

Every row is flat (no nested objects), so CSV and Excel exports have one column per value. Lists are joined with `; `.

### What it does NOT do (please read)

- **No JavaScript rendering.** Pages are read as the server sends them, which keeps the price low and the speed high. Sites built entirely in the browser (some Wix, Squarespace and React sites) may show few or no contacts. Those rows have `jsHeavy: true` so you can spot them.
- **Emails behind contact forms can't be found** by any crawler. Many small businesses publish only a form. Those rows say `hasContactForm: true` and give you the `contactPageUrl`.
- **At most 10 pages per site** (5 by default): the homepage, then the most promising contact, impressum, about and team pages. Emails that only appear on deep pages such as blog posts or individual staff profiles may be missed.
- **Polite by design:** we follow robots.txt (`siteStatus: robots_disallowed` when a site asks bots to stay out, free), read one page at a time per site with a pause, and honour crawl-delay up to 5 s.
- **No bypassing of protection.** Sites behind Cloudflare challenges, captchas or logins are reported as `blocked` (free). Facebook, Instagram, Linktree and similar pages are not read, because they need a login.
- **No email verification.** We report what the website publishes. We don't ping mail servers (Apify blocks port 25), and an address on a website can still bounce.
- **Phone numbers are only as good as the page.** Numbers are validated with Google's libphonenumber rules. The country comes from the domain (`.com.au` → AU), the site's schema.org data, your **Default country**, or the page language, in that order. When none of these works (a `.com` site with no hints), local numbers from `tel:` links are shown as written (not converted to `+…`) and local numbers in plain text are skipped rather than guessed; `notes` tells you to set **Default country**.
- **Redirects are followed.** If `old-name.com` redirects to `new-name.com`, contacts come from the new site and `notes` says so. Check those rows when you need contacts for the original company only.

### How accurate is it?

We tested it on **43 real small-business websites** (cafés, hairdressers, bakeries, dentists, bike shops, plumbers) taken from OpenStreetMap listings in Australia, Germany and the UK, in the Apify cloud with default settings (November 2026):

| | Result |
|---|---|
| Websites that could be read | **34 of 43 (79%)**. The other 9: 3 dead domains, 2 blocking bots (HTTP 403), 1 robots.txt opt-out, 1 expired certificate, 1 server error, 1 never answered. All 9 were free. |
| Read websites with at least one email or phone | **27 of 34 (79%)**: 23 with an email, 24 with a phone, 23 with a social profile, 25 with a contact page found |
| Read websites with nothing found | 7: 3 built entirely in JavaScript (no text without a browser), 3 offering only a contact form, 1 redirecting to an unrelated site |
| Manual check | We re-downloaded every page the Actor visited and compared every `mailto:`/`tel:` link and visible email by hand. None were missed and no addresses were made up. |
| Against OpenStreetMap's own email for the business | Same address found in 7 of 13 cases. The 6 misses: 1 JavaScript-only site, 1 blocked, 1 server error, and 3 where the address isn't on the pages of the website at all |

Expect similar numbers on local-business lists. Big corporate sites (Google, Amazon…) rarely publish an email and often block bots, so results there are much lower. Found emails are what the website shows; they are **not** verified as deliverable.

### Site statuses

| siteStatus | Meaning | Charged |
|---|---|---|
| `ok` | Homepage read | ✅ |
| `dead` | The domain doesn't resolve (no website) | free |
| `unreachable` | Timeout or connection refused | free |
| `tls_error` | Broken HTTPS, and no plain-HTTP version | free |
| `http_error` | The homepage returns an error (404, 500…) | free |
| `blocked` | Bot protection, captcha, 401/403/429 | free |
| `parked` | Domain-for-sale or placeholder page | free |
| `robots_disallowed` | robots.txt asks bots not to read the homepage | free |
| `redirects_to_platform` | The domain just forwards to Facebook, Instagram, a marketplace… | free |
| `not_html` | The address serves a file, not a web page | free |

`rowStatus` is `checked` for all of the above, `invalid_input` for entries that aren't websites (free, with a `reason`), and `not_processed` for websites not started because your maximum cost per run was reached (free). Those, plus any websites skipped by **maxWebsites**, are listed in the `REMAINING` key-value record, ready to re-run.

### Input options

| Field | Default | What it does |
|---|---|---|
| `websites` | — | Websites, URLs or email addresses, one per line |
| `websitesFileUrl` | — | Link to a CSV / TXT file or Google Sheet (up to 100,000 rows) |
| `maxPagesPerSite` | 5 | 1–10 pages per website. The price stays the same; more pages are just slower |
| `defaultCountry` | — | 2-letter code used to read local phone numbers when the site doesn't show its country |
| `maxWebsites` | 0 (all) | Read only the first N, which is handy for a cheap trial on a big file |
| `maxConcurrency` | 10 | Websites read in parallel |
| `pageTimeoutSecs` | 30 | Per-page timeout; each website stops after 2 minutes in total |
| `selfTest` | false | Run the built-in health check instead (free, no rows) |

### Speed and limits

About **30–60 websites per minute** with the defaults (measured: 60 websites in 87 seconds; 1,000 sites ≈ 20–30 minutes). Slow or dead hosts are the main factor; raising `maxConcurrency` to 20–30 roughly halves the time. The log shows an ETA and the maximum cost at the start. If you set a **Maximum cost per run**, the Actor stops cleanly when it's reached: finished rows are kept, the rest are marked `not_processed` (free), and the status message says how many are left. Websites already being read when the limit is hit are still delivered, free.

Bad input never fails the run. If nothing usable was given, the run ends with a clear message and **$0 charged**.

### Good to combine with

- **[OpenStreetMap Business Leads](https://apify.com/southern-data/osm-business-leads)**: get local businesses with their websites, then feed the CSV link straight into this Actor.
- **[Domain Checker](https://apify.com/southern-data/domain-intel)**: check whether those domains have working email (MX, SPF, DMARC) before you email them.

### Responsible use

This Actor only reads pages that are public and allowed by robots.txt. Business contact details are often still personal data (GDPR, Australian Privacy Act, CAN-SPAM). You are responsible for having a lawful basis to store and contact them, and for honouring opt-outs.

### Support

Found a site where we missed a visible email or phone? Open an issue on the **Issues** tab with the URL. Bug reports are usually fixed within a few days.

# Actor input Schema

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

One per line: "acme.com.au", "https://www.acme.com/", a deep link like "https://acme.com/en/contact" (that page is read first), or an email address (its website is read). Duplicates are removed. Facebook / Instagram / Linktree / Yelp links and gmail.com-type addresses are not company websites, so they are listed as rowStatus=invalid_input (free).

## `websitesFileUrl` (type: `string`):

Public URL of a .txt or .csv file, or a Google Sheets link shared as "Anyone with the link can view". Uses the column headed website / url / domain / homepage / email if there is one; otherwise the column with the most websites. The log names the column used. Up to 100,000 rows. Exports from Google Maps or OpenStreetMap scrapers work as-is.

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

Pages read on each website: the homepage, then the most likely contact, impressum, about and team pages. 5 finds nearly everything on small-business sites; up to 10. The price is per website, not per page, so this only changes speed.

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

Optional 2-letter code (AU, US, GB, DE…). Used to turn local numbers like "(02) 4929 1234" into +61 2 4929 1234 when the website itself doesn't reveal its country (e.g. .com sites). Country domains (.com.au, .de, .co.uk) and address data on the site take priority.

## `maxWebsites` (type: `integer`):

Read only the first N websites — handy for a cheap trial on a big file. The rest are skipped (not charged) and listed in the REMAINING record.

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

How many different websites are read at the same time (pages within one website are always read one at a time, politely). Higher is faster; 10 is a good default for 512 MB memory.

## `pageTimeoutSecs` (type: `integer`):

Give up on a page that hasn't loaded in this time. Many small-business sites on cheap hosting need 15–30 s. A homepage timeout makes the website 'unreachable' (free). Each website stops after 2 minutes in total.

## `selfTest` (type: `boolean`):

Ignore the inputs above and run the built-in health check instead: extraction rules, a full crawl of a test website, and two live sites. Writes a SELF_TEST record, adds no dataset rows, charges nothing, and FAILS the run if anything is broken — schedule it daily as a free monitor.

## Actor input object example

```json
{
  "websites": [
    "apify.com",
    "python.org"
  ],
  "maxPagesPerSite": 5,
  "maxWebsites": 0,
  "maxConcurrency": 10,
  "pageTimeoutSecs": 30,
  "selfTest": false
}
```

# Actor output Schema

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

One row per input website. Views: Overview, Emails & phones, Social profiles, Where found (audit), Not readable / skipped, All fields.

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

All emails and phone numbers per website.

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

RUN_SUMMARY record: sites read, how many had emails/phones, status counts, charged events and any stop reason.

## `remaining` (type: `string`):

REMAINING record: inputs not read in this run (spend cap or maxWebsites), ready to paste into the next run. Only present when some were left.

## `selfTest` (type: `string`):

SELF_TEST record: results of the health check. Only present when the Actor runs with selfTest: true.

# 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",
        "python.org"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("southern-data/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 = { "websites": [
        "apify.com",
        "python.org",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("southern-data/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 '{
  "websites": [
    "apify.com",
    "python.org"
  ]
}' |
apify call southern-data/website-contact-finder --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,southern-data/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/GmvtyU2b7CrTi5JDc/builds/Ogj8iPu5aq3Jv6ZTG/openapi.json
