# Phone Number Validator & Email Cleaner: Contact Normalizer (`tinplatelabs/contact-normalizer`) Actor

Validate and format phone numbers (E.164, type, region) and clean emails, names and countries in bulk for CRM imports, lead lists and AI agents. Flags disposable, role and invalid emails, dedupes records. Offline, $0.0005 per record.

- **URL**: https://apify.com/tinplatelabs/contact-normalizer.md
- **Developed by:** [tinplatelabs](https://apify.com/tinplatelabs) (community)
- **Categories:** Lead generation, Developer tools, Automation
- **Stats:** 1 total users, 1 monthly users, 88.9% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

Pay per event

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

## Phone Number Validator and Email Cleaner: Contact Data Normalizer

**Validate and format phone numbers (E.164, national and international, with country and line type), check email
addresses (syntax, disposable, role and free-provider flags), tidy names, convert countries to ISO codes and find
duplicates, offline, in bulk, for $0.0005 per record.**

For CRM imports, lead lists, form exports and scraped contact data, and for AI agents (Claude, ChatGPT, Cursor and
others through the [Apify MCP server](https://mcp.apify.com)) that need contact data in one consistent shape before
they act on it.

### Use cases

- **Phone number validation and formatting:** `(650) 253-0000`, `020 7946 0958` and `+1 650-253-0000` become E.164.
- **Email list cleaning:** invalid syntax, disposable domains (mailinator...) and role accounts (`info@`) flagged.
- **CRM import and dedupe:** one consistent record shape, duplicates by email and/or phone marked or dropped.
- **Lead-scraper output:** pick the dataset of another Apify Actor and clean it in place of a CSV.
- **AI agents:** read `normalized` for clean values and `issues` for what's wrong, with a plain-English fix.

### Quick start

This input (the Actor's example) cleans four records in about 4 seconds for $0.002:

```json
{
  "records": [
    {"name": "ada LOVELACE", "email": " Ada.Lovelace@EXAMPLE.com ", "phone": "(650) 253-0000", "country": "USA"},
    {"name": "JEAN-LUC O'BRIEN", "email": "info@mailinator.com", "phone": "020 7946 0958", "country": "United Kingdom"},
    {"name": "Ada Lovelace", "email": "ada.lovelace@example.com", "phone": "+1 650-253-0000", "country": "us"},
    {"name": "grace hopper", "email": "grace@@example", "phone": "+1 555 12", "country": "Atlantis"}
  ],
  "defaultCountry": "US",
  "dedupe": "emailOrPhone"
}
```

One dataset item per record (abridged, real output from 2026-10-24):

```json
{"index": 1, "status": "ok", "valid": true,
 "normalized": {"name": "Jean-Luc O'Brien", "email": "info@mailinator.com", "phone": "+442079460958", "country": "GB"},
 "issues": [
   {"field": "email", "code": "EMAIL_DISPOSABLE", "severity": "warning", "message": "mailinator.com is a disposable (throwaway) mail domain."},
   {"field": "email", "code": "EMAIL_ROLE_ACCOUNT", "severity": "warning", "message": "'info@' is a role account that usually reaches a team, not a person."}
 ],
 "isDuplicate": false}
```

Record 2 comes back with `"isDuplicate": true, "duplicateOf": 0`, and record 3 with `"valid": false` and
`EMAIL_INVALID`, `PHONE_INVALID` and `COUNTRY_UNKNOWN` issues.

### Pricing in plain words

You pay **$0.0005 per record** ($0.50 per 1,000). Records that can't be read (not an object, no contact fields)
and dropped duplicates cost nothing, and `maxRecords` caps the cost of a run. Full table under
[Pricing](#pricing-pay-per-event).

### Why this one

- **Offline and private.** Phone checks use Google's libphonenumber metadata and email checks are pure syntax
  rules plus vendored lists. No carrier APIs, no LLMs, no telemetry. DNS is touched only if you turn on `checkMx`.
- **Cheap and fast.** One run handles thousands of records per second at 256 MB memory. $0.50 per 1,000 records.
- **Deterministic.** The same input always gives the same output, so you can diff and re-run safely.
- **Everything in one pass:** phones to E.164 with national/international formats, region and line type;
  emails with RFC syntax validation, normalization, disposable/role/free-provider flags and optional Gmail
  canonical form; names tidied; countries to ISO 3166-1 codes; optional dedupe by email and/or phone.
- **Agent-shaped output.** Each item has the untouched `original`, a drop-in `normalized` record (same keys,
  clean values), per-field `fields` details and an `issues` list with codes, severities and plain-English fixes.

### Input

Give records inline, an Apify dataset, or a CSV/JSON file URL (or a mix; they are processed in that order).

| Field | Type | Default | What it does |
|---|---|---|---|
| `records` | array of objects | | Inline records. Other columns are copied through. |
| `datasetId` | string | | Apify dataset to read, e.g. the output of a lead-scraping Actor. Pick it in Console or pass its ID (or `username~dataset-name`). The Actor runs with limited permissions: it can read only the dataset you give here. |
| `dataUrl` | string | | Public CSV (header row; `,` `;` tab or `|`), JSON array, `{"items": [...]}` or JSON Lines; UTF-8, UTF-16 or Windows-1252. Max 200 MB. |
| `fieldMapping` | object | detect | `{"email": [...], "phone": [...], "name": [...], "country": [...]}` column lists. Empty = detect by column name. |
| `defaultCountry` | string | `US` | Country for phones written without `+country code`. ISO code or name. Empty = require `+`. |
| `useRecordCountry` | boolean | `true` | Use the record's own country field for its phones. |
| `phoneFormat` | `e164` | `international` | `national` | `e164` | Format written into `normalized`. |
| `countryFormat` | `alpha2` | `alpha3` | `name` | `alpha2` | Format written into `normalized`. |
| `lowercaseEmail` | boolean | `true` | Lowercase the local part too (the domain is always lowercased). |
| `canonicalizeEmail` | boolean | `false` | Add `canonical`: Gmail dots and +tags removed, +tags removed for Outlook/iCloud/Fastmail/Proton. Used by dedupe. |
| `checkMx` | boolean | `false` | DNS MX lookup per email domain (cached per domain). |
| `dedupe` | `none` | `email` | `phone` | `emailOrPhone` | `none` | Mark duplicates across the whole run. |
| `dropDuplicates` | boolean | `false` | Leave duplicates out of the output (and the bill). |
| `maxRecords` | integer | 1,000,000 | Stop after this many records. |

Column detection: `email`, `e-mail`, `Email Address` → email; `phone`, `mobile`, `tel`, `cell`, `fax`,
`whatsapp`, `mobilePhone` → phone; `name`, `first name`, `last_name`, `surname`, `contact name` → name
(but not `company name`, `username`, `file name`...); anything containing `country` → country.

```json
{
  "records": [
    {"name": "ada LOVELACE", "email": " Ada.Lovelace@EXAMPLE.com ", "phone": "(650) 253-0000", "country": "USA", "company": "Example Inc"},
    {"name": "grace hopper", "email": "info@mailinator.com", "phone": "+1 555 12", "country": "Atlantis"}
  ],
  "defaultCountry": "US",
  "dedupe": "emailOrPhone"
}
```

### Output

One dataset item per input record, in input order:

```json
{
  "index": 0,
  "status": "ok",
  "valid": true,
  "original": {"name": "ada LOVELACE", "email": " Ada.Lovelace@EXAMPLE.com ", "phone": "(650) 253-0000", "country": "USA", "company": "Example Inc"},
  "normalized": {"name": "Ada Lovelace", "email": "ada.lovelace@example.com", "phone": "+16502530000", "country": "US", "company": "Example Inc"},
  "fields": {
    "name": {"kind": "name", "input": "ada LOVELACE", "normalized": "Ada Lovelace", "recased": true},
    "email": {"kind": "email", "input": " Ada.Lovelace@EXAMPLE.com ", "valid": true, "normalized": "ada.lovelace@example.com",
              "canonical": null, "local": "ada.lovelace", "domain": "example.com",
              "disposable": false, "role": false, "freeProvider": false, "mx": null},
    "phone": {"kind": "phone", "input": "(650) 253-0000", "valid": true, "possible": true, "e164": "+16502530000",
              "national": "(650) 253-0000", "international": "+1 650-253-0000", "region": "US", "countryCode": 1,
              "type": "fixed_line_or_mobile", "extension": null},
    "country": {"kind": "country", "input": "USA", "valid": true, "alpha2": "US", "alpha3": "USA", "numeric": "840", "name": "United States"}
  },
  "issues": [],
  "isDuplicate": false,
  "duplicateOf": null
}
```

The second example record comes back with `"valid": false` and these issues:

```json
[
  {"field": "email", "code": "EMAIL_DISPOSABLE", "severity": "warning", "message": "mailinator.com is a disposable (throwaway) mail domain."},
  {"field": "email", "code": "EMAIL_ROLE_ACCOUNT", "severity": "warning", "message": "'info@' is a role account that usually reaches a team, not a person."},
  {"field": "phone", "code": "PHONE_INVALID", "severity": "error", "message": "'+1 555 12' is not a valid number for country code +1 (wrong length or unassigned range). Check the digits and the country."},
  {"field": "country", "code": "COUNTRY_UNKNOWN", "severity": "error", "message": "'Atlantis' is not a recognised country. Use an ISO 3166 code (e.g. 'DE', 'DEU') or an English name."}
]
```

- `valid` is false when any issue has severity `error`. Warnings (disposable, role account, duplicate,
  MX lookup failed) leave it true.
- Invalid values stay in `normalized` as the trimmed input, so no data is lost.
- Phone `type`: `mobile`, `fixed_line`, `fixed_line_or_mobile` (US/Canada numbers can't be told apart
  offline), `toll_free`, `premium_rate`, `shared_cost`, `voip`, `personal_number`, `pager`, `uan`, `voicemail`, `unknown`.
- Issue codes: `PHONE_UNPARSEABLE`, `PHONE_NO_COUNTRY`, `PHONE_INVALID`, `EMAIL_INVALID`, `EMAIL_DISPOSABLE`,
  `EMAIL_ROLE_ACCOUNT`, `EMAIL_NO_MX`, `EMAIL_MX_LOOKUP_FAILED`, `COUNTRY_UNKNOWN`, `DUPLICATE`, and for
  `status: "error"` items `NOT_AN_OBJECT` and `NO_CONTACT_FIELDS`. A dataset that can't be read gives one error item
  (`index: null`, `source: "datasetId"`) with `DATASET_ACCESS_DENIED`, `DATASET_NOT_FOUND` or `DATASET_READ_FAILED`
  and the fix; the other inputs are still processed and the error item is not charged.
- The key-value store record `OUTPUT` holds a run summary: `records`, `normalized`, `valid`, `withIssues`,
  `errors`, `duplicates`, `duplicatesDropped`, `charged`, `stoppedReason`.

Export the dataset as CSV or Excel from Apify to get flat columns such as `normalized.email`.

### Pricing (pay per event)

| Event | Price | When |
|---|---|---|
| `record-normalized` | **$0.0005** | Each record processed (including records with validation issues, which are the result you asked for) |
| Actor start | set by Apify | Per run |

10,000 records cost $5. Error items (a record that isn't an object, or has no contact fields) and dropped
duplicates are free. If the run's maximum total charge is reached, the Actor stops cleanly and `OUTPUT` says so.

### Limits

- Up to 1,000,000 records per run; `dataUrl` files up to 200 MB (streamed: 1M rows run in 256 MB).
- A file problem never loses the rest of the run: an unreadable, malformed or unsupported file (`SOURCE_UNREADABLE`,
  `FILE_TOO_LARGE`, `UNSUPPORTED_FILE`, `CSV_MALFORMED`, `JSON_INVALID`) becomes one error item and records read
  before it are kept. If memory runs low the run stops cleanly (`MEMORY_LOW`) and says to raise it. Error items
  are never charged; the run's status message summarizes the result.
- Phone line type comes from numbering-plan metadata, not a live carrier lookup: ported numbers keep their
  original type, and there is no carrier name or reachability check.
- Email checks are syntax plus lists; `checkMx` confirms the domain accepts mail but never contacts a mailbox (no SMTP probing).
- Names are tidied, not split or gender-guessed. Casing is changed only when it is clearly mechanical
  (ALL CAPS or all lowercase words); hand-cased names like `DeLuca` are left alone.
- Disposable-domain list as of the release date (see licenses); new throwaway domains appear daily.

### Privacy

Records are processed inside your run and written only to that run's dataset and key-value store, which follow
your Apify data-retention settings. Nothing is sent to any third party and no LLM sees your data. The only
exception is the optional `checkMx` check, which sends each email **domain** (never the full address) to DNS
resolvers. With `dataUrl`, the Actor downloads only that exact file.

### Use from an AI agent (MCP)

Add the Apify MCP server to your agent and enable this Actor. Call it with `{"records": [...]}` and read
`normalized` for clean values and `issues` for what to fix or ask the user about.

### Open-source licenses

All dependencies are permissively licensed (no GPL/LGPL; pycountry is deliberately not used). CI fails on any
copyleft dependency.

| Component | License | Role |
|---|---|---|
| [phonenumbers](https://github.com/daviddrysdale/python-phonenumbers) 9.0.41 (libphonenumber metadata) | Apache-2.0 | Phone parsing, formatting, type |
| [email-validator](https://github.com/JoshData/python-email-validator) 2.3.0 | Unlicense | Email syntax, normalization, optional MX check |
| dnspython 2.9.0, idna 3.20 | ISC / BSD-3-Clause | DNS (MX check only), internationalized domains |
| [iso3166](https://github.com/deactivated/python-iso3166) 3.0.0 | MIT | ISO 3166-1 country codes and names |
| [disposable-email-domains](https://github.com/disposable-email-domains/disposable-email-domains) list (vendored, 2026-10-10) | CC0-1.0 | Disposable domain flag |
| httpx 0.28.1, httpcore, h11, anyio | BSD-3-Clause / MIT | `dataUrl` download |
| Apify SDK for Python 4.1.0, crawlee | Apache-2.0 | Actor runtime |

The free-provider and role-account lists and the country aliases were written for this Actor.

# Actor input Schema

## `records` (type: `array`):

Contact records to validate and clean (phone numbers to E.164, emails checked, names and countries tidied), as JSON objects, e.g. <code>\[{"name": "ada LOVELACE", "email": "Ada@Example.com", "phone": "(650) 253-0000", "country": "USA"}]</code>. Any other columns are copied through unchanged. You can also (or instead) use <b>datasetId</b> or <b>dataUrl</b>.

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

Read records from an Apify dataset, for example the output of a lead-scraping Actor: pick it or paste its ID (or <code>username~dataset-name</code>). Each dataset item is one record. The Actor gets read access to the dataset you choose here and to nothing else in your account.

## `dataUrl` (type: `string`):

Public http(s) URL of a CSV file with a header row (comma, semicolon, tab or pipe separated), a JSON array, an object with an <code>items</code> array, or JSON Lines, in UTF-8, UTF-16 or Windows-1252. Max 200 MB.

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

Which columns hold which kind of data, e.g. <code>{"email": \["Work email"], "phone": \["Mobile", "Office"], "name": \["First", "Last"], "country": \["Ctry"]}</code>. Leave empty to detect columns by name (email, e-mail, phone, mobile, tel, fax, name, first name, surname, country...). When set, only the listed columns are normalized.

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

Country used to read phone numbers written without a country code, as an ISO code or name (US, DE, GB, Canada...). Leave empty to require '+' country codes.

## `useRecordCountry` (type: `boolean`):

If a record has a recognised country field, read its phone numbers with that country instead of the default.

## `phoneFormat` (type: `string`):

All three formats are always in <code>fields</code>; this picks the one written into <code>normalized</code>.

## `countryFormat` (type: `string`):

All formats are always in <code>fields</code>; this picks the one written into <code>normalized</code>.

## `lowercaseEmail` (type: `boolean`):

Domains are always lowercased. This also lowercases the part before @ (almost every mail server ignores its case).

## `canonicalizeEmail` (type: `boolean`):

Add a <code>canonical</code> address: Gmail/Googlemail dots and +tags removed, +tags removed for Outlook, iCloud, Fastmail and Proton. Dedupe then uses it. The normalized address itself is not changed.

## `checkMx` (type: `boolean`):

Look up each email domain's mail servers in DNS (once per domain). Off by default: the Actor is then fully offline. Slower, and the domain names are sent to DNS resolvers.

## `dedupe` (type: `string`):

Mark records whose (normalized) email and/or E.164 phone already appeared earlier in this run. Uses the canonical email when <b>canonicalizeEmail</b> is on.

## `dropDuplicates` (type: `boolean`):

Omit duplicates (they are not charged). Otherwise they are kept and marked with <code>isDuplicate</code> and <code>duplicateOf</code>.

## `maxRecords` (type: `integer`):

Stop after this many records. Use it to cap cost on large inputs.

## Actor input object example

```json
{
  "records": [
    {
      "name": "ada LOVELACE",
      "email": " Ada.Lovelace@EXAMPLE.com ",
      "phone": "(650) 253-0000",
      "country": "USA"
    },
    {
      "name": "JEAN-LUC O'BRIEN",
      "email": "info@mailinator.com",
      "phone": "020 7946 0958",
      "country": "United Kingdom"
    },
    {
      "name": "Ada Lovelace",
      "email": "ada.lovelace@example.com",
      "phone": "+1 650-253-0000",
      "country": "us"
    },
    {
      "name": "grace hopper",
      "email": "grace@@example",
      "phone": "+1 555 12",
      "country": "Atlantis"
    }
  ],
  "defaultCountry": "US",
  "useRecordCountry": true,
  "phoneFormat": "e164",
  "countryFormat": "alpha2",
  "lowercaseEmail": true,
  "canonicalizeEmail": false,
  "checkMx": false,
  "dedupe": "none",
  "dropDuplicates": false,
  "maxRecords": 1000000
}
```

# Actor output Schema

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

All normalized records and per-record error items as JSON.

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

Counts of records, normalized, valid, with issues, errors, duplicates and charged, plus stoppedReason.

# 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 = {
    "records": [
        {
            "name": "ada LOVELACE",
            "email": " Ada.Lovelace@EXAMPLE.com ",
            "phone": "(650) 253-0000",
            "country": "USA"
        },
        {
            "name": "JEAN-LUC O'BRIEN",
            "email": "info@mailinator.com",
            "phone": "020 7946 0958",
            "country": "United Kingdom"
        },
        {
            "name": "Ada Lovelace",
            "email": "ada.lovelace@example.com",
            "phone": "+1 650-253-0000",
            "country": "us"
        },
        {
            "name": "grace hopper",
            "email": "grace@@example",
            "phone": "+1 555 12",
            "country": "Atlantis"
        }
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("tinplatelabs/contact-normalizer").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 = { "records": [
        {
            "name": "ada LOVELACE",
            "email": " Ada.Lovelace@EXAMPLE.com ",
            "phone": "(650) 253-0000",
            "country": "USA",
        },
        {
            "name": "JEAN-LUC O'BRIEN",
            "email": "info@mailinator.com",
            "phone": "020 7946 0958",
            "country": "United Kingdom",
        },
        {
            "name": "Ada Lovelace",
            "email": "ada.lovelace@example.com",
            "phone": "+1 650-253-0000",
            "country": "us",
        },
        {
            "name": "grace hopper",
            "email": "grace@@example",
            "phone": "+1 555 12",
            "country": "Atlantis",
        },
    ] }

# Run the Actor and wait for it to finish
run = client.actor("tinplatelabs/contact-normalizer").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 '{
  "records": [
    {
      "name": "ada LOVELACE",
      "email": " Ada.Lovelace@EXAMPLE.com ",
      "phone": "(650) 253-0000",
      "country": "USA"
    },
    {
      "name": "JEAN-LUC O'\''BRIEN",
      "email": "info@mailinator.com",
      "phone": "020 7946 0958",
      "country": "United Kingdom"
    },
    {
      "name": "Ada Lovelace",
      "email": "ada.lovelace@example.com",
      "phone": "+1 650-253-0000",
      "country": "us"
    },
    {
      "name": "grace hopper",
      "email": "grace@@example",
      "phone": "+1 555 12",
      "country": "Atlantis"
    }
  ]
}' |
apify call tinplatelabs/contact-normalizer --silent --output-dataset

```

## MCP server setup

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

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/7mvDzK8vaOgtt5eHp/builds/M6dg1IEw56kj7GybN/openapi.json
