# Contact Details Scraper — Emails, Phones, Socials from Websites (`tortuga/contact-details-scraper`) Actor

Extract emails, phone numbers and social profiles (Facebook, Instagram, LinkedIn, X, YouTube, TikTok, WhatsApp...) from a list of websites or domains: one merged row per domain.

- **URL**: https://apify.com/tortuga/contact-details-scraper.md
- **Developed by:** [Trevor Ortega](https://apify.com/tortuga) (community)
- **Categories:** Lead generation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $6.00 / 1,000 domain with contacts

This Actor is paid per event and usage. You are charged both the fixed price for specific events and for Apify platform usage.

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

## Contact Details Scraper — Emails, Phones, Socials from Websites

Give it a list of websites or domains and get back **one clean row per domain** with every email address, phone
number and social profile the site publishes: Facebook, Instagram, LinkedIn, X/Twitter, YouTube, TikTok, Pinterest,
WhatsApp, Telegram, Discord and GitHub, plus the company name, the postal address (when the site publishes it as
structured data) and the URL of the contact page.

The scraper reads the start page and then the pages where contacts usually are: **Contact, Impressum/Imprint, About,
Team, Support and legal pages** first, other pages after them. It understands `mailto:` links, plain text, obfuscated
spellings like `info (at) acme (dot) com`, Cloudflare's email protection and schema.org JSON-LD. Phone numbers come
normalized to international E.164 format (`+12122473491`) next to the way the site writes them.

You pay only for domains where something was found. Dead domains, blocked sites and sites without any contact
details cost nothing.

### What data does Contact Details Scraper extract?

| Field | Description |
|---|---|
| `domain` | The domain the row is about (host without `www.`), e.g. `patsys.com` |
| `inputUrl` / `finalUrl` | The URL or domain as you entered it / the start page after redirects |
| `status` | `ok`, `unreachable`, `blocked` (anti-bot page), `httpError`, `notHtml`, `blockedByRobotsTxt`, `socialProfileOnly`, `platformPage`, `skipped` |
| `error` | Why the site could not be read (only when `status` is not `ok`) |
| `companyName` | Business name from JSON-LD Organization data, `og:site_name` or the page title (the one matching the domain wins) |
| `address` | Postal address from JSON-LD: `streetAddress`, `addressLocality`, `addressRegion`, `postalCode`, `addressCountry`, `full` |
| `emails` | Email addresses, the site's own domain first, then other addresses the site shows (gmail.com, agencies) |
| `phones` | Valid phone numbers in E.164 format (`+493093931290`) |
| `phonesRaw` | The same numbers as written on the site (`+49 (0)30 939312-90`), in the same order as `phones` |
| `phonesUncertain` | Numbers from `tel:` links that could not be validated for any country |
| `facebooks`, `instagrams`, `linkedIns`, `twitters`, `youtubes`, `tiktoks`, `pinterests`, `whatsapps`, `telegrams`, `discords`, `githubs` | Profile / invite links, normalized (share buttons, single posts and template placeholders are dropped) |
| `contactPageUrl` | The site's contact page |
| `contactsFound` | `true` when at least one email, phone or social profile was found (only these rows are charged) |
| `pagesVisited` / `visitedUrls` | How many and which pages were read |
| `errors` | Pages of the site that could not be read (404, timeouts...) |
| `changeType` / `previousValues` | Only with monitoring on (see below) |
| `scrapedAt` | When the row was made (UTC) |

With **Also output one row per page** on, every visited page also gets a row (`type: "page"`) with `url`, `depth`,
`pageKind` (contact, imprint, about, team, legal, other...), `httpStatus`, `title` and the contacts found on that page,
so you can see where each email was published. Page rows are free.

### How to scrape contact details from a list of websites

1. Add websites under **Websites** (one URL per line, or upload / link a text or CSV file), or paste a list or a
   whole CSV export into **Or paste a list of domains / URLs**. Bare domains like `acme.co.uk` are fine.
2. Keep **Max pages per domain** at 5 (enough for most sites) or raise it for big sites.
3. Click **Start** and download the results as CSV, Excel or JSON from the **Output** tab, or read them via the API.

Several URLs of the same domain are merged into one row; subdomains (`shop.acme.com`) get their own row.

### Input example

```json
{
  "startUrls": [{ "url": "https://www.patsys.com/contact.html" }],
  "urlsText": "joscoffee.com\neinstein-kaffee.de\nhttps://www.zingermansdeli.com",
  "maxPagesPerDomain": 5,
  "maxDepth": 1,
  "sameDomainEmailsOnly": false,
  "proxyConfiguration": { "useApifyProxy": true }
}
```

### Output example

```json
{
  "type": "domain",
  "domain": "patsys.com",
  "inputUrl": "https://www.patsys.com/contact.html",
  "finalUrl": "https://www.patsys.com/contact.html",
  "status": "ok",
  "error": null,
  "companyName": "Patsys Italian Restaurant",
  "emails": ["anne@patsys.com", "patsysnyc@gmail.com"],
  "phones": ["+12122473491"],
  "phonesRaw": ["(212) 247-3491"],
  "phonesUncertain": [],
  "facebooks": ["https://www.facebook.com/patsysitalianrestaurant"],
  "instagrams": ["https://www.instagram.com/patsysitalianrestaurant"],
  "linkedIns": [],
  "twitters": ["https://x.com/patsysitalrest"],
  "youtubes": ["https://www.youtube.com/channel/UCaBjym9W_eD6Svq9vKqX5_w"],
  "tiktoks": [],
  "pinterests": ["https://www.pinterest.com/sharonaburch/patsys-italian-restaurant-ny"],
  "whatsapps": [],
  "telegrams": [],
  "discords": [],
  "githubs": [],
  "contactPageUrl": "https://www.patsys.com/contact.html",
  "address": null,
  "contactsFound": true,
  "pagesVisited": 3,
  "visitedUrls": ["https://www.patsys.com/contact.html", "https://www.patsys.com/", "https://www.patsys.com/ourstory.html"],
  "errors": [],
  "scrapedAt": "2026-10-12T18:54:33Z"
}
```

### Which pages does it visit?

The start page, then pages linked from it, best first: contact pages (also *Kontakt*, *Contacto*, *Contatti*,
*Nous contacter*...), Impressum / imprint / legal notice, About, Team, support, privacy and terms pages, locations,
then any other page of the site. **Crawl depth** 1 (default) means the start page and the pages it links to; 2 also
follows links found on those pages. Only the website's own domain is crawled (with or without `www.`); contact,
imprint, about and team pages on other subdomains are read when the site links to them, and **Include subdomains**
opens the whole subdomain family. `robots.txt` is respected unless you switch it off. Every site gets at most 3
requests at a time with short pauses, so crawling is polite.

### Pricing

| Event | Price | When |
|---|---|---|
| Domain with contacts | $0.006 | A domain where at least one email, phone number or social profile was found. Includes up to 5 pages. |
| Extra page | $0.0012 | Each page beyond the first 5 of a domain with contacts (only if you raise **Max pages per domain**). |

Domains with nothing found, unreachable or blocked sites, and page rows are **free**. Platform usage is billed by
Apify at cost and is small: a run of 98 domains with the default settings read 4.6 pages per site, took 3 minutes and
used $0.007 of platform usage (about $0.07 per 1,000 domains). Example: 1,000 domains, 90% with contacts, default
settings: 900 × $0.006 = **$5.40** plus about $0.07 usage.

### Monitor changes on a schedule

Turn on **Only new or changed domains** to watch a lead list or your competitors' contact pages. The actor remembers
what earlier runs of the same input found (emails, phones and social profiles per domain) and outputs only domains that
are new or whose contact set changed; unchanged domains are not output and not charged. Save the input as a task and
add a Schedule in Apify Console (Schedules → Create, e.g. weekly). Changed rows carry `changeType: "changed"` and
`previousValues` with the old emails/phones/profiles; first-seen domains carry `changeType: "new"`. A site that is down
for one run, or a run where a page fails to load and something disappears, does not count as a change. Use
**Monitoring key** to keep several watches on the same list apart.

### How do I extract emails from a CSV or Google Sheet of domains?

Paste the CSV into **Or paste a list of domains / URLs**: every column that holds a website or domain is used and the
other columns (names, cities, emails) are ignored. For a Google Sheet, use *File → Share → Publish to web → CSV* and add
that link under **Websites** with the "Link remote text file" option.

### Why was no email found for a website?

Common reasons: the site shows a contact form instead of an address, the address is in an image, or the page is
built entirely by JavaScript (single-page apps); this scraper reads the HTML without a browser, which keeps it fast and
cheap. Some sites block datacenter IPs (`status: "blocked"`): try the residential proxy for those. Rows without
contacts are free, so it costs nothing to try.

### Does it find emails hidden by Cloudflare or written as "name (at) domain (dot) com"?

Yes. Cloudflare's email obfuscation is decoded, and the common "(at)" / "\[at]" / "(dot)" spellings are read.
Placeholders (`you@example.com`, Wix and Sentry ids, image names like `logo@2x.png`) and no-reply addresses are dropped.

### Proxy note

The default Apify datacenter proxy works for most business websites and is included in the platform usage. For lists
of sites that block datacenter IPs, choose the Residential proxy group; Apify bills residential traffic per GB.

### Integrations and API

Run it from Zapier, Make, n8n or Google Sheets, or call it from Python / Node.js with the Apify client (see the
**API** tab). Use the result to enrich a CRM: the `domain` field is a stable key.

### Is it legal to scrape contact details from websites?

The actor reads only what websites publish openly to anyone, without logging in, and does not guess or verify
addresses. Business contact details are public by intent, but some addresses identify a person (`jane.doe@...`); if
you are in the EU/UK, data protection law (GDPR) applies to how you store and use them, e.g. for cold outreach. You are
responsible for complying with the applicable law and the sites' terms.

### Support

Found a site where a visible email or phone was missed? Open an issue in the **Issues** tab with the URL; it is
usually answered within a day.

# Actor input Schema

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

Websites or domains to scan, e.g. https://acme.com or acme.co.uk. Each domain becomes one result row; several URLs of the same domain are crawled together. You can also upload a text/CSV file or link one (e.g. a Google Sheets CSV export).

## `urlsText` (type: `string`):

Paste websites or domains: one per line, comma-separated, or a whole CSV export (every column holding a website or domain is used, other columns are ignored).

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

Pages read per website, start page included. Contact, Impressum/imprint, About, Team, support and legal pages are read first, then other pages. 5 finds most published contacts; pages beyond 5 are charged as 'extra page' (only for domains where contacts were found).

## `maxDepth` (type: `integer`):

How many clicks away from the start page to go: 0 = start page only, 1 = the start page and the pages it links to (default), 2 = also pages linked from those.

## `sameDomainEmailsOnly` (type: `boolean`):

Keep only addresses on the site's own domain (info@acme.com for acme.com, also mail.acme.com). Drops web agency, partner and free-mail (gmail.com...) addresses that the site shows.

## `includeSubdomains` (type: `boolean`):

Also crawl other subdomains of the site (shop.acme.com, support.acme.com) like the main one. Off: only the domain itself, with or without www.; contact, imprint, about and team pages on other subdomains are still read when the site links to them.

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

Follow each site's robots.txt rules: pages it disallows are not visited. Recommended.

## `includePageRows` (type: `boolean`):

Besides the merged row per domain, output one row per visited page (type 'page') with what was found on that page: handy to see where an email was published. Page rows are free.

## `maxItems` (type: `integer`):

Stop after this many domain rows. Leave empty to process the whole list.

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

Websites visited in parallel. Every site gets at most 3 requests at a time with pauses in between, so a higher value is still polite to each site.

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

Two-letter country code used to read phone numbers written without +country code when the site does not reveal its country (domain ending like .de / .co.uk, address data, page language). Default: US.

## `onlyNewOrChanged` (type: `boolean`):

Remember what earlier runs of this list found and output only domains that are new or whose emails, phone numbers or social profiles changed. Use with a Schedule; unchanged domains are not output and not charged.

## `monitoringKey` (type: `string`):

Name for this watch, so several schedules on the same list stay separate; defaults to a key derived from the input.

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

Apify datacenter proxy (default) works for most websites. Pick the Residential group for lists of sites that block datacenter IPs (Apify bills residential traffic per GB).

## Actor input object example

```json
{
  "startUrls": [
    {
      "url": "https://www.joscoffee.com/"
    },
    {
      "url": "https://www.patsys.com/"
    },
    {
      "url": "https://einstein-kaffee.de/"
    }
  ],
  "maxPagesPerDomain": 5,
  "maxDepth": 1,
  "sameDomainEmailsOnly": false,
  "includeSubdomains": false,
  "respectRobotsTxt": true,
  "includePageRows": false,
  "maxConcurrency": 10,
  "onlyNewOrChanged": false,
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}
```

# Actor output Schema

## `results` (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://www.joscoffee.com/"
        },
        {
            "url": "https://www.patsys.com/"
        },
        {
            "url": "https://einstein-kaffee.de/"
        }
    ],
    "proxyConfiguration": {
        "useApifyProxy": true
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("tortuga/contact-details-scraper").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.joscoffee.com/" },
        { "url": "https://www.patsys.com/" },
        { "url": "https://einstein-kaffee.de/" },
    ],
    "proxyConfiguration": { "useApifyProxy": True },
}

# Run the Actor and wait for it to finish
run = client.actor("tortuga/contact-details-scraper").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.joscoffee.com/"
    },
    {
      "url": "https://www.patsys.com/"
    },
    {
      "url": "https://einstein-kaffee.de/"
    }
  ],
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}' |
apify call tortuga/contact-details-scraper --silent --output-dataset

```

## MCP server setup

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

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/P60rafnk5hyiRL8MP/builds/wIw3xwX9GDh4o3PTM/openapi.json
