# Google Maps Email & Contact Details Scraper (`olliolli/website-contact-profile`) Actor

Best public contact route for each company website or Google Maps lead: email, phone or contact form, each with its source page. Emails labelled role, person or freemail. Optional socials and booking routes. No guessing.

- **URL**: https://apify.com/olliolli/website-contact-profile.md
- **Developed by:** [Tom J](https://apify.com/olliolli) (community)
- **Stats:** 2 total users, 1 monthly users, 66.7% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $12.00 / 1,000 contact profiles

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

Extract the **best public contact details** from company websites and **Google Maps leads**: an email address when a suitable one exists, otherwise a phone number or contact form, **each with the page it was found on**. Emails are labelled role, person or freemail; no guessing, no mailbox pinging. Use it right after a Google Maps scraper. From **$2 per 1,000 websites checked + $12 per 1,000 contact profiles**.

### What you can do with it

- **Turn Google Maps leads into contactable leads**: one contact route per company website, with its source page.
- **Find the right email for the purpose**: sales, general or support address, never a support address for sales.
- **Keep GDPR checks easy**: personal and freemail addresses and social links are flagged `personal_data: true`.
- **Spot booking and quote routes** (optional): proven booking or quote pages, with evidence.
- **Track changes**: pass an earlier snapshot to see whether the chosen email changed.

### Use it after Google Maps Scraper

1. Run a Google Maps scraper in your own Apify account.
2. Pick that run's dataset in `datasetId`, or on the Maps scraper open **Integrations → Add integration**, choose this Actor and use `{"datasetId": "{{resource.defaultDatasetId}}"}`.
3. You get one row per unique company website.

Only the website, name, city, postal code and country of each Maps row are read. Rows without an own website (Facebook pages, booking platforms, directories) are skipped and not charged; a website shared by several places is scanned and charged once.

### How to extract contact details from websites in 3 steps

1. **Add websites** in `urls` (1–500) or pick a dataset.
2. **Choose the purpose and scope**: `contactPurpose`, `emailScope`, `channelScope`.
3. **Click Start.**

### Input

```json
{ "urls": ["https://www.example.be"], "contactPurpose": "sales", "channelScope": "all", "emailScope": "all" }
```

| Option | Default | What it does |
|---|---|---|
| `contactPurpose` | `any` | `sales`, `general`, `support`; `sales` never picks a support address |
| `emailScope` | `role_person` | `all` adds freemail and department addresses |
| `channelScope` | `core` (email, phone, form) | `all` adds social profiles, WhatsApp and the online booking link |
| `conversionRoutes` | off | Adds proven booking and quote routes with evidence |
| `maxPages` | 5 | Pages per website (contact and legal pages first) |
| `maxRunSeconds` | 600 | Unprocessed websites are listed and picked up on resume |
| `maxConcurrency` | 5 | 1–10 |
| `expectedEntities` | — | Name, city, postal code per site; output says whether the site agrees |
| `previousSnapshot` | — | SNAPSHOT from an earlier run, to detect changes |
| `countryHint` | from the domain | Flags phone numbers with a foreign calling code |

### Output

```json
{
  "input_url": "https://example.be/",
  "status": "accepted",
  "primary_channel": "email",
  "profile": {
    "email": "info@example.be",
    "role": "general",
    "email_category": "role",
    "emails_observed": [
      { "value": "info@example.be", "category": "role", "source_url": "https://example.be/contact", "method": "mailto" }
    ],
    "channels": [
      { "type": "email", "value": "info@example.be", "source_url": "https://example.be/contact" },
      { "type": "phone", "value": "+3212345678", "source_url": "https://example.be/contact" },
      { "type": "contact_form", "url": "https://example.be/contact", "submission_tested": false }
    ],
    "evidence": { "method": "mailto", "source_url": "https://example.be/contact", "text": "Contact us" }
  },
  "mailbox_verified": false,
  "scan_completeness": "bounded_scan",
  "change": { "state": "first_observed" }
}
```

Status values: `accepted`, `abstained`, `failed`, `invalid_input`. Key-value stores: `OUTPUT` (totals, stop reason), `STATUS`, `ERRORS`, `SNAPSHOT`.

### Source-verified, not guessed

Every email, phone and form comes with the page and the evidence it was found on. The Actor never guesses addresses (no `firstname@` patterns) and never pings mailboxes: `mailbox_verified` is always false, by design.

### Measured quality

- **Email precision 97.4–97.9%** in a preregistered comparison on 300 new SME websites in Belgium, the Netherlands and France (October 2026, AI-reviewed with a human spot check).
- On 200 local businesses with their own website (four Belgian and Dutch municipalities), **57% got a usable contact route and 43% an email address**; most of the rest had websites that were offline, blocked or redirected.
- Booking and quote routes: **48 of 50** proven routes were correct in a blind test on 660 new SME websites.

These are results on test samples, not a guarantee for your list.

### How much does it cost to get contact details?

| Event | Price |
|---|---|
| Website checked | $0.002 |
| Contact profile (accepted, source-backed route) | $0.012 |
| Booking or quote route (only with `conversionRoutes`) | $0.005 |
| Actor start | $0.00005 |

**Example:** 1,000 websites, of which 570 give a contact profile = $2 + $6.84 = **$8.84**. `channelScope: all` and `emailScope: all` cost nothing extra.

#### What you are not charged for

Failures on our side, invalid URLs (free `invalid_input` rows), Maps rows without an own website, and duplicate websites. Rows are stored before they are charged, a resumed run never charges a website twice, and the run stops cleanly at your spending limit.

### Integrate with any app

```python
from apify_client import ApifyClient

client = ApifyClient("<YOUR_APIFY_TOKEN>")
run = client.actor("olliolli/website-contact-profile").call(
    run_input={"urls": ["https://www.example.be"], "contactPurpose": "sales"}
)
for row in client.dataset(run["defaultDatasetId"]).iterate_items():
    print(row["input_url"], row["primary_channel"], row.get("profile", {}).get("email"))
```

Send rows to **Google Sheets, HubSpot, Make, Zapier or n8n** through Apify's integrations. Through Apify's **MCP** server, AI assistants can look up a company's contact route.

### Privacy

Social links, WhatsApp and personal or freemail addresses are marked `personal_data: true`, so you can filter them for your GDPR checks. A contact route is not consent to be contacted: check the rules for your outreach (GDPR and national direct-marketing rules).

### Good to know

- Robots.txt is respected; at most 5 pages per site. No forms are sent, no logins, no browser, no paid proxies, no third-party contact database.
- Pages behind a login, JavaScript-only pages and bot challenges can give no result.

### FAQ

**Does it find emails for Google Maps leads?** Yes: chain it after a Google Maps scraper and it reads each company's own website.

**Does it guess email addresses?** No. Only addresses published on the site, with the source page.

**Are the emails verified?** They are source-verified (published on the company's own site), not mailbox-verified.

**Is it GDPR-compliant for B2B outreach?** It reads public business websites and flags personal data. Whether you may contact someone depends on your outreach and local rules.

**What if a company has no website?** The row is skipped and not charged.

### Need help?

Open an issue on the **Issues** tab. Please do not send passwords, private records or mailbox contents.

# Actor input Schema

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

One URL per company website, up to 500. Duplicate hosts are scanned once per run.

## `datasetId` (type: `string`):

Select a dataset with business listings, for example the results of a Google Maps scraper run you made yourself. The scan reads only each row's website and, for an identity check, its name, city, postal code and country code (first 5,000 rows, up to 500 unique company websites; listing, social and booking pages are skipped). You can also chain it: add an integration on your listing scraper that runs this Actor with {"datasetId": "{{resource.defaultDatasetId}}"}. You are responsible for how you obtained the listing data.

## `maxPages` (type: `integer`):

Includes the homepage. Robots.txt and bounded redirects are separate requests. Lower limits can miss contacts.

## `maxRunSeconds` (type: `integer`):

Stops new work at this time budget. Unprocessed URLs are listed in OUTPUT. The platform timeout must leave additional time to save the summary.

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

Bounded parallel scans. Higher values use more Actor memory/network capacity and may increase target-site load; 5 is the default.

## `contactPurpose` (type: `string`):

Sales prefers sales, then general, never support; general addresses with nearby customer-service phrases are excluded. This is a conservative heuristic, not a guarantee of commercial suitability. General/support only accept their own mailbox category.

## `emailScope` (type: `string`):

role_person returns role and named company-domain addresses. all also includes freemail and department addresses; these categories are less validated.

## `channelScope` (type: `string`):

Extra social, booking and WhatsApp links are experimental and are not included by default.

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

Applied to all URLs in this run. Labels explicit international calling codes as matching, differing, non-geographic or unknown; it never guesses a code for a local-format number and does not prove the phone belongs to a local branch.

## `expectedEntities` (type: `array`):

Optional per-website identity from your own list. The output compares it with same-site JSON-LD claims; a match is supporting evidence only, not independent proof of branch ownership.

## `previousSnapshot` (type: `object`):

Paste the SNAPSHOT record from the previous run. Purpose, email scope, page limit, policy and input URL must match. Leave empty on the first run. Failed/partial scans preserve the previous confirmed selection.

## `conversionRoutes` (type: `boolean`):

Also look for online booking/appointment and quote-request routes (proven, with evidence) and online ordering and price routes (candidates, with evidence). Opens the page behind each candidate button once (robots.txt respected) and adds a few seconds per website. Proven routes: in a blind test on 660 new SME websites (BE/NL/FR, October 2026), 48 of 50 booking and quote routes were correct. Candidates are not covered by that figure.

## Actor input object example

```json
{
  "urls": [
    "https://www.umicore.com/",
    "https://www.kbc.com/"
  ],
  "maxPages": 5,
  "maxRunSeconds": 240,
  "maxConcurrency": 5,
  "contactPurpose": "any",
  "emailScope": "role_person",
  "channelScope": "core",
  "conversionRoutes": false
}
```

# Actor output Schema

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

One row per website: status, chosen contact route, all channels and emails found, each with its source page.

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

Counts, stop reason and websites that were not processed (OUTPUT).

## `status` (type: `string`):

One status per input website, also when nothing was found (STATUS).

## `snapshot` (type: `string`):

Pass this record as previousSnapshot to a later run to see what changed (SNAPSHOT).

# 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 = {
    "urls": [
        "https://www.umicore.com/",
        "https://www.kbc.com/"
    ],
    "maxRunSeconds": 240
};

// Run the Actor and wait for it to finish
const run = await client.actor("olliolli/website-contact-profile").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 = {
    "urls": [
        "https://www.umicore.com/",
        "https://www.kbc.com/",
    ],
    "maxRunSeconds": 240,
}

# Run the Actor and wait for it to finish
run = client.actor("olliolli/website-contact-profile").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 '{
  "urls": [
    "https://www.umicore.com/",
    "https://www.kbc.com/"
  ],
  "maxRunSeconds": 240
}' |
apify call olliolli/website-contact-profile --silent --output-dataset

```

## MCP server setup

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

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/bkfWJAHG1hGu7ooK6/builds/9uLz8lFdN7w1BOo3w/openapi.json
