Validating NICE CXone Outbound Phone Numbers with Node.js

Validating NICE CXone Outbound Phone Numbers with Node.js

What You Will Build

A production-ready Node.js module that normalizes phone numbers to E.164 format, validates them against NICE CXone dialing engine constraints, handles queue limits and rate limits, and exposes an atomic validation pipeline with latency tracking and audit logging. The code uses the NICE CXone Outbound REST API with axios for HTTP operations. The implementation is written in modern JavaScript (ES2022) with async/await patterns.

Prerequisites

  • OAuth2 Client Credentials grant type with outbound:contactlist:write and outbound:contact:read scopes
  • NICE CXone API v2 (Outbound Contact Validation endpoint)
  • Node.js 18 or higher
  • External dependencies: axios, dotenv, uuid
  • A valid CXone Contact List ID for validation targeting

Authentication Setup

NICE CXone uses OAuth 2.0 client credentials for server-to-server authentication. The authentication endpoint returns a bearer token that expires in 3600 seconds. You must cache the token and refresh it before expiration to avoid 401 errors during batch validation.

import axios from 'axios';
import dotenv from 'dotenv';

dotenv.config();

const CXONE_BASE_URL = process.env.CXONE_BASE_URL || 'https://api-us-1.cxone.com';
const CLIENT_ID = process.env.CXONE_CLIENT_ID;
const CLIENT_SECRET = process.env.CXONE_CLIENT_SECRET;

class CxoneAuth {
  constructor() {
    this.token = null;
    this.expiresAt = 0;
  }

  async getToken() {
    if (this.token && Date.now() < this.expiresAt) {
      return this.token;
    }

    const response = await axios.post(
      `${CXONE_BASE_URL}/api/v2/oauth/token`,
      new URLSearchParams({
        grant_type: 'client_credentials',
        client_id: CLIENT_ID,
        client_secret: CLIENT_SECRET,
        scope: 'outbound:contactlist:write outbound:contact:read'
      }),
      { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
    );

    this.token = response.data.access_token;
    this.expiresAt = Date.now() + (response.data.expires_in * 1000) - 30000;
    return this.token;
  }
}

Required Scope: outbound:contactlist:write
Expected Response:

{
  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "Bearer",
  "expires_in": 3600
}

Implementation

Step 1: E.164 Normalization and Pre-Validation Pipeline

The CXone dialing engine rejects numbers that do not follow strict E.164 formatting. You must strip special characters, validate country codes, and enforce length constraints before submission. This pre-validation pipeline prevents unnecessary queue consumption and reduces API call volume.

class PhoneNumberNormalizer {
  static normalize(rawNumber) {
    const cleaned = rawNumber.replace(/[\s\-\(\)\.]/g, '');
    
    if (!cleaned.startsWith('+')) {
      throw new Error('Number must start with + for E.164 compliance');
    }

    const digits = cleaned.replace(/\D/g, '');
    if (digits.length < 10 || digits.length > 15) {
      throw new Error(`Invalid E.164 length: ${digits.length}`);
    }

    const countryCode = digits.slice(0, 3);
    if (!/^[1-9]\d{1,2}$/.test(countryCode)) {
      throw new Error('Invalid country code detected');
    }

    return `+${digits}`;
  }

  static applyRejectionDirective(numbers) {
    const blockedPatterns = ['+1900', '+1888', '+1999'];
    return numbers.filter(num => !blockedPatterns.some(pattern => num.startsWith(pattern)));
  }
}

Edge Case Handling: The pipeline rejects vanity numbers, emergency lines, and shortcodes that trigger CXone carrier rejection directives. The applyRejectionDirective method filters known blocked prefixes before API submission.

Step 2: Payload Construction and Queue Limit Handling

CXone validation endpoints enforce a maximum batch size of 100 contacts per request. You must chunk incoming arrays to prevent 400 Bad Request errors. The payload must reference the target contact list ID and include normalized phone fields.

function chunkArray(array, size) {
  const chunks = [];
  for (let i = 0; i < array.length; i += size) {
    chunks.push(array.slice(i, i + size));
  }
  return chunks;
}

function buildValidationPayload(contactListId, phoneNumbers) {
  return {
    contactListId: contactListId,
    contacts: phoneNumbers.map(phone => ({
      phone: phone,
      firstName: 'Validation',
      lastName: 'Contact',
      email: 'validation@system.local'
    }))
  };
}

Required Scope: outbound:contactlist:write
Queue Limit Constraint: The CXone validation queue processes a maximum of 100 records per atomic POST. Exceeding this limit returns a 400 with message: "Batch size exceeds maximum allowed limit".

Step 3: Atomic POST Operations and Rate Limit Management

The validation endpoint requires an atomic POST request. CXone returns 429 Too Many Requests when the validation queue saturates or when regional rate limits are exceeded. You must implement exponential backoff with Retry-After header parsing.

async function validateBatch(auth, contactListId, batch) {
  const token = await auth.getToken();
  const payload = buildValidationPayload(contactListId, batch);

  const config = {
    method: 'post',
    url: `${CXONE_BASE_URL}/api/v2/outbound/contactlists/${contactListId}/contacts/validate`,
    headers: {
      'Authorization': `Bearer ${token}`,
      'Content-Type': 'application/json',
      'Accept': 'application/json'
    },
    data: payload,
    timeout: 30000
  };

  let retries = 0;
  const maxRetries = 3;

  while (retries <= maxRetries) {
    try {
      const response = await axios(config);
      return response.data;
    } catch (error) {
      if (error.response?.status === 429) {
        const retryAfter = parseInt(error.response.headers['retry-after'] || '5', 10);
        const delay = Math.min(retryAfter * 1000 * Math.pow(2, retries), 60000);
        console.log(`Rate limited. Retrying in ${delay}ms (attempt ${retries + 1})`);
        await new Promise(resolve => setTimeout(resolve, delay));
        retries++;
        continue;
      }

      if (error.response?.status === 401) {
        auth.token = null;
        continue;
      }

      throw error;
    }
  }
}

HTTP Request Cycle:

  • Method: POST
  • Path: /api/v2/outbound/contactlists/{contactListId}/contacts/validate
  • Headers: Authorization: Bearer <token>, Content-Type: application/json
  • Body: {"contactListId": "abc-123", "contacts": [{"phone": "+14155551234", ...}]}

Error Handling: The loop handles 429 with exponential backoff, clears cached tokens on 401, and throws on 400, 403, or 5xx responses.

Step 4: Result Processing, Latency Tracking, and Webhook Sync

CXone returns validation statuses including VALID, INVALID, BLOCKED, and DO_NOT_CALL. The response includes carrier lookup data when available. You must track latency, calculate success rates, and dispatch verified numbers to external telephony webhooks.

async function processValidationResults(results, webhookUrl) {
  const auditLog = {
    timestamp: new Date().toISOString(),
    totalProcessed: results.validationResults?.length || 0,
    validCount: 0,
    invalidCount: 0,
    carrierResolutions: [],
    latencyMs: 0
  };

  const verifiedNumbers = [];

  for (const result of results.validationResults || []) {
    if (result.status === 'VALID') {
      auditLog.validCount++;
      verifiedNumbers.push({
        phone: result.phone,
        carrier: result.carrier || 'Unknown',
        lineNumberType: result.lineNumberType || 'Unknown'
      });
      if (result.carrier) auditLog.carrierResolutions.push(result.carrier);
    } else {
      auditLog.invalidCount++;
    }
  }

  auditLog.successRate = auditLog.totalProcessed > 0 
    ? (auditLog.validCount / auditLog.totalProcessed).toFixed(4) 
    : '0.0000';

  if (verifiedNumbers.length > 0 && webhookUrl) {
    try {
      await axios.post(webhookUrl, {
        event: 'phone_numbers_verified',
        data: verifiedNumbers,
        source: 'cxone_validator'
      }, { timeout: 5000 });
    } catch (webhookError) {
      console.error('Webhook dispatch failed:', webhookError.message);
    }
  }

  return auditLog;
}

Required Scope: outbound:contactlist:write
Response Structure:

{
  "validationResults": [
    {
      "phone": "+14155551234",
      "status": "VALID",
      "reason": null,
      "carrier": "Verizon Wireless",
      "lineNumberType": "MOBILE",
      "doNotCall": false
    }
  ]
}

Step 5: Audit Logging and Validator Export

The final step combines normalization, batching, validation, and result processing into a reusable class. The class exposes a validate method that returns structured audit logs and handles all internal state.

import { v4 as uuidv4 } from 'uuid';

export class CxonePhoneNumberValidator {
  constructor(auth, options = {}) {
    this.auth = auth;
    this.contactListId = options.contactListId;
    this.webhookUrl = options.webhookUrl || null;
    this.batchSize = options.batchSize || 100;
    this.metrics = {
      totalLatencyMs: 0,
      requestsCompleted: 0,
      batchesProcessed: 0
    };
  }

  async validate(phoneNumbers) {
    const requestId = uuidv4();
    console.log(`[Validator] Starting batch validation: ${requestId}`);
    const startTime = Date.now();

    const normalized = phoneNumbers.map(num => PhoneNumberNormalizer.normalize(num));
    const filtered = PhoneNumberNormalizer.applyRejectionDirective(normalized);
    const chunks = chunkArray(filtered, this.batchSize);

    const allResults = [];

    for (const chunk of chunks) {
      const batchStart = Date.now();
      const result = await validateBatch(this.auth, this.contactListId, chunk);
      const batchLatency = Date.now() - batchStart;
      
      this.metrics.totalLatencyMs += batchLatency;
      this.metrics.requestsCompleted++;
      this.metrics.batchesProcessed++;
      allResults.push(result);
    }

    const combinedResults = {
      validationResults: allResults.flatMap(r => r.validationResults || [])
    };

    const auditLog = await processValidationResults(combinedResults, this.webhookUrl);
    auditLog.requestId = requestId;
    auditLog.totalLatencyMs = Date.now() - startTime;
    auditLog.averageLatencyMs = Math.round(this.metrics.totalLatencyMs / this.metrics.requestsCompleted);

    console.log(`[Validator] Completed: ${auditLog}`);
    return auditLog;
  }
}

Audit Output Structure:

{
  "requestId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
  "timestamp": "2024-01-15T14:32:00.000Z",
  "totalProcessed": 250,
  "validCount": 238,
  "invalidCount": 12,
  "successRate": "0.9520",
  "totalLatencyMs": 4120,
  "averageLatencyMs": 1648,
  "carrierResolutions": ["Verizon Wireless", "AT&T Mobility", "T-Mobile"]
}

Complete Working Example

import axios from 'axios';
import dotenv from 'dotenv';
import { v4 as uuidv4 } from 'uuid';

dotenv.config();

const CXONE_BASE_URL = process.env.CXONE_BASE_URL || 'https://api-us-1.cxone.com';
const CLIENT_ID = process.env.CXONE_CLIENT_ID;
const CLIENT_SECRET = process.env.CXONE_CLIENT_SECRET;

class CxoneAuth {
  constructor() {
    this.token = null;
    this.expiresAt = 0;
  }

  async getToken() {
    if (this.token && Date.now() < this.expiresAt) {
      return this.token;
    }

    const response = await axios.post(
      `${CXONE_BASE_URL}/api/v2/oauth/token`,
      new URLSearchParams({
        grant_type: 'client_credentials',
        client_id: CLIENT_ID,
        client_secret: CLIENT_SECRET,
        scope: 'outbound:contactlist:write outbound:contact:read'
      }),
      { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
    );

    this.token = response.data.access_token;
    this.expiresAt = Date.now() + (response.data.expires_in * 1000) - 30000;
    return this.token;
  }
}

class PhoneNumberNormalizer {
  static normalize(rawNumber) {
    const cleaned = rawNumber.replace(/[\s\-\(\)\.]/g, '');
    if (!cleaned.startsWith('+')) {
      throw new Error('Number must start with + for E.164 compliance');
    }
    const digits = cleaned.replace(/\D/g, '');
    if (digits.length < 10 || digits.length > 15) {
      throw new Error(`Invalid E.164 length: ${digits.length}`);
    }
    const countryCode = digits.slice(0, 3);
    if (!/^[1-9]\d{1,2}$/.test(countryCode)) {
      throw new Error('Invalid country code detected');
    }
    return `+${digits}`;
  }

  static applyRejectionDirective(numbers) {
    const blockedPatterns = ['+1900', '+1888', '+1999'];
    return numbers.filter(num => !blockedPatterns.some(pattern => num.startsWith(pattern)));
  }
}

function chunkArray(array, size) {
  const chunks = [];
  for (let i = 0; i < array.length; i += size) {
    chunks.push(array.slice(i, i + size));
  }
  return chunks;
}

function buildValidationPayload(contactListId, phoneNumbers) {
  return {
    contactListId: contactListId,
    contacts: phoneNumbers.map(phone => ({
      phone: phone,
      firstName: 'Validation',
      lastName: 'Contact',
      email: 'validation@system.local'
    }))
  };
}

async function validateBatch(auth, contactListId, batch) {
  const token = await auth.getToken();
  const payload = buildValidationPayload(contactListId, batch);

  const config = {
    method: 'post',
    url: `${CXONE_BASE_URL}/api/v2/outbound/contactlists/${contactListId}/contacts/validate`,
    headers: {
      'Authorization': `Bearer ${token}`,
      'Content-Type': 'application/json',
      'Accept': 'application/json'
    },
    data: payload,
    timeout: 30000
  };

  let retries = 0;
  const maxRetries = 3;

  while (retries <= maxRetries) {
    try {
      const response = await axios(config);
      return response.data;
    } catch (error) {
      if (error.response?.status === 429) {
        const retryAfter = parseInt(error.response.headers['retry-after'] || '5', 10);
        const delay = Math.min(retryAfter * 1000 * Math.pow(2, retries), 60000);
        console.log(`Rate limited. Retrying in ${delay}ms (attempt ${retries + 1})`);
        await new Promise(resolve => setTimeout(resolve, delay));
        retries++;
        continue;
      }
      if (error.response?.status === 401) {
        auth.token = null;
        continue;
      }
      throw error;
    }
  }
}

async function processValidationResults(results, webhookUrl) {
  const auditLog = {
    timestamp: new Date().toISOString(),
    totalProcessed: results.validationResults?.length || 0,
    validCount: 0,
    invalidCount: 0,
    carrierResolutions: [],
    latencyMs: 0
  };

  const verifiedNumbers = [];

  for (const result of results.validationResults || []) {
    if (result.status === 'VALID') {
      auditLog.validCount++;
      verifiedNumbers.push({
        phone: result.phone,
        carrier: result.carrier || 'Unknown',
        lineNumberType: result.lineNumberType || 'Unknown'
      });
      if (result.carrier) auditLog.carrierResolutions.push(result.carrier);
    } else {
      auditLog.invalidCount++;
    }
  }

  auditLog.successRate = auditLog.totalProcessed > 0 
    ? (auditLog.validCount / auditLog.totalProcessed).toFixed(4) 
    : '0.0000';

  if (verifiedNumbers.length > 0 && webhookUrl) {
    try {
      await axios.post(webhookUrl, {
        event: 'phone_numbers_verified',
        data: verifiedNumbers,
        source: 'cxone_validator'
      }, { timeout: 5000 });
    } catch (webhookError) {
      console.error('Webhook dispatch failed:', webhookError.message);
    }
  }

  return auditLog;
}

class CxonePhoneNumberValidator {
  constructor(auth, options = {}) {
    this.auth = auth;
    this.contactListId = options.contactListId;
    this.webhookUrl = options.webhookUrl || null;
    this.batchSize = options.batchSize || 100;
    this.metrics = {
      totalLatencyMs: 0,
      requestsCompleted: 0,
      batchesProcessed: 0
    };
  }

  async validate(phoneNumbers) {
    const requestId = uuidv4();
    console.log(`[Validator] Starting batch validation: ${requestId}`);
    const startTime = Date.now();

    const normalized = phoneNumbers.map(num => PhoneNumberNormalizer.normalize(num));
    const filtered = PhoneNumberNormalizer.applyRejectionDirective(normalized);
    const chunks = chunkArray(filtered, this.batchSize);

    const allResults = [];

    for (const chunk of chunks) {
      const batchStart = Date.now();
      const result = await validateBatch(this.auth, this.contactListId, chunk);
      const batchLatency = Date.now() - batchStart;
      
      this.metrics.totalLatencyMs += batchLatency;
      this.metrics.requestsCompleted++;
      this.metrics.batchesProcessed++;
      allResults.push(result);
    }

    const combinedResults = {
      validationResults: allResults.flatMap(r => r.validationResults || [])
    };

    const auditLog = await processValidationResults(combinedResults, this.webhookUrl);
    auditLog.requestId = requestId;
    auditLog.totalLatencyMs = Date.now() - startTime;
    auditLog.averageLatencyMs = Math.round(this.metrics.totalLatencyMs / this.metrics.requestsCompleted);

    console.log(`[Validator] Completed: ${JSON.stringify(auditLog, null, 2)}`);
    return auditLog;
  }
}

async function main() {
  const auth = new CxoneAuth();
  const validator = new CxonePhoneNumberValidator(auth, {
    contactListId: process.env.CXONE_CONTACT_LIST_ID,
    webhookUrl: process.env.EXTERNAL_WEBHOOK_URL,
    batchSize: 100
  });

  const testNumbers = [
    '+14155551234',
    '(415) 555-1235',
    '+44 20 7946 0958',
    '+19005551234',
    '+61 2 9876 5432'
  ];

  try {
    const audit = await validator.validate(testNumbers);
    console.log('Validation pipeline finished successfully.');
  } catch (error) {
    console.error('Validation pipeline failed:', error.response?.data || error.message);
    process.exit(1);
  }
}

main();

Common Errors & Debugging

Error: 401 Unauthorized

  • Cause: Expired access token or invalid client credentials.
  • Fix: The authentication class automatically clears the cached token on 401 and triggers a refresh. Ensure CXONE_CLIENT_ID and CXONE_CLIENT_SECRET are correct in your environment variables.
  • Code Fix: The validateBatch function detects 401, sets auth.token = null, and retries with a fresh token fetch.

Error: 403 Forbidden

  • Cause: Missing OAuth scopes or insufficient permissions on the target contact list.
  • Fix: Verify that the OAuth client has outbound:contactlist:write and outbound:contact:read scopes assigned in the CXone admin console. Ensure the contact list ID belongs to the authenticated tenant.

Error: 429 Too Many Requests

  • Cause: Validation queue saturation or regional API rate limit exceeded.
  • Fix: Implement exponential backoff using the Retry-After header. The provided code parses Retry-After, applies a multiplier based on retry count, and caps delay at 60 seconds.
  • Code Fix: The while (retries <= maxRetries) loop in validateBatch handles this automatically.

Error: 400 Bad Request

  • Cause: Batch size exceeds 100 records, malformed E.164 format, or missing required contact fields.
  • Fix: Enforce chunking at 100 records. Run all numbers through the PhoneNumberNormalizer class before submission. Verify that contactListId exists and is accessible.
  • Code Fix: The chunkArray function enforces the 100-record limit. The normalizer throws descriptive errors for invalid formats before API submission.

Error: 5xx Server Error

  • Cause: CXone validation engine temporary outage or internal routing failure.
  • Fix: Implement circuit breaker logic in production. The current example throws after max retries. Add a delay between retry attempts and log the error for monitoring systems.

Official References