# Website Contact Scraper – Emails, Phones, Socials (`simple.actors/website-contacts`) Actor

Scrape a business's contact details off its website: every email address and validated phone number, social and messaging profiles, postal address and contact form — one row per site. It reads the page you give plus the few pages likeliest to carry contacts, because a home page rarely does.

- **URL**: https://apify.com/simple.actors/website-contacts.md
- **Developed by:** [Simple Actors](https://apify.com/simple.actors) (community)
- **Categories:** Lead generation, Automation, Business
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.50 / 1,000 page reads

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?

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 Scraper – Emails, Phones, Socials

Give it a website. Get back how to reach the business behind it — **email
addresses, phone numbers, social accounts, the postal address and the contact
form** — as **one row per website**. Built for lead generation, CRM enrichment,
sales prospecting and list building from nothing but a column of domains.

It does not just read the page you give it. Contact details are usually not on
a home page: across the first ten real sites this was built against, eight
published no email address on their home page and none published a phone
number. So it reads the entry page, scores that site's own links for how likely
each is to lead to contact details, and reads the best few as well — the contact
page, the imprint, the about or team page, whichever that site actually has.

Two things it is careful about:

- **A wrong phone number is worse than no phone number.** Numbers are validated
  against the real numbering plan of their country, and are read from the page's
  visible text and its links, never from its raw markup. On one real home page a
  phone-shaped pattern over the markup returned about seventy matches — CSS
  aspect ratios, image IDs, timestamps — and not one phone number.
- **A site that could not be read never comes back looking like a site with no
  contact details.** It arrives as an error row with no `emails` field at all.
  See [Failures](#failures).

### Features

- **Every email on the site, not just the first** — each with the page it was
  found on and how it was found (`mailto`, JSON-LD, Cloudflare-obfuscated,
  plain text…).
- **Phone numbers validated against the real numbering plan** — returned in
  E.164, with national formatting, line type and country.
- **Finds the contact page for itself** — scores a site's own links and reads
  the contact, imprint, about or team page, whichever that site actually has.
- **Social and messaging profiles separated** — accounts to follow apart from
  WhatsApp, Telegram, Messenger and the rest, with share buttons rejected.
- **Postal address only when it really is one** — structured data first, and
  from prose only with a house number, street and postal code together.
- **Contact-form URL** for businesses that publish a form instead of an address.
- **Named contact points** where the site groups them by person or department.
- **The real logo, not the favicon** — the image the site shows at the top of
  its page, at the largest size it offers, with `logoSource` saying whether you
  got that or fell back to an icon.
- **Company identity too** — name and description.
- **Bare hosts accepted** — paste a column straight out of a spreadsheet.
- **Cost capped per site** — `maxPagesPerSite` is a hard ceiling on what any one
  website can cost.
- **JavaScript rendered only when needed.**
- **Unreadable is never confused with empty** — an error row carries no `emails`
  key at all.
- **Runs stay green.** No site problem and no input problem fails a run.

### Input

```json
{
  "urls": ["https://www.bungalowny.com/"],
  "maxPagesPerSite": 5
}
```

A bare host works too — paste a column straight out of a spreadsheet:

```json
{ "urls": ["bungalowny.com", "dishoom.com", "apify.com"] }
```

| Field | Type | Default | What it does |
|---|---|---|---|
| `startUrls` / `urls` | array | — | The websites. Both are read; either one is enough. `urls` also accepts bare hosts. |
| `maxPagesPerSite` | integer | `5` | How many pages of each site may be read — and your ceiling on what each website costs, since charging is per page. `1` reads only the page you gave, which is the cheapest and finds roughly half as many email addresses. Above about 6 there is little left to find. |
| `renderJs` | string | `auto` | `auto` opens a browser only when the served HTML held nothing at all. `always` renders everything. `never` refuses the browser. |
| `countryHint` | string | — | A two-letter code such as `GB`. Only used to read phone numbers written without a country code. |
| `proxy` | object | Apify datacenter | Switch to residential if sites answer with challenge pages. |

### Output

One row per website:

```json
{
  "type": "website",
  "url": "https://www.bungalowny.com/",
  "finalUrl": "https://www.bungalowny.com/",
  "domain": "bungalowny.com",
  "name": "Bungalow",
  "description": "Modern Indian Flagship restaurant by Michelin Star Chef Vikas Khanna",
  "logo": "https://images.squarespace-cdn.com/content/v1/65983d57438d713cfc579485/290d5546/Bungalow_FullLogo_Mint.png?format=1500w",
  "logoSource": "header",

  "email": "info@bungalowny.com",
  "phone": null,

  "emails": [
    { "email": "info@bungalowny.com", "source": "mailto", "foundOn": "https://www.bungalowny.com/" },
    { "email": "info@gupshupnyc.com", "source": "mailto", "foundOn": "https://www.bungalowny.com/" }
  ],
  "phones": [],
  "contacts": [
    { "name": null, "role": null, "email": "info@bungalowny.com", "phone": null, "source": "dom", "sourceUrl": "https://www.bungalowny.com/" }
  ],

  "social": {
    "instagram": "https://instagram.com/bungalowny",
    "facebook": "https://www.facebook.com/p/Bungalow-NY-61558260149142"
  },
  "messaging": {},
  "socialProfiles": [
    { "platform": "instagram", "url": "https://instagram.com/bungalowny", "handle": "bungalowny" },
    { "platform": "facebook", "url": "https://www.facebook.com/p/Bungalow-NY-61558260149142", "handle": "Bungalow-NY-61558260149142" }
  ],

  "address": { "street": null, "city": null, "region": null, "postalCode": null, "country": null, "raw": "24 First Avenue, New York, NY 10009", "source": "text" },
  "contactFormUrl": null,

  "phoneCountry": "US",
  "rendered": false,
  "pagesRead": [
    { "url": "https://www.bungalowny.com/", "statusCode": 200, "rendered": false, "role": "entry" },
    { "url": "https://www.bungalowny.com/hours-and-location", "statusCode": 200, "rendered": false, "role": "linked" }
  ],
  "pagesReadCount": 5,
  "retrievedAt": "2026-08-28T00:31:12.904Z"
}
```

#### The four fields most people use

- **`email`** — the single best address to write to. Not the first one found: an
  address on the site's own domain beats a Gmail one, a general address
  (`info@`, `hello@`, `contact@`) beats a personal one, one published as a
  `mailto:` link beats one mentioned in prose, and a `noreply@` address goes to
  the bottom.
- **`phone`** — the single best number, in E.164 (`+12125551234`), ready to
  dial. Where a site declares in its structured data which number is for
  customer service, that is the one.
- **`social.<platform>`** — one URL per platform: `social.facebook`,
  `social.instagram`, `social.twitter`, `social.linkedin`, `social.youtube`,
  `social.tiktok`, and a couple of dozen more. Only platforms actually found
  are present, so `{}` means no social accounts were linked.
- **`contacts[0]`** — always carries the best email and the best phone
  *together*, so `contacts[0].email` and `contacts[0].phone` are the two fields
  to read if you read nothing else. Later entries are the groupings the site
  itself made — a department in its structured data, or an email and a phone
  sitting in the same block of the page.

#### The logo, and how to tell a logo from a favicon

Most tools report a site's `apple-touch-icon` as its logo. That is a favicon:
square, often 32 pixels, and on a Wix or Squarespace site frequently the site
builder's own default icon rather than the business's mark at all.

This Actor reads the logo off the page first — the image at the top named
`logo`, usually inside the link back to the home page — and takes the largest
version the markup offers. Across seventeen real sites measured, eleven had a
logo image to find and every one was correct; the other six draw their logo as
inline SVG, which has no address to hand you.

`logoSource` says which route answered, and it is the field to filter on:

| `logoSource` | What you got |
|---|---|
| `header` | The logo the page displays. The one to trust. |
| `jsonld` | The logo the site declares in its structured data. Usually right, sometimes a stray photograph. |
| `og:logo` | The same, from a meta tag. |
| `apple-touch-icon`, `icon` | A favicon — square, small, but the business's own. |
| `og:image` | The site's share picture, which may be a photograph. |
| `null` | The site offers nothing. |

Nothing is fetched to decide this, so the field costs you nothing.

#### What counts as a social account

A link to a platform is not an account on it. These are all excluded on
purpose, and each was found on a real page:

| Left out | Why |
|---|---|
| `tiktok.com/discover/<business name>` | TikTok's search results, not the business |
| `youtube.com/watch?v=…` | one video, not a channel |
| `facebook.com/sharer/…`, `twitter.com/intent/tweet`, `linkedin.com/shareArticle` | share buttons |
| `instagram.com/p/…` | a post |
| `linkedin.com/`, `facebook.com/` | the platform's own front page |

`facebook.com/p/Name-123/` and `facebook.com/profile.php?id=…` *are* kept — that
is how a page with no vanity URL is linked. Tracking parameters are stripped, so
one account never appears as several.

**Messaging is separate.** `messaging.whatsapp`, `messaging.telegram`,
`messaging.messenger`, `messaging.skype`, `messaging.viber` and `messaging.line`
are ways to start a conversation now rather than accounts to follow. A `wa.me`
link also contributes its digits to `phones`, because it is a phone number the
site published.

#### Which pages get read

The entry page always. Then that site's own links, ranked — an **imprint**
outranks even a contact page, because German law requires that page to carry a
postal address, a phone number and an email, which makes it the richest page on
a European site. Then contact, support, about, team, locations.

Both the link's address *and* the words that were clickable are scored, because
neither alone is reliable: `/about` is a 404 on most sites, whose about pages
live at `/who-we-are` or `/our-story`, while one site's contact page is at
`/say-hello/` and only the label "Contact us" says so.

If a site links nothing that looks like a contact page, conventional addresses
(`/contact`, `/contact-us`, and `/impressum` or `/kontakt` on a German domain)
are tried instead. `pagesRead` on every row says exactly which pages were read
and whether each was linked or guessed.

### How to use

#### From Apify Console

1. Open the Actor and click **Try for free** / **Start**.
2. Paste your domains into **Websites (plain list)** — bare hosts like
   `dishoom.com` work, so a spreadsheet column goes straight in.
3. Leave **Pages to read per website** at 5. That is your cost ceiling per site,
   and it averages 3.9 pages in practice because guessed addresses that turn out
   not to exist are never charged. Set it to 1 for the cheapest possible run,
   which finds roughly half as many email addresses.
4. If your list is mostly one country and its sites write phone numbers without
   a country code, set **Country for phone numbers** to that two-letter code.
5. Click **Start**, then open the **Dataset** tab and export as JSON, CSV or
   Excel.

**Skip rows with no `emails` key** — those are sites that could not be read, not
sites without contact details.

#### From the API

```bash
curl -s "https://api.apify.com/v2/acts/simple.actors~website-contacts/run-sync-get-dataset-items?token=$APIFY_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"urls": ["bungalowny.com", "dishoom.com"], "maxPagesPerSite": 5}'
```

Enriching a list of domains, with the JavaScript client:

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

const client = new ApifyClient({ token: process.env.APIFY_TOKEN });
const run = await client.actor('simple.actors/website-contacts').call({
    urls: ['bungalowny.com', 'dishoom.com', 'apify.com'],
    maxPagesPerSite: 5,
    countryHint: 'GB',
});
const { items } = await client.dataset(run.defaultDatasetId).listItems();

for (const row of items) {
    if (!('emails' in row)) continue;   // unread, not "no contacts" — key on presence
    console.log(row.domain, row.email, row.phone, row.pagesReadCount);
}
```

### Use cases

- **Lead generation and list building** — turn a column of domains into emails,
  phone numbers and contact-form URLs ready for outreach.
- **CRM and sales data enrichment** — fill in missing contact routes, postal
  addresses and social handles against accounts you already hold.
- **Sales prospecting from a market list** — feed in the domains from a
  directory, search or competitor scrape and get the way in for each.
- **Social profile discovery** — collect a business's real accounts, with share
  buttons and platform boilerplate already rejected.
- **Company data and directory building** — name, description, logo, postal
  address and contact details in one row per site.
- **Deliverability and contact hygiene checks** — see which of your accounts
  still publish a working address, and which publish none at all.

### Usage notes

Simple, transparent pay-per-result pricing — no subscriptions.

#### What it costs

Charged **per page read**, plus one small fee per run. Nothing else.

| | |
|---|---|
| a run | one run fee, charged once |
| each page read | one page event |
| a page that turns out not to exist | free |
| a website that could not be read at all | free |
| a run where nothing could be read | free — no run fee either |

You control the bill directly with **Pages to read per website**. At the default
of 5 a site averages **3.9 pages**, because guessed addresses that turn out not
to exist are never charged. Set it to 1 and a website costs exactly one page —
the cheapest run there is, and it finds roughly half as many email addresses.

Charging per page rather than per website is deliberate: a site that links its
contact page from its home page costs two reads, and one that hides its address
behind a five-page hunt costs five. You pay for the second only when it happens.
Every row carries `pagesReadCount`, which is exactly what that row was charged.

#### Failures

**The run always succeeds.** A site that refuses us, a host that does not
resolve, an address that is not a URL — each becomes a row, and the run's status
message counts them. Only a bug in this Actor fails a run.

So the failure channel is the row shape, and there is one rule:

> **A site that publishes no contact details has `emails: []`. A site that could
> not be read has no `emails` field at all.**

If your pipeline acts on emptiness — retiring a lead, marking a record checked —
key off the presence of `emails`, never off the run status.

```json
{
  "type": "error",
  "url": "https://www.katzsdelicatessen.com/",
  "statusCode": 405,
  "error": "challenge",
  "errorDescription": "https://www.katzsdelicatessen.com/ answered with a challenge or a refusal (\"Human Verification\") rather than the page.",
  "retrievedAt": "2026-08-28T00:31:12.904Z"
}
```

| `error` | What happened |
|---|---|
| `bad_input` | The address was not usable |
| `unreachable` | The host did not answer |
| `http_error` | It answered with a status that is not a page |
| `challenge` | It answered with a bot check or a refusal — try residential proxy |
| `not_found` | There is no page at that address |
| `render_failed` | Its scripts had to be run and the browser could not |
| `read_failed` | Anything else that stopped the read |

#### Notes on accuracy

**Phone numbers written without a country code need a country to be read at
all** — `020 7946 0958` is a real number in the UK and not a number anywhere
else. The country is taken from `countryHint` if you give one, then the site's
own structured data, then its domain ending, falling back to `US`. Every row
reports which was used as `phoneCountry`. If a site's numbers look wrong, that
is the first field to check, and `countryHint` is the fix.

**A postal address is only reported when it is really one.** Structured data
first. Read out of the page's own words it must carry a house number, a street
and a postal code, in that order, in a short span of text — a sentence that
merely mentions a street is not an address, and neither is a delivery radius or
a set of opening hours. `address.source` says which of the four routes found it.

**Empty is an answer.** Plenty of well-known businesses publish no email address
anywhere on their site, and this Actor will tell you so rather than inventing
one.

### FAQ

**Is scraping contact details from websites legal?**
This Actor reads publicly published pages — the contact details a business puts
on its own site for people to use. Using them is a separate question: check the
platform's ToS, and check your local rules on personal data and marketing
consent (GDPR, CAN-SPAM and their equivalents) before emailing or calling
anything you collect. A named person's work email is personal data in many
jurisdictions.

**Does it guess or verify email addresses?**
Neither — it reports what the site publishes and nothing else. No pattern like
`firstname@domain` is ever invented, and no mailbox is pinged to check it
exists. An empty `emails` array means the site genuinely published none.

**Why did I get no phone number when the site clearly shows one?**
Most often the number is written without a country code, so it cannot be
validated — `020 7946 0958` is a real number in the UK and nowhere else. Set
`countryHint` to the right two-letter code. Every row reports which country was
used, as `phoneCountry`.

**How many pages does it read per website?**
Up to `maxPagesPerSite`, which defaults to 5 and averages 3.9 in practice.
It reads the page you gave plus the highest-scoring links on it — the contact
page, imprint, about or team page. Set it to 1 to read only the page you gave.

**Can it crawl a whole website?**
No, deliberately. It reads the few pages likeliest to carry contact details, not
the whole site — that is what keeps the cost per website bounded and
predictable.

**How do I tell "no contact details" from "could not read the site"?**
By whether the row has an `emails` key. A site that was read has one, even if it
is empty. A site that could not be read has no `emails` field at all and carries
`error` instead. Key your pipeline on the presence of the field, not its length.

**Does it find social media profiles?**
Yes, and it separates two kinds: `social` holds accounts to follow, `messaging`
holds WhatsApp, Telegram, Messenger and similar. Share buttons and platform
boilerplate are rejected rather than returned as the business's own accounts.

**Why did my run succeed when a site failed?**
By design — a site that cannot be read is an answer about that site, not a fault
in the Actor, so it becomes a row and the run stays green. Only a bug in the
Actor fails a run.

# Actor input Schema

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

The websites to look up, one per entry. Give the home page — the actor finds the contact page itself. Each website produces exactly one row, however many of its pages had to be read.

## `urls` (type: `array`):

The same thing as "Websites", as a plain list — easier to paste a column out of a spreadsheet, and it accepts a bare host such as example.com with no https:// in front. Both inputs are read; giving either one is enough.

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

How many pages of each site may be read — and, because this Actor charges per page read, your ceiling on what each website costs. The first page is the address you gave; the rest are that site's own links, ranked by how likely each is to carry contact details — a contact page, an imprint, an about or team page. You are charged only for pages actually read, which averages 3.9 at the default of 5, because guessed addresses that turn out not to exist are never charged. Set it to 1 to read only the page you gave: the cheapest possible run, and it finds roughly half as many email addresses, because most sites do not publish one on the home page. Above about 6 there is little left to find.

## `renderJs` (type: `string`):

"auto" reads the served HTML and opens a browser only when that HTML turned out to hold nothing at all — no email, no phone, no social link and nowhere to go next — which is what a JavaScript-built shell looks like. It is the right setting for almost everyone and the cheapest. "always" renders every page: several times slower and dearer. "never" refuses the browser, so a site that needs one comes back as an error row.

## `countryHint` (type: `string`):

A two-letter country code, such as GB, US or DE. It is used only to read phone numbers written the local way, without a country code in front — "020 7946 0958" is a real number in the UK and not a number anywhere else, so a guess has to be made. Left empty, the actor takes the country from the site's own structured data, then from its domain ending, and falls back to US. Whichever was used is reported on every row as phoneCountry.

## `proxy` (type: `object`):

Defaults to Apify's datacenter proxy, which is included in every plan and is enough for almost every site. Switch to residential if sites answer with challenge pages — every retry already lands on a different address, so a challenge that survives that is usually about the address pool rather than the request.

## Actor input object example

```json
{
  "startUrls": [
    {
      "url": "https://www.bungalowny.com/"
    }
  ],
  "urls": [
    "bungalowny.com"
  ],
  "maxPagesPerSite": 5,
  "renderJs": "auto",
  "countryHint": "GB",
  "proxy": {
    "useApifyProxy": true
  }
}
```

# Actor output Schema

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

One row per website: the business, its best email and phone, its social accounts and its address.

## `everything` (type: `string`):

Every address, number, profile and contact point found on each site, and which pages they came from.

## `raw` (type: `string`):

Every field of every row, exactly as pushed.

# 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://www.bungalowny.com/"
        }
    ],
    "urls": []
};

// Run the Actor and wait for it to finish
const run = await client.actor("simple.actors/website-contacts").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://www.bungalowny.com/" }],
    "urls": [],
}

# Run the Actor and wait for it to finish
run = client.actor("simple.actors/website-contacts").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://www.bungalowny.com/"
    }
  ],
  "urls": []
}' |
apify call simple.actors/website-contacts --silent --output-dataset

```

## MCP server setup

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

```

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/4B0u4uRABPZUrDJI8/builds/JVlaQQErX5cfN2xfL/openapi.json
