# Lead List Cleaner (`zinin/lead-list-cleaner`) Actor

Turn any scraper's raw dataset into a clean, scored lead list — dedupe rows, kill dead emails and dead domains, rank the rest by hotness. Runs standalone or as an Integration bolted onto Google Maps / lead scrapers.

- **URL**: https://apify.com/zinin/lead-list-cleaner.md
- **Developed by:** [Tim Zinin](https://apify.com/zinin) (community)
- **Categories:** Lead generation, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $8.50 / 1,000 cleaned lead rows

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/platform/actors/running/actors-in-store#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

## Lead List Cleaner

Turn any scraper's raw lead dump — a Google Maps export, a directory scrape, a CSV
turned into JSON — into a clean, deduped, scored list ready to work. Every email gets a
real syntax + MX check, every domain gets a real DNS-alive check, duplicates collapse
into one row, and what's left is ranked by hotness so you know who to call first.

### What you get

- Works on **any row shape** — no fixed schema. Point it at a Google Maps scraper
  export, a CSV turned into JSON, or a pasted list — it finds the email, domain, name
  and phone wherever the source row put them.
- Duplicate leads (same domain, or same email when there's no domain) collapse into one
  row, so you never pay to see the same company twice.
- Every email gets a real syntax + MX check, not a regex guess — dead addresses, role
  accounts (`info@`, `support@`...) and known disposable domains are all flagged.
- Every domain gets a real DNS alive/dead check — a company that folded two years ago
  doesn't make your call list.
- A 0-100 hotness score for what's left, so the leads worth calling first float to the
  top.
- Runs standalone, **or** as an Integration bolted straight onto the scraper you already
  use — see "How to run it" below.
- Runs on Apify: schedule it, monitor it, call it from the API, export to JSON/CSV/Excel
  or push straight into your own pipeline.

### How to run it

Three ways in — pick whichever fits your workflow.

1. **Paste rows directly.** Click **Try for free**, paste your lead rows into **Items**
   — any shape works — and hit **Start**.
2. **Point it at a dataset.** Leave **Items** empty, use the **Dataset ID** picker to
   select another Actor's run (or a public JSON URL into **Dataset URL**), then hit
   **Start**. Picking the dataset through that field — not just having its ID — is what
   grants this run read access to it; a dataset you didn't pick there is only reachable
   if it's public.
3. **Add it as an Integration** — how most buyers will actually use this. Open the
   scraper Actor you already run (a Google Maps scraper, a directory scraper, anything
   that produces a dataset of leads), go to its **Integrations** tab, click **Add
   integration**, and pick **Lead List Cleaner**. Leave **Dataset ID** empty in the
   integration's prefilled input — this Actor reads it automatically from the
   triggering run. From then on, every successful run of that scraper feeds straight
   into this one, no manual step required. One honest caveat: what actually grants
   access to another Actor's dataset is picking it through the **Dataset ID** field —
   that's a real permission grant, not just an ID. The raw
   `payload.resource.defaultDatasetId` this Actor also reads as a fallback carries no
   such grant, so it only succeeds when the triggering dataset happens to be public; on
   a private dataset it fails, and you'll see that failure spelled out in the run's free
   output row rather than a silent empty result.

### Pricing

Pay-per-event: **$0.005 per run start + $0.01 per cleaned lead delivered**. No monthly
seat, no minimum. 100 leads cost about **$1.01**; 1,000 leads about **$10.01**.

Both prices step down automatically with Apify's own account tier — from
$0.005/$0.01 on the FREE plan to $0.004/$0.008 on DIAMOND (up to 20% off, the standard
discount ladder every Actor in this fleet runs on, not a volume discount tied to how
many leads you clean) — this Actor's own Pricing tab shows what applies to your account.

The first **25 clean leads of every run are free** — no card, no commitment, see it
work on your own data before you pay for a single row. Rejected rows (duplicates, dead
emails, dead domains, below your score cutoff) and the final summary row are **always
free, on every run** — you only ever pay for a lead that actually passed.

### Input

| Field | Required | What it does |
|---|---|---|
| `items` | no | Raw lead rows to clean directly — any shape (a Google Maps scraper export, a CSV turned into JSON, anything with an email/website/phone somewhere in it). Takes priority over `datasetId` and `datasetUrl`. |
| `datasetId` | no | Another Actor's dataset to clean, chosen through the dataset picker (not a plain text ID) — picking it there is what grants this run read access to it. Filled in automatically when this Actor runs as an Integration — leave it empty in that setup. |
| `datasetUrl` | no | Public URL returning a JSON array of lead rows. Used only when both `items` and `datasetId` are empty. |
| `checks` | no | Pipeline stages to run: `dedupe`, `email`, `domain`, `score`. Default: all four. |
| `minScore` | no | Drop leads scoring below this (0-100) from the clean, billed output. They still come back as free rejected rows with a reason. Default `0`. |
| `maxRows` | no | Safety cap on how many input rows this run will process, regardless of source. Default `1000`. |

```json
{
    "items": [
        { "title": "Shopify", "categoryName": "Software company", "website": "https://www.shopify.com", "phone": "+1 888-746-7439", "url": "https://www.google.com/maps/place/Shopify" },
        { "name": "HubSpot", "email": "press@hubspot.com", "phoneNumber": "+1 888-482-7768", "link": "https://www.hubspot.com" },
        { "companyName": "Zoom Video Communications", "description": "Video conferencing platform. Contact us at info@zoom.us for enterprise sales.", "domain": "zoom.us" },
        { "title": "Shopify Inc", "website": "https://shopify.com", "phone": "+1 888-746-7439" },
        { "title": "Stripe", "categoryName": "Payment processor", "website": "https://stripe.com", "phone": "+1 888-926-2289", "emails": ["support@stripe.com"] }
    ],
    "checks": ["dedupe", "email", "domain", "score"],
    "minScore": 0,
    "maxRows": 1000
}
```

### Output

One dataset row per clean lead — this is a real row from a real run of the exact input
above:

```json
{
    "name": "Stripe",
    "email": "support@stripe.com",
    "domain": "stripe.com",
    "phone": "+1 888-926-2289",
    "sourceUrl": null,
    "raw": { "title": "Stripe", "categoryName": "Payment processor", "website": "https://stripe.com", "phone": "+1 888-926-2289", "emails": ["support@stripe.com"] },
    "emailStatus": "valid",
    "emailFlags": ["role:support"],
    "domainStatus": "alive",
    "techStack": [],
    "hotnessScore": 70,
    "scoreReasons": ["valid email +40", "alive domain +30"],
    "found": true,
    "checkedAt": "2026-08-03T15:11:21.886Z"
}
```

The fourth row of that same run — "Shopify Inc" — shares its domain with the very first
row ("Shopify"), so it comes back as a free, rejected duplicate instead:

```json
{
    "name": "Shopify Inc",
    "email": null,
    "domain": "shopify.com",
    "phone": "+1 888-746-7439",
    "sourceUrl": null,
    "raw": { "title": "Shopify Inc", "website": "https://shopify.com", "phone": "+1 888-746-7439" },
    "emailStatus": null,
    "emailFlags": [],
    "domainStatus": null,
    "techStack": [],
    "hotnessScore": 0,
    "scoreReasons": [],
    "rejected": true,
    "rejectReasons": ["duplicate"],
    "found": false,
    "checkedAt": "2026-08-03T15:11:21.885Z"
}
```

The final row of every run is a free summary, never billed — for this same 5-row input:

```json
{
    "rowType": "summary",
    "totalIn": 5,
    "clean": 4,
    "rejected": 1,
    "billed": 0,
    "freeDemo": 4,
    "pushFailed": 0,
    "rejectReasons": { "duplicate": 1 }
}
```

| Field | Meaning |
|---|---|
| `name` / `email` / `domain` / `phone` / `sourceUrl` | Normalized from whatever fields the source row actually had. `null` when that piece wasn't found anywhere in the row. |
| `emailStatus` | `valid` (syntax + MX both check out), `dead` (confirmed: bad syntax, the domain doesn't exist, or it can't receive mail at all), `risky` (a DNS resolver hiccup — not a confirmed answer either way), or `null` when there was nothing to check (no email found in the row, or `email` wasn't in `checks`). |
| `emailFlags` | `role:info` / `role:support` / etc. for shared-inbox addresses, `disposable` for a known temp-mail domain. Both are free bonus signals from a static list, not exhaustive. |
| `domainStatus` | `alive` (resolves, or has working mail servers), `dead` (confirmed: no DNS records at all), `unknown` (a resolver hiccup, never treated as dead), or `null` when there was nothing to check. |
| `hotnessScore` | 0-100, additive: `+40` valid email, `+30` alive domain, `+15` a non-role email address. `0` (with `scoreReasons: []`) if the `score` stage wasn't included in `checks`. |
| `scoreReasons` | Plain-text breakdown of exactly which bonuses added up to `hotnessScore`. |
| `techStack` | Reserved for a future check — currently always `[]`. The `+15` scoring bonus for tech signals is wired up and ready for when it ships. |
| `rejected` / `rejectReasons` | Present only on filtered-out rows. Reasons: `duplicate`, `dead-email`, `dead-domain`, `below-min-score`, `no-contact-signals` (no email AND no domain were found anywhere in the row — nothing here to ever contact). |
| `found` | `true` for a clean, billed lead; `false` for a rejected row or a processing error. |
| `raw` | The original row, completely untouched, for anything not extracted above. |
| `billed` + `freeDemo` (summary row) | How many of `clean` were actually charged vs. delivered free under the 25-lead demo cap. |
| `pushFailed` (summary row) | Always `0` on a normal run — rises only if a row genuinely couldn't be delivered to your dataset. |

#### Related tools

Related tools for adjacent workflows in B2B lead generation and data enrichment.

| Actor | What it does |
|---|---|
| [B2B Lead Enricher](https://apify.com/zinin/b2b-lead-enricher) | Pair it in the B2B lead generation and data enrichment workflow: Turn a list of company websites into sales-qualified lead cards: detected tech stack, a rough revenue... |
| [Company Profile Lookup](https://apify.com/zinin/company-lookup) | Pair it in the B2B lead generation and data enrichment workflow: Turn a domain or company name into one unified company card: website tech stack (CMS, ecommerce, key tech)... |
| [Company Registry Enricher](https://apify.com/zinin/company-registry-enricher) | Pair it in the B2B lead generation and data enrichment workflow: Turn a company name, LEI or UK company number into an official registry card: legal name, status,... |
| [Structured Data Extractor](https://apify.com/zinin/structured-extract) | Pair it in the B2B lead generation and data enrichment workflow: Turn any URL into clean structured JSON — title, description, image, JSON-LD, headings, links, emails and... |
| [Company Hiring Radar](https://apify.com/zinin/company-hiring-radar) | Pair it in the B2B lead generation and data enrichment workflow: Pull every open role a company is hiring for from its public job board (Greenhouse, Lever, Ashby) and turn... |

### FAQ / Limitations

**Does it need an API key, a login, or a browser?** No — just DNS lookups, and
optionally reading a dataset or URL you point it at. No proxies, no browser, no
scraping of its own.

**Why did a real company's email or domain come back `risky` / `unknown`?** That means
the DNS resolver didn't return a confirmed answer within the 12-second timeout — not
that the address or domain is actually bad. A confirmed `dead` verdict (no such domain,
or no mail servers at all) is a different, stronger signal, and the two are never
collapsed into each other.

**Why doesn't `domainStatus` flag a for-sale or placeholder page as dead?** It's a
live/dead call from DNS records alone — `alive`, `dead`, or `unknown`. This Actor
doesn't fetch the homepage or judge what's actually being served; a domain that
resolves comes back `alive` even if nobody's home yet.

**My lead has a non-Latin or accented email address and it came back `null`.** Email
extraction currently matches ASCII addresses only — an internationalized (Unicode)
local part or domain in the source text won't be picked up. Put it in a dedicated
`email` field and it will still be tried, but a genuinely non-ASCII address itself
won't match.

**Does a personal Gmail / Yahoo / Outlook / mail.ru address count as the lead's own
domain?** No. Free-mail providers are excluded from domain extraction on purpose — two
different companies both using `@gmail.com` must never collapse into one deduped
"lead," and a free-mail provider's own domain is always alive regardless of who's
behind the address.

**What happens if my source is empty?** A soft success, not a failure — you get one
free explanatory row telling you which input source was empty or missing, and why. No
charge beyond the automatic run-start fee.

**Who pays when this runs as an Integration, triggered by another Actor's run?**
Apify's own documentation doesn't spell this out explicitly for third-party
Integrations, and we haven't had this confirmed by Apify yet. Our working assumption,
until it is: the account that configured the Integration is billed. We'll update this
line the moment we have a confirmed answer.

**Can I call it from an AI agent?** Yes — standard Apify Actor, callable from the Apify
API, the SDK, or the Apify MCP server.

**What this is NOT.** Not a live mailbox check — no SMTP conversation, ever;
`emailStatus: valid` means the domain can receive mail, not that one specific inbox
exists. Not a company-enrichment tool — there's no tech-stack, firmographic or social
lookup here, just contact-signal validation and scoring. Not a web scraper — it never
fetches a lead's own website.

Found a wrong result, or need a check we don't run? Open an issue on this Actor's page.

***

Built by [zinin](https://apify.com/zinin). Questions? Telegram [@timzinin](https://t.me/timzinin).

# Actor input Schema

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

Pick another Actor's dataset to clean (e.g. a Google Maps or lead-scraper run). Ignored when 'items' is set. Picking it here — not just pasting the ID — is what grants this run's token READ access to that dataset. When this Actor is added as an Integration on another Actor, this field is filled in automatically from the triggering run.

## `datasetUrl` (type: `string`):

Public URL returning a JSON array of lead rows. Used only when both 'items' and 'datasetId' are empty.

## `items` (type: `array`):

Raw lead rows to clean directly, one object per lead — any shape works (Google Maps scraper export, CSV turned into JSON, anything with an email/website/phone somewhere in it). Takes priority over 'datasetId' and 'datasetUrl'.

## `checks` (type: `array`):

Pipeline stages to run, in order. 'dedupe' collapses rows sharing the same domain/email. 'email' verifies syntax + MX. 'domain' verifies the domain is alive. 'score' computes the hotness score used for the 'minScore' cutoff.

## `minScore` (type: `integer`):

Drop leads scoring below this threshold (0-100) from the clean, billed output. They still show up as free rejected rows with a reason.

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

Safety cap on how many input rows this run will process, regardless of source.

## Actor input object example

```json
{
  "items": [
    {
      "title": "Shopify",
      "categoryName": "Software company",
      "website": "https://www.shopify.com",
      "phone": "+1 888-746-7439",
      "url": "https://www.google.com/maps/place/Shopify"
    },
    {
      "name": "HubSpot",
      "email": "press@hubspot.com",
      "phoneNumber": "+1 888-482-7768",
      "link": "https://www.hubspot.com"
    },
    {
      "companyName": "Zoom Video Communications",
      "description": "Video conferencing platform. Contact us at info@zoom.us for enterprise sales.",
      "domain": "zoom.us"
    },
    {
      "title": "Shopify Inc",
      "website": "https://shopify.com",
      "phone": "+1 888-746-7439"
    },
    {
      "title": "Stripe",
      "categoryName": "Payment processor",
      "website": "https://stripe.com",
      "phone": "+1 888-926-2289",
      "emails": [
        "support@stripe.com"
      ]
    }
  ],
  "checks": [
    "dedupe",
    "email",
    "domain",
    "score"
  ],
  "minScore": 0,
  "maxRows": 1000
}
```

# Actor output Schema

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

API URL for the default dataset items produced by this run.

# 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 = {
    "items": [
        {
            "title": "Shopify",
            "categoryName": "Software company",
            "website": "https://www.shopify.com",
            "phone": "+1 888-746-7439",
            "url": "https://www.google.com/maps/place/Shopify"
        },
        {
            "name": "HubSpot",
            "email": "press@hubspot.com",
            "phoneNumber": "+1 888-482-7768",
            "link": "https://www.hubspot.com"
        },
        {
            "companyName": "Zoom Video Communications",
            "description": "Video conferencing platform. Contact us at info@zoom.us for enterprise sales.",
            "domain": "zoom.us"
        },
        {
            "title": "Shopify Inc",
            "website": "https://shopify.com",
            "phone": "+1 888-746-7439"
        },
        {
            "title": "Stripe",
            "categoryName": "Payment processor",
            "website": "https://stripe.com",
            "phone": "+1 888-926-2289",
            "emails": [
                "support@stripe.com"
            ]
        }
    ],
    "checks": [
        "dedupe",
        "email",
        "domain",
        "score"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("zinin/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 = {
    "items": [
        {
            "title": "Shopify",
            "categoryName": "Software company",
            "website": "https://www.shopify.com",
            "phone": "+1 888-746-7439",
            "url": "https://www.google.com/maps/place/Shopify",
        },
        {
            "name": "HubSpot",
            "email": "press@hubspot.com",
            "phoneNumber": "+1 888-482-7768",
            "link": "https://www.hubspot.com",
        },
        {
            "companyName": "Zoom Video Communications",
            "description": "Video conferencing platform. Contact us at info@zoom.us for enterprise sales.",
            "domain": "zoom.us",
        },
        {
            "title": "Shopify Inc",
            "website": "https://shopify.com",
            "phone": "+1 888-746-7439",
        },
        {
            "title": "Stripe",
            "categoryName": "Payment processor",
            "website": "https://stripe.com",
            "phone": "+1 888-926-2289",
            "emails": ["support@stripe.com"],
        },
    ],
    "checks": [
        "dedupe",
        "email",
        "domain",
        "score",
    ],
}

# Run the Actor and wait for it to finish
run = client.actor("zinin/lead-list-cleaner").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print("💾 Check your data here: https://console.apify.com/storage/datasets/" + run["defaultDatasetId"])
for item in client.dataset(run["defaultDatasetId"]).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "items": [
    {
      "title": "Shopify",
      "categoryName": "Software company",
      "website": "https://www.shopify.com",
      "phone": "+1 888-746-7439",
      "url": "https://www.google.com/maps/place/Shopify"
    },
    {
      "name": "HubSpot",
      "email": "press@hubspot.com",
      "phoneNumber": "+1 888-482-7768",
      "link": "https://www.hubspot.com"
    },
    {
      "companyName": "Zoom Video Communications",
      "description": "Video conferencing platform. Contact us at info@zoom.us for enterprise sales.",
      "domain": "zoom.us"
    },
    {
      "title": "Shopify Inc",
      "website": "https://shopify.com",
      "phone": "+1 888-746-7439"
    },
    {
      "title": "Stripe",
      "categoryName": "Payment processor",
      "website": "https://stripe.com",
      "phone": "+1 888-926-2289",
      "emails": [
        "support@stripe.com"
      ]
    }
  ],
  "checks": [
    "dedupe",
    "email",
    "domain",
    "score"
  ]
}' |
apify call zinin/lead-list-cleaner --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "command": "npx",
            "args": [
                "mcp-remote",
                "https://mcp.apify.com/?tools=zinin/lead-list-cleaner",
                "--header",
                "Authorization: Bearer <YOUR_API_TOKEN>"
            ]
        }
    }
}

```

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/rxPSjoRfq3pODDMe6/builds/fKDhJXowD6hssRGe0/openapi.json
