# Lead List Cleaner — Dedupe, Phones, Addresses, Websites, Emails (`signalloom/lead-list-cleaner`) Actor

Clean any business lead list (CSV, Google Sheet, JSON or an Apify dataset such as Google Maps results): remove duplicates, format phones (E.164) and addresses, check websites are live and email domains have MX, drop personal emails, add a quality score. $1.50 per 1,000 rows.

- **URL**: https://apify.com/signalloom/lead-list-cleaner.md
- **Developed by:** [Signalloom](https://apify.com/signalloom) (community)
- **Features:** Limited permissions
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.50 / 1,000 row cleaneds

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

## Lead List Cleaner: Dedupe, Phones, Addresses, Websites & Emails

Turn a **messy business lead list into a clean, de-duplicated one** in a single run. Point it at the results of another Actor (Google Maps, store leads, directories), a CSV or JSON file, or paste rows, and every row comes back with your original columns plus `clean_*` columns:

- **Duplicates found and merged**: the same business by website domain, phone number, or name + street address. Branches of a chain (same website, different address) stay separate unless you ask otherwise.
- **Phones in E.164** (`+15124949252`) with validity, line type (mobile, landline, toll-free, VoIP…), country and national format. Works offline for every country.
- **Addresses standardised**: parsed into street, city, state and ZIP; title-cased; USPS-style abbreviations (Boulevard → Blvd, North → N, Suite → Ste); state names → codes (Texas → TX); ZIP and postcode formatting; missing US states filled from the ZIP.
- **Websites checked**: root domain, live or dead, HTTP status, HTTPS, where it redirects, parked or for-sale domains, and social or delivery-app pages that aren't a real website.
- **Emails classified**: syntax, whether the domain accepts mail (MX record in DNS), company role mailbox vs person-looking, free-mail (Gmail, Yahoo…) vs business domain. No email is ever sent.
- **Quality score 0–100** with a grade and plain reasons ("+25 valid phone", "0 website dead (HTTP 404)").

No paid APIs, no logins, no proxies.

**Price: $1.50 per 1,000 unique rows** ($0.0015 each). Duplicate rows are free, whether flagged or removed. The prefilled example (16 rows) finishes in a few seconds.

### Use this when…

- you just ran a **Google Maps scrape** and need to remove duplicate places, fix phone formats and drop businesses whose website is dead
- you **merged several lead lists** (two scrapes, a purchased list, a CRM export) and the same business appears in different spellings and formats
- you're about to **import leads into a CRM or dialer** (HubSpot, Salesforce, Pipedrive, Close) that needs E.164 phone numbers and one record per company
- you want to **prioritise outreach**: sort by quality score, skip rows with invalid phones, dead websites or email domains that can't receive mail
- you want **company contacts only**: keep info@ and sales@ style addresses and drop personal ones before your list goes anywhere

It cleans and checks the data you give it; it doesn't search for new leads or find missing emails.

### For AI agents

- **Run** `signalloom/lead-list-cleaner` (Apify MCP server `call-actor`, or the Apify API) with input like `{"rows": [{"name": "Joe's Pizza", "phone": "(512) 555 0100", "website": "joespizza.com"}], "defaultCountry": "US"}`
- **Returns** each input row with your original columns plus `clean_*` columns: `clean_phone` (E.164), `clean_address`, `clean_domain`, `clean_website_status`, `clean_email`, `clean_is_duplicate`, `clean_quality_score`. Or pass `datasetId` / `sourceUrl` instead of `rows`.
- **Typical cost:** 1,000 rows = **$1.50** ($0.0015 per cleaned row); duplicate rows are free.

### How to use it

1. Choose your list (any one, or several together):
   - **Apify dataset ID**: pick the dataset of an earlier run (Google Maps Scraper, E-commerce Store Leads, any Actor). This is the quickest way to clean scraper output.
   - **CSV or JSON file URL**: any direct download link. Google Sheets links work when the sheet is shared as "Anyone with the link".
   - **Rows (JSON)**: paste an array of objects.
   - Leave all three empty to try the built-in sample.
2. Columns are **mapped automatically** (`title`/`name`/`company`, `phone`/`phones`, `website`/`url`/`domain`, `email`/`emails`, `address` or `street`/`city`/`state`/`postalCode`/`country`). If yours are named differently, set **Field mapping**, e.g. `{"phone": "Main Phone"}`.
3. Choose **Flag** or **Remove** duplicates and which row to keep: **Merge** (default) keeps the most complete row and fills its missing phone, website, email or address from the duplicates.
4. Click **Start**. Export to CSV, Excel or JSON, or send the results to Google Sheets or your CRM with Make, Zapier or n8n. The **Unique businesses only** view in the dataset shows one clean row per business.

**Recipe: clean a Google Maps scrape.** Paste the run's dataset ID, set Duplicates to Remove, then sort by `clean_quality_score` and filter out rows where `clean_website_status` is `DEAD` or `clean_phone_valid` is `false`.

### Output

One row per input row: your original columns first, then the `clean_*` columns. Real example from the built-in sample (the prefilled run), row 4:

```json
{
  "title": "24 Diner",
  "phone": "5124725400",
  "website": "https://www.24diner.com/",
  "email": "info@24diner.com",
  "address": "600 N Lamar Blvd Ste 200, Austin, TX 78703",
  "clean_row": 4,
  "clean_name": "24 Diner",
  "clean_phone": "+15124725400",
  "clean_phone_valid": true,
  "clean_phone_type": "landline_or_mobile",
  "clean_phone_country": "US",
  "clean_phone_national": "(512) 472-5400",
  "clean_website": "https://www.24diner.com",
  "clean_domain": "24diner.com",
  "clean_website_status": "LIVE",
  "clean_website_http_status": 200,
  "clean_website_https": true,
  "clean_website_redirects_to_domain": null,
  "clean_email": "info@24diner.com",
  "clean_email_mx": true,
  "clean_email_kind": "role",
  "clean_email_free_mail": false,
  "clean_email_matches_website": true,
  "clean_emails_dropped": 0,
  "clean_address": "600 N Lamar Blvd Ste 200, Austin, TX 78703",
  "clean_street": "600 N Lamar Blvd Ste 200",
  "clean_city": "Austin",
  "clean_state": "TX",
  "clean_postal_code": "78703",
  "clean_country": "US",
  "clean_is_duplicate": false,
  "clean_duplicate_count": 1,
  "clean_quality_score": 100,
  "clean_quality_grade": "A",
  "clean_quality_reasons": [
    "+10 business name present",
    "+25 valid phone (landline_or_mobile)",
    "+25 website live (HTTPS)",
    "+25 business email, domain accepts mail (MX)",
    "+15 full street address"
  ]
}
```

Row 5 of the same run is the same business written differently. It is flagged, not charged:

```json
{
  "title": "24 Diner",
  "phone": "+1 (512) 472-5400",
  "address": "600 North Lamar Boulevard, Suite 200",
  "city": "Austin",
  "state": "TX",
  "postalCode": "78703",
  "clean_phone": "+15124725400",
  "clean_address": "600 N Lamar Blvd Ste 200, Austin, TX 78703",
  "clean_is_duplicate": true,
  "clean_duplicate_of_row": 4,
  "clean_duplicate_reason": "same phone; same name and address",
  "clean_quality_score": 50
}
```

More from the same run (shortened):

| Row | Business | Input | Result |
|---|---|---|---|
| 14 | Lick Honest Ice Creams | phone `512-363-562` | `clean_phone_valid: false` (one digit short), score 50 |
| 10 | Canary Roost Karaoke Bar | website `canary-roost.business.site` | `clean_website_status: "DEAD"`, HTTP 404 |
| 6 | Bennu Coffee Shop | email `bennucoffee@gmail.com`, no state | `clean_email_kind: "brand"`, `clean_email_free_mail: true`, state `TX` filled from the ZIP |
| 8, 9 | Starbucks (two branches) | same website domain, different addresses | both kept: different locations, not duplicates |

Other columns: `clean_phone_extension`, `clean_phone_original`, `clean_other_phones`, `clean_domain_source`, `clean_website_is_platform_page`, `clean_website_live`, `clean_website_final_url`, `clean_website_error`, `clean_email_valid_syntax`, `clean_email_disposable`, `clean_emails`, `clean_emails_invalid`, `clean_state_from_zip`, `clean_merged_fields`.

**Website status values:** `LIVE` (answers, including sites that turn automated visitors away with 401/403/429), `DEAD` (no DNS record, connection refused, or HTTP 404/410/5xx), `PARKED` (redirects to a domain-sale or parking page), `ERROR` (timed out or failed in a way that may be temporary), `PLATFORM_PAGE` (a Facebook, Yelp or delivery-app page, not checked).

**Run summary** (key-value store → `OUTPUT`). Real summary of the prefilled run:

```json
{
  "rowsIn": 16, "rowsOut": 16, "uniqueBusinesses": 13, "duplicateRows": 3, "duplicateGroups": 3, "rowsCharged": 13,
  "phones": { "valid": 14, "invalid": 1, "missing": 1 },
  "websites": { "live": 13, "dead": 1, "parked": 0, "checkFailed": 0, "platformPages": 0, "missing": 2, "uniqueSitesChecked": 13 },
  "emails": { "rowsWithEmail": 5, "noMailServer": 0, "freeMail": 1, "personEmailsDropped": 0, "invalidSyntax": 0, "domainsLookedUp": 5 },
  "quality": { "averageScore": 74, "grades": { "A": 5, "B": 7, "C": 3, "D": 1 } }
}
```

### How duplicates are decided

Two rows are the same business when they share **any** of the keys you enable in **Match duplicates by**:

- **Website domain**: `https://www.example.com/menu` and `example.com` match. Shared hosting subdomains (`name.business.site`, `name.wixsite.com`) count as separate sites, and social or delivery pages (Facebook, Yelp, Uber Eats…) never match.
- **Phone number**: compared in E.164, so `(512) 472-5400` and `+1 512.472.5400` match. Only valid numbers are compared.
- **Name + street address**: names compared without punctuation, "The" or legal suffixes (LLC, Inc), addresses after standardising (`600 North Lamar Boulevard` = `600 N Lamar Blvd`).

**Branches stay separate.** If two rows share a website or phone but have different street addresses, they're treated as different locations of one company, even through a chain of matches. Turn on **Merge branches of the same company** for one row per company.

**Which row is kept:** *Merge* (default) keeps the most complete row and fills its empty `clean_*` fields from the others; *Most complete* keeps that row unchanged; *First* keeps the first row in your list. Your original columns are never merged.

### Data handling and privacy

- **Company mailboxes only (default).** With **Drop person-looking emails** on, only company role mailboxes (info@, sales@, reservations@…) and the business's own name (bennucoffee@gmail.com for Bennu Coffee) are kept. Addresses that look like a person's (jane@, j.smith@) are removed from the output, **including from your original columns**, and counted in `clean_emails_dropped`. The detection is cautious: a misspelt role mailbox may also be dropped. Turn this off only if you have a lawful basis to process personal email addresses.
- **Only light, polite checks.** Each unique website gets one request to its homepage (plus one per redirect); the page content is not read or stored. Email domains are checked with a DNS MX lookup only. The Actor **never connects to a mail server or sends email**.
- **Your data stays yours.** Rows are processed in your run and written only to your run's dataset. Datasets you pick are opened read-only.
- Use the results lawfully: follow the anti-spam and privacy laws that apply to you (for example CAN-SPAM, GDPR, CASL) when contacting businesses.

### Pricing

- **$1.50 per 1,000 unique rows** ($0.0015 each)
- Duplicate rows: **free**, whether flagged or removed
- Websites and email domains that appear on several rows are checked once

### Limitations

- Address parsing is rule-based and tuned for the US and Canada (UK postcodes are formatted too). Other countries get whitespace and capitalisation fixes only. There is no geocoding and no check that the address exists.
- US and Canadian numbers can't be told apart as mobile or landline from the number alone, so they show `landline_or_mobile`. Other countries usually get an exact type.
- An MX record means the domain can receive email, not that a specific mailbox exists. For mailbox-level checks, use a dedicated email verification service.
- A `LIVE` website answered our request; it may still be a placeholder page. Sites that block automated visits are counted as live because the server exists.
- Up to 100,000 rows per run (default 10,000). Website checks take most of the time: in our tests 72 sites took about 17 seconds at the default 10 in parallel, so plan on roughly 4–5 minutes per 1,000 different websites. Turn off **Check websites are live** for a fast run.

The built-in sample uses real Austin, TX businesses from OpenStreetMap (© OpenStreetMap contributors, ODbL). Some rows were reformatted on purpose (spacing, capitals, phone formats, one phone cut short) to show the cleaning.

### FAQ

**How do I remove duplicate leads from a list?**

Give the list (dataset ID, CSV/Sheet link or pasted rows) and run. Rows are the same business when they share a website domain, a phone, or a name plus address; choose **Flag** to mark them or **Remove** to keep one row per business. Duplicates are never charged.

**How do I clean Google Maps scraper results?**

Pass the scraper run's dataset ID in **datasetId**. Columns are detected automatically; you get formatted phones, standardized addresses, live-website checks and a quality score.

**How do I check which websites in my lead list are dead?**

`clean_website_status` says LIVE, PARKED, DEAD or ERROR after one request to each homepage, with the HTTP status and any redirect target.

**Does it verify email addresses?**

It checks syntax and whether the domain has a mail server (MX), and flags free-mail and disposable domains. It never sends email or contacts a mail server, so it doesn't confirm individual mailboxes.

### Feedback

Need another column detected, a country's address format, or a different duplicate rule? Open an issue on the **Issues** tab.

# Actor input Schema

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

Clean the results of another Actor run: pick the dataset or paste its ID (Run → Storage → Dataset ID). Works with Google Maps, store-leads or any other dataset in your account; the Actor gets read-only access to that dataset only. Leave all three sources empty to try the built-in sample. Example: "aBcD1234eFgH5678".

## `sourceUrl` (type: `string`):

A link to a CSV or JSON file (comma, semicolon or tab separated). Google Sheets links work if the sheet is shared as "Anyone with the link". Example: "https://example.com/leads.csv".

## `rows` (type: `array`):

Rows pasted as a JSON array of objects, e.g. \[{"name": "Joe's Pizza", "phone": "(512) 555 0100", "website": "joespizza.com"}]. Rows from all sources you fill in are cleaned together.

## `fieldMapping` (type: `object`):

Columns are detected automatically (name/title, phone, website/url/domain, email/emails, address or street/city/state/postalCode/country). Override any of them with your column name, e.g. {"phone": "Main Phone", "name": "Company"}. Keys: name, phone, website, email, address, street, city, state, postalCode, country.

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

Two-letter country code used for phone numbers without a +country code when the row has no country, e.g. US, GB, CA, AU, DE. Example: "GB".

## `duplicates` (type: `string`):

Flag: keep every row and mark duplicates (clean_is_duplicate, clean_duplicate_of_row). Remove: output only one row per business. Duplicate rows are never charged either way. API values: "flag", "remove".

## `mergeStrategy` (type: `string`):

Merge (recommended): keep the most complete row and fill its missing phone, website, email or address from its duplicates. Most complete: keep the most complete row as is. First: keep the first row as is. API values: "merge", "keepMostComplete", "keepFirst".

## `dedupeBy` (type: `array`):

Rows are the same business when they share any of these. Website domain ignores social and platform pages (Facebook, Yelp…).

## `mergeLocations` (type: `boolean`):

Off (default): rows with the same website or phone but different street addresses are kept as separate locations (chains, franchises). On: one row per company.

## `checkWebsites` (type: `boolean`):

One request to each unique website's homepage: HTTP status, redirect target, HTTPS, parked domains. Page content is not read or stored.

## `checkEmailDns` (type: `boolean`):

Looks up each email domain's mail server (MX record) in DNS. No email is sent and no mail server is contacted.

## `dropPersonEmails` (type: `boolean`):

On (default): only company mailboxes are kept (info@, sales@, reservations@, the brand's own name). Addresses that look like a person's (jane@, j.smith@) are removed from the output, including from your original columns, and counted in clean_emails_dropped. Turn off only if you have a lawful basis to process personal emails.

## `tidyOriginalFields` (type: `boolean`):

Trim spaces, collapse double spaces, remove invisible characters and decode HTML entities in your original columns too. The cleaned values are always in the clean\_\* columns.

## `maxRows` (type: `integer`):

Stop after this many input rows. Example: 1000.

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

How many different websites are checked at the same time. Each site gets at most one request per redirect.

## Actor input object example

```json
{
  "defaultCountry": "US",
  "duplicates": "flag",
  "mergeStrategy": "merge",
  "dedupeBy": [
    "domain",
    "phone",
    "nameAddress"
  ],
  "mergeLocations": false,
  "checkWebsites": true,
  "checkEmailDns": true,
  "dropPersonEmails": true,
  "tidyOriginalFields": true,
  "maxRows": 10000,
  "maxConcurrency": 10
}
```

# Actor output Schema

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

No description

## `runSummary` (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 = {};

// Run the Actor and wait for it to finish
const run = await client.actor("signalloom/lead-list-cleaner").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 = {}

# Run the Actor and wait for it to finish
run = client.actor("signalloom/lead-list-cleaner").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 '{}' |
apify call signalloom/lead-list-cleaner --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,signalloom/lead-list-cleaner"
        }
    }
}
```

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/6lfamM5YIdChDPZl7/builds/8x8g0aLnUc38Sm8gC/openapi.json
