Normalize a list of phone numbers with an SDK

Turn mixed phone formats into consistent international numbers in Node or Python, while preserving original input and separating invalid numbers from failed requests.

Node · PythonLast tested Get the complete example ↓
On this page

Run the example

Download the complete source below. In a macOS or Linux terminal, install the SDK, set your secret API key, then run the file.

Install
npm install @parseapi/sdk
API key
export PARSEAPI_KEY='YOUR_SECRET_API_KEY'
Run
node normalize-phone-numbers.node.mjs

Before you start

Build a small command-line script that reads phone records, calls the Phone API, and writes structured JSON. Choose Node or Python above. Both versions produce the same output shape and include a four-record sample, so the first run needs no input file.

Use Node 18 or later, or Python 3.9 or later. Install the SDK and set PARSEAPI_KEY using the instructions above. Use a secret key from the keys page, keep it in the environment, and run this script on your own machine or server.

The example is for a small integration: one to one hundred records, processed sequentially. It uses core phone parsing only. It does not request carrier information or live phone status.

1. Keep phone numbers as text

A phone record contains a number string and an optional two-letter country code. Save your own records as phones.json:

json
[
  { "number": "(415) 555-2671", "country": "US" },
  { "number": "020 7946 0958", "country": "GB" },
  { "number": "+33 1 42 68 53 00" },
  { "number": "123", "country": "US" }
]

The country is context for a national-format number. 020 7946 0958 needs GB to resolve as a British number. An international number beginning with + already carries its calling code. Use country information you actually have; do not assign one default to an international list without evidence.

Keep the leading zero, plus sign, and original text. Converting the input to a numeric type can discard information before the parser receives it. The script preserves each original record in input, including any extra fields such as your own record ID.

Pass the filename after the script name when you run it. In Node, that is node normalize-phone-numbers.node.mjs phones.json. In Python, use python3 normalize-phone-numbers.python.py phones.json. Omit phones.json to use the built-in sample.

2. Make one parsing call per record

Construct the SDK client once and reuse it. Each record calls phone with the number and any country context. The SDK handles path encoding and authentication.

JavaScript
for (const row of rows) {
  const result = emptyResult(row);
  const number = typeof row?.number === 'string' ? row.number.trim() : '';
  const country = typeof row?.country === 'string' ? row.country.trim().toUpperCase() : undefined;
  if (!number || number.length > 100 || (row.country != null && !/^[A-Z]{2}$/.test(country ?? ''))) {
    result.error = { code: 'invalid_input', status: null };
    results.push(result);
    continue;
  }
  if (stopped) {
    result.error = { code: 'not_attempted', status: null };
    results.push(result);
    continue;
  }
  try {
    const data = await parse.phone(number, country ? { country } : {});
    results.push(foldResult(row, data));
  } catch (error) {
    result.error = error instanceof ParseAPIError
      ? { code: error.code, status: error.status }
      : { code: 'request_failed', status: null };
    results.push(result);
    // Fix key/access/allowance errors before spending more attempts on this file.
    if (error instanceof ParseAPIError && [401, 403, 429].includes(error.status)) stopped = true;
  }
}

The script rejects empty values, non-string numbers, and malformed country codes locally. A parseable input still needs the API's verdict; a short value such as 123 is a useful invalid-number test.

These examples set a ten-second timeout and disable automatic retries, so a failed call is visible in the output. The SDK's default retry policy is documented in the SDK quickstart. A manual rerun can make additional requests, so retry the rows that need attention instead of routinely reprocessing a whole file.

If a request reports an authentication, access, or rate-limit error, the script stops calling the API for the remaining records. Their output says not_attempted. Fix the cause before trying those rows again.

3. Preserve invalid and unknown as different states

Normalize successful results into a stable set of fields. Use phone for the canonical international number, and keep national and international for display. Missing fields remain null.

JavaScript
function emptyResult(input) {
  return { input, valid: null, phone: null, country: null, national: null, international: null, error: null };
}

function foldResult(input, data) {
  if (!data || typeof data.valid !== 'boolean' ||
      (data.valid && (typeof data.phone !== 'string' || !/^\+[1-9]\d{1,14}$/.test(data.phone)))) {
    return { ...emptyResult(input), error: { code: 'unexpected_response', status: null } };
  }
  const text = (value) => typeof value === 'string' && value.length > 0 ? value : null;
  return {
    input,
    valid: data.valid,
    phone: data.valid ? data.phone : null,
    country: text(data.country),
    national: data.valid ? text(data.national) : null,
    international: data.valid ? text(data.international) : null,
    error: null
  };
}

Each output record has one of three meanings:

  • valid: true and error: null: the number matches the numbering plan and has a canonical phone value.
  • valid: false and error: null: parsing completed and found the number invalid.
  • valid: null and an error object: the script could not complete that check.

A timeout must not become an invalid phone number. Likewise, an API response with valid: false is a completed answer, not a failed HTTP request. The script checks the response shape before accepting it and retains the original record in every case.

The process exits with code 0 after completed checks, even if some numbers are invalid. It exits with 2 when any row needs attention, while still printing the results. A startup or input-file failure exits with 1 and writes a message to standard error.

4. Use the clean value without losing the original

For a valid record, store the canonical phone string alongside the original input. Use that canonical value to compare records that were entered with different spaces or punctuation. Preserve any extension in a separate field in your application; this sample is for phone numbers themselves.

Do not blindly discard every duplicate canonical number. Shared office lines and family numbers can appear on multiple legitimate records. Matching is useful evidence; merging people is a separate decision.

You can redirect standard output to a new file by adding > normalized.json to the run command. Choose a new filename so the original input remains available. Review records with a non-null error before using the output in an automated workflow.

Try the script

Run the built-in sample. The first three records should produce canonical numbers; 123 should remain an invalid result with phone: null. Then try a record with "number": null and confirm it becomes an invalid_input row without making an API call.

Try an unavailable API connection or a rejected key in a test environment. Those records should have valid: null, and a key rejection should leave the following valid inputs not_attempted. Both language versions retain the same input order.

What normalization tells you

A structurally valid number can still be disconnected, unassigned, or controlled by someone else. This script does not prove reachability or ownership. Phone type comes from the numbering plan and is not a live carrier lookup. The Phone API reference describes the fields and related APIs when your workflow needs a different answer.

For a signup form, combine this server-side normalization with an email check. For a large CSV workflow, the bulk phone tool provides a file interface.

Complete example

The complete Node source. The script reads your API key from PARSEAPI_KEY.

Download Node
View and copy the complete source
normalize-phone-numbers.node.mjs
import { readFile, stat } from 'node:fs/promises';
import { parseAPI, ParseAPIError } from '@parseapi/sdk';

const sample = [
  { number: '(415) 555-2671', country: 'US' },
  { number: '020 7946 0958', country: 'GB' },
  { number: '+33 1 42 68 53 00' },
  { number: '123', country: 'US' }
];

async function readRows() {
  if (process.argv.length > 3) throw new Error('Use: node normalize-phone-numbers.node.mjs [phones.json]');
  if (!process.argv[2]) return sample;
  if ((await stat(process.argv[2])).size > 65536) throw new Error('Use a JSON file smaller than 64 KiB.');
  const rows = JSON.parse(await readFile(process.argv[2], 'utf8'));
  if (!Array.isArray(rows) || rows.length === 0 || rows.length > 100) {
    throw new Error('Supply an array containing 1 to 100 phone records.');
  }
  return rows;
}

// example:result
function emptyResult(input) {
  return { input, valid: null, phone: null, country: null, national: null, international: null, error: null };
}

function foldResult(input, data) {
  if (!data || typeof data.valid !== 'boolean' ||
      (data.valid && (typeof data.phone !== 'string' || !/^\+[1-9]\d{1,14}$/.test(data.phone)))) {
    return { ...emptyResult(input), error: { code: 'unexpected_response', status: null } };
  }
  const text = (value) => typeof value === 'string' && value.length > 0 ? value : null;
  return {
    input,
    valid: data.valid,
    phone: data.valid ? data.phone : null,
    country: text(data.country),
    national: data.valid ? text(data.national) : null,
    international: data.valid ? text(data.international) : null,
    error: null
  };
}
// /example:result

async function main() {
  const rows = await readRows();
  // Reads PARSEAPI_KEY. Keep this secret key on the server.
  const parse = parseAPI(undefined, { timeoutMs: 10000, retries: 0 });
  const results = [];
  let stopped = false;

  // example:request
  for (const row of rows) {
    const result = emptyResult(row);
    const number = typeof row?.number === 'string' ? row.number.trim() : '';
    const country = typeof row?.country === 'string' ? row.country.trim().toUpperCase() : undefined;
    if (!number || number.length > 100 || (row.country != null && !/^[A-Z]{2}$/.test(country ?? ''))) {
      result.error = { code: 'invalid_input', status: null };
      results.push(result);
      continue;
    }
    if (stopped) {
      result.error = { code: 'not_attempted', status: null };
      results.push(result);
      continue;
    }
    try {
      const data = await parse.phone(number, country ? { country } : {});
      results.push(foldResult(row, data));
    } catch (error) {
      result.error = error instanceof ParseAPIError
        ? { code: error.code, status: error.status }
        : { code: 'request_failed', status: null };
      results.push(result);
      // Fix key/access/allowance errors before spending more attempts on this file.
      if (error instanceof ParseAPIError && [401, 403, 429].includes(error.status)) stopped = true;
    }
  }
  // /example:request

  process.stdout.write(`${JSON.stringify(results, null, 2)}\n`);
  // An invalid number is a completed check. A failed check needs attention.
  if (results.some((row) => row.error !== null)) process.exitCode = 2;
}

main().catch(() => {
  console.error('Could not start. Check PARSEAPI_KEY and provide a JSON array of 1 to 100 records (under 64 KiB).');
  process.exitCode = 1;
});

Need a key? Create a secret API key. For every field and parameter, see Phone API reference.