# Phone Number Normalization E.164 (`first_watch/phone-number-normalization-e-164`) Actor

Phone lists from forms, scrapers, CRM exports and spreadsheets are messy: mixed formats, 00/011 prefixes, '(0)' trunk digits, Excel scientific-notation damage, several numbers in one cell, and duplicates. Automation users (n8n, Make, Zapier, Clay, CRM) need every row returned with a validated...

- **URL**: https://apify.com/first_watch/phone-number-normalization-e-164.md
- **Developed by:** [Jordan Nabbe](https://apify.com/first_watch) (community)
- **Categories:** Automation, Business, Developer tools
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.25 / 1,000 normalized phone rows

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 Normalization E.164

Clean phone lists offline before you import or message them. This Actor is built for RevOps and lead-gen teams, WhatsApp and SMS campaign operators, and n8n, Make, Zapier and Clay users. It reads your own contact list as inline records, CSV text, JSON text or a real CSV, JSON or XLSX file, and repairs common spreadsheet damage. Every phone value it can read is converted to E.164 plus national, international, RFC 3966 and WhatsApp formats, and duplicate numbers are flagged. All original columns are kept, so the rows go straight back into a CRM, spreadsheet, database or LLM pipeline. Numbers are processed inside the run and never sent to a lookup service.

### What it does

- Reads inline objects, CSV text, JSON text and base64-encoded CSV, JSON or XLSX files. XLSX workbooks are parsed from the actual file bytes with ExcelJS. CSV is parsed with Papa Parse, including quoted fields.
- Converts national and international notations into E.164, national, international and RFC 3966 strings, plus WhatsApp digits and a wa.me link. The link is generated but never opened.
- Takes each row's country from a column you choose (ISO code, English country name or calling code) and falls back to a default country. An explicit international prefix always takes precedence: +, 00, or 011 on US and Canadian rows.
- Handles 00 and 011 prefixes, (0) trunk digits, extensions written as ext, x, # or ;ext=, and a leading apostrophe. Spreadsheet repairs are listed in the row's warnings: ="..." text wrappers, a trailing .0, exact scientific notation and a country code without +.
- Splits several numbers in one cell, separated by ; , | or line breaks. The first valid number becomes the primary one. Other valid numbers go to additional_numbers and unreadable ones to candidate_errors.
- Flags duplicates on the primary E.164 number with duplicate_group_id and is_first_seen, or keeps only the first occurrence.
- Keeps every original column in original and adds deterministic snake_case keys in normalized_original.
- Can also save CLEANED.csv in the run's key-value store.
- Handles failures per row. Empty, invalid or unprocessable values never stop the run. They are listed with a reason_code and message in the rejected array of the OUTPUT record. Only rows with a valid number go to the dataset.

National-format parsing and structural checks cover the United States, Canada, the United Kingdom, Germany, France, Spain, Italy, the Netherlands, Australia and India. This build does not bundle libphonenumber metadata. As a result, validation_level is always structure_only, line_type is always null and time_zones is always empty.

### How to use it

1. Click Start. The prefilled example runs as-is and shows valid, duplicate and rejected rows.
2. For your own list, replace Contact records, or clear it and paste CSV text, JSON text or a base64 file instead. Use exactly one source per run.
3. Enter your phone columns and country column exactly as they appear in the header. Then choose a default country and how to handle duplicates.

Copy-paste input:

```json
{
  "records": [
    { "name": "Ana", "country": "GB", "phone": "(0)20 7946 0958" },
    { "name": "Bob", "country": "DE", "phone": "00 49 30 123456" },
    { "name": "Ana duplicate", "country": "GB", "phone": "+44 20 7946 0958" },
    { "name": "Needs review", "country": "US", "phone": "123" }
  ],
  "phoneColumns": ["phone"],
  "countryColumn": "country",
  "defaultCountry": "US",
  "dedupe": "flag",
  "saveCleanedFile": true
}
```

For an XLSX workbook, base64-encode the original file bytes, put the text in fileBase64 and set fileFormat to xlsx. If you leave out phoneColumns, the Actor uses every column whose name contains phone, mobile, tel, cell or whatsapp.

### Output

The default dataset holds one row for each input row that produced a valid number:

| row_index | source_reference | input_value | e164 | national | duplicate_group_id | is_first_seen |
| --- | --- | --- | --- | --- | --- | --- |
| 0 | inline:records | (0)20 7946 0958 | +442079460958 | 020 7946 0958 | g1 | true |
| 1 | inline:records | 00 49 30 123456 | +4930123456 | 030 123456 | null | true |
| 2 | inline:records | +44 20 7946 0958 | +442079460958 | 020 7946 0958 | g1 | false |

Each row also contains fields like these:

```json
{
  "phone_column": "phone",
  "country": "GB",
  "calling_code": "44",
  "international": "+44 20 7946 0958",
  "rfc3966": "tel:+442079460958",
  "whatsapp_digits": "442079460958",
  "wa_link": "https://wa.me/442079460958",
  "extension": null,
  "additional_numbers": [],
  "is_valid": true,
  "validation_level": "structure_only",
  "reason_code": "OK",
  "status": "ok",
  "warnings": [],
  "original": { "country": "GB", "name": "Ana", "phone": "(0)20 7946 0958" }
}
```

The fourth input row (value 123) is not in the dataset. It appears in OUTPUT.rejected with reason_code TOO_SHORT and a readable message.

The OUTPUT record also holds:

- counters: processed, valid, invalid, errors, duplicates and skipped
- the delivered rows
- an outcome object with status (SUCCEEDED, PARTIAL, FAILED or LIMIT_REACHED), itemsReceived, itemsSucceeded, itemsFailed, billableCount, limitReached and undelivered

Every row carries source_reference and a zero-based row_index. For XLSX files, row_index + 2 is the worksheet row.

### Pricing

Pay per event. The main charge is per normalized phone row; everything else listed below is free.

| Event | Price (USD) | Charged when |
|---|---|---|
| Run start (`apify-actor-start`) | $0.002 | The Actor run starts. |
| Normalized phone row (`apify-default-dataset-item`) | $0.00025 | A row with a structurally valid E.164 number is pushed to the dataset. |

Not charged:

- OUTPUT summary and outcome
- rejected rows listed in OUTPUT
- CLEANED.csv

Examples (default 512 MB memory, one start event):

- 1,000 valid rows: $0.002 start + 1 × $0.002 + 1000 × $0.00025 = **$0.254**
- Only rejected rows: $0.002 start + 1 × $0.002 + 0 × $0.00025 = **$0.004**

Spending limit: when you set a maximum cost per run, the Actor stops before it would exceed it, keeps every normalized phone row it already delivered and reports `limitReached` in the `OUTPUT` record. It never delivers results beyond the limit.

The Store's Pricing tab shows the prices in force. If this section and the Pricing tab ever differ, the Pricing tab applies.

### Use cases

- Convert a CRM or spreadsheet export to E.164 before import.
- Clean and dedupe a lead list before a WhatsApp or SMS campaign.
- Add an offline normalization step to an n8n, Make, Zapier or Clay workflow through the Apify API.
- Read national-format numbers correctly using a per-row country column.
- Send rejected values to a manual review queue using OUTPUT.rejected.

### Limits

- At most 1,000 rows per run, in every format. maxRows processes only the first rows; the rest are counted as skipped.
- Size limits:
  - total input: 4 MB
  - CSV or JSON text: 1 MB
  - decoded base64 file: 2 MB
  - parsed rows: 2 MB of JSON
  - JSON nesting depth: 8
  - keys per object: 100
- Up to 10 phone columns and 10 numbers per cell. A cell with more numbers is reported as a row error instead of being cut off without notice.
- XLSX files:
  - only the first worksheet is read
  - row 1 must hold unique, nonempty headers
  - up to 100 columns
  - archive expansion is limited to 8 MB
  - formulas are never evaluated; cached results are used when present
- This build cannot download files from URLs. Supply files as base64.
- The structural rules are deliberately small. They can reject legitimate special-service numbers or accept unassigned ones.
- A +1 number alone cannot tell the US from Canada, so country stays empty unless the row says US or CA.
- Digits lost to spreadsheet rounding cannot be recovered.
- Ambiguous values are never guessed. Letters, lost precision, fractional scientific notation and unknown countries are rejected with an explicit reason_code.

### FAQ

#### Does is_valid mean the number is active?

No. It means the value meets this build's structural rules for its country. The Actor does not check whether a number is reachable or who owns it, and it does not check carrier assignment, line type or consent.

#### Are numbers sent to a lookup service?

No. Everything is processed inside the run, with no network requests. Your input and results stay in your own Apify storage, so set retention to suit your data policy.

#### How are duplicates defined?

Only the primary E.164 number is compared, and extensions are ignored. With flag, every row is kept and marked with duplicate_group_id and is_first_seen. With keepFirst, later matching rows are removed, including any different additional numbers they carry.

#### What happens to bad data?

Invalid rows are listed in OUTPUT.rejected and never enter the dataset. The run fails in three cases: no valid number is produced, the input is malformed, or the input is over a limit. The Actor first writes OUTPUT with a FAILED outcome and the reason, so a failure is never reported as success.

#### Why does the cleaned CSV show a leading apostrophe?

In CLEANED.csv, values that start with a formula character are escaped to protect spreadsheet imports. This includes the plus sign at the start of an E.164 number. Dataset and OUTPUT values are unchanged.

# Actor input Schema

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

Up to 1,000 JSON objects with at most 100 keys each. Remove this source before supplying CSV text, JSON text or a base64 file. Original keys and values are preserved in every output row.

## `csvText` (type: `string`):

UTF-8 CSV with a header row of unique, nonempty column names. Maximum 1 MB and 1,000 data rows. Quoted fields containing commas or line breaks are supported.

## `jsonText` (type: `string`):

A JSON array of 1 to 1,000 objects, up to 1 MB.

## `fileBase64` (type: `string`):

The original file bytes as canonical base64, without a data-URL prefix. Maximum decoded size 2 MB. XLSX workbooks are parsed with ExcelJS; archive expansion is bounded to 8 MB.

## `fileFormat` (type: `string`):

Applies only to the base64 file. Auto recognizes XLSX (ZIP) and JSON arrays; anything else is parsed as CSV.

## `phoneColumns` (type: `array`):

Exact column names (1 to 100 characters each) in priority order. If omitted, columns whose names contain phone, mobile, tel, cell or whatsapp are used in sorted order. Maximum 10 columns.

## `countryColumn` (type: `string`):

Exact column name holding each row's country: a supported ISO code, English country name or calling code. Blank or missing values use the default country; unrecognized values are rejected for national-format numbers.

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

Used for national-format numbers when the row has no country. An explicit international prefix always takes precedence. A +1 number alone cannot tell the US from Canada.

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

Compares primary E.164 numbers and ignores extensions. Flag keeps every row and marks duplicates; keepFirst removes later matching rows, including their additional numbers.

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

Process at most this many rows from the start of the source and report the rest as skipped. Every source also has a hard limit of 1,000 rows.

## `saveCleanedFile` (type: `boolean`):

Also write CLEANED.csv to the run's key-value store with the delivered rows and their original columns. Spreadsheet formula characters are escaped in this export only.

## `inputMode` (type: `string`):

This build processes customer-supplied content locally without any network request. URL downloads are unavailable because no customer-document host is approved.

## Actor input object example

```json
{
  "records": [
    {
      "country": "GB",
      "name": "Ana",
      "phone": "(0)20 7946 0958"
    },
    {
      "country": "DE",
      "name": "Bob",
      "phone": "00 49 30 123456"
    },
    {
      "country": "GB",
      "name": "Ana duplicate",
      "phone": "+44 20 7946 0958"
    },
    {
      "country": "US",
      "name": "Needs review",
      "phone": "123"
    }
  ],
  "fileFormat": "auto",
  "phoneColumns": [
    "phone"
  ],
  "countryColumn": "country",
  "defaultCountry": "US",
  "dedupe": "flag",
  "maxRows": 1000,
  "saveCleanedFile": true,
  "inputMode": "inline"
}
```

# Actor output Schema

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

Every result row stored in the default dataset.

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

Summary written to the OUTPUT record.

# 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": [
        {
            "country": "GB",
            "name": "Ana",
            "phone": "(0)20 7946 0958"
        },
        {
            "country": "DE",
            "name": "Bob",
            "phone": "00 49 30 123456"
        },
        {
            "country": "GB",
            "name": "Ana duplicate",
            "phone": "+44 20 7946 0958"
        },
        {
            "country": "US",
            "name": "Needs review",
            "phone": "123"
        }
    ],
    "phoneColumns": [
        "phone"
    ],
    "countryColumn": "country",
    "defaultCountry": "US",
    "dedupe": "flag",
    "inputMode": "inline"
};

// Run the Actor and wait for it to finish
const run = await client.actor("first_watch/phone-number-normalization-e-164").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": [
        {
            "country": "GB",
            "name": "Ana",
            "phone": "(0)20 7946 0958",
        },
        {
            "country": "DE",
            "name": "Bob",
            "phone": "00 49 30 123456",
        },
        {
            "country": "GB",
            "name": "Ana duplicate",
            "phone": "+44 20 7946 0958",
        },
        {
            "country": "US",
            "name": "Needs review",
            "phone": "123",
        },
    ],
    "phoneColumns": ["phone"],
    "countryColumn": "country",
    "defaultCountry": "US",
    "dedupe": "flag",
    "inputMode": "inline",
}

# Run the Actor and wait for it to finish
run = client.actor("first_watch/phone-number-normalization-e-164").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": [
    {
      "country": "GB",
      "name": "Ana",
      "phone": "(0)20 7946 0958"
    },
    {
      "country": "DE",
      "name": "Bob",
      "phone": "00 49 30 123456"
    },
    {
      "country": "GB",
      "name": "Ana duplicate",
      "phone": "+44 20 7946 0958"
    },
    {
      "country": "US",
      "name": "Needs review",
      "phone": "123"
    }
  ],
  "phoneColumns": [
    "phone"
  ],
  "countryColumn": "country",
  "defaultCountry": "US",
  "dedupe": "flag",
  "inputMode": "inline"
}' |
apify call first_watch/phone-number-normalization-e-164 --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,first_watch/phone-number-normalization-e-164"
        }
    }
}
```

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/ATIeLFnp2IajwMYIx/builds/LYQms5AxtxqAJnWyN/openapi.json
