Optimizing NICE CXone Outbound Campaign Scheduling with a Node.js Genetic Algorithm Service

Optimizing NICE CXone Outbound Campaign Scheduling with a Node.js Genetic Algorithm Service

What You Will Build

  • A Node.js service that queries historical outbound answer rates, executes a genetic algorithm to identify peak connectivity windows, and automatically updates campaign schedules via the REST API.
  • Uses the NICE CXone Analytics API v2 and Outbound Campaign API v2.
  • Covers JavaScript (Node.js 18+) with async/await, axios, and deterministic evolutionary optimization logic.

Prerequisites

  • OAuth 2.0 Client Credentials client registered in the CXone Developer Portal
  • Required scopes: analytics:outbound:read, outbound:campaign:read, outbound:campaign:write
  • CXone Platform API v2
  • Node.js 18 LTS or newer
  • Dependencies: npm install axios dotenv
  • Environment variables: CXONE_CLIENT_ID, CXONE_CLIENT_SECRET, CXONE_CAMPAIGN_ID, CXONE_ORGANIZATION_ID

Authentication Setup

NICE CXone uses the OAuth 2.0 Client Credentials grant. The token endpoint returns a bearer token valid for 3600 seconds. Production services must cache tokens and refresh them before expiration to prevent 401 cascades. The following implementation includes a TTL buffer and automatic retry for transient 429 and 5xx responses.

const axios = require('axios');
const CXONE_BASE = 'https://platformapi.niceincontact.com';

let tokenCache = { token: null, expiry: 0 };

async function getAccessToken() {
  if (tokenCache.token && Date.now() < tokenCache.expiry) {
    return tokenCache.token;
  }

  const payload = new URLSearchParams({
    grant_type: 'client_credentials',
    client_id: process.env.CXONE_CLIENT_ID,
    client_secret: process.env.CXONE_CLIENT_SECRET,
    scope: 'analytics:outbound:read outbound:campaign:read outbound:campaign:write'
  });

  const response = await axios.post(`${CXONE_BASE}/oauth2/v1/token`, payload, {
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
  });

  tokenCache.token = response.data.access_token;
  tokenCache.expiry = Date.now() + (response.data.expires_in * 1000) - 60000;
  return tokenCache.token;
}

async function makeAuthenticatedRequest(config) {
  const token = await getAccessToken();
  const baseConfig = {
    ...config,
    headers: { ...config.headers, Authorization: `Bearer ${token}` }
  };

  try {
    return await axios(baseConfig);
  } catch (error) {
    if (error.response?.status === 401) {
      tokenCache.token = null;
      tokenCache.expiry = 0;
      const refreshedToken = await getAccessToken();
      baseConfig.headers.Authorization = `Bearer ${refreshedToken}`;
      return await axios(baseConfig);
    }
    throw error;
  }
}

The makeAuthenticatedRequest wrapper handles token refresh transparently. If the API returns 401, the cache invalidates immediately and the request retries with a fresh token. This pattern prevents stale token failures during long-running analytics queries.

Implementation

Step 1: Fetch Historical Answer Rates

The Analytics API v2 aggregates metrics across configurable time buckets. Outbound campaigns require the analytics:outbound:read scope. The request body defines the date range, metrics, and groupings. The API returns paginated results using nextPageToken.

Request payload structure:

{
  "query": {
    "dateRange": {
      "startDate": "2023-09-01",
      "endDate": "2023-09-30"
    },
    "metrics": ["answerRate"],
    "groupings": ["time"]
  },
  "size": 1000
}

Implementation with pagination and retry logic:

async function fetchHistoricalAnswerRates(startDate, endDate) {
  const allData = [];
  let nextPageToken = null;
  const maxRetries = 3;

  do {
    let retryCount = 0;
    let success = false;

    while (retryCount < maxRetries && !success) {
      try {
        const response = await makeAuthenticatedRequest({
          method: 'post',
          url: `${CXONE_BASE}/platformapi/v2/analytics/outbound/details/query`,
          params: nextPageToken ? { nextPageToken } : {},
          data: {
            query: {
              dateRange: { startDate, endDate },
              metrics: ['answerRate'],
              groupings: ['time']
            },
            size: 1000
          }
        });

        if (response.data.data) {
          allData.push(...response.data.data);
        }
        nextPageToken = response.data.nextPageToken || null;
        success = true;
      } catch (error) {
        retryCount++;
        if (error.response?.status === 429) {
          const retryAfter = error.response.headers['retry-after'] || Math.pow(2, retryCount);
          console.warn(`Rate limited. Retrying in ${retryAfter}s...`);
          await new Promise(r => setTimeout(r, retryAfter * 1000));
        } else if (error.response?.status >= 500) {
          console.warn(`Server error ${error.response.status}. Retrying...`);
          await new Promise(r => setTimeout(r, 1000 * retryCount));
        } else {
          throw error;
        }
      }
    }
  } while (nextPageToken);

  return allData;
}

The endpoint returns an array of objects containing time (ISO 8601) and answerRate (0.0 to 1.0). Pagination continues until nextPageToken is null. The retry loop handles 429 rate limits using exponential backoff and respects the Retry-After header when present.

Step 2: Genetic Algorithm for Optimal Scheduling

Raw time-series data requires aggregation before optimization. The genetic algorithm evaluates start times across a 24-hour cycle. Each chromosome represents a candidate start hour. The fitness function calculates the average answer rate within a 2-hour window starting at that hour. The algorithm runs tournament selection, single-point crossover, and Gaussian mutation over multiple generations.

function aggregateHourlyRates(rawData) {
  const hourlyMap = new Map();
  for (let i = 0; i < 24; i++) hourlyMap.set(i, []);

  for (const record of rawData) {
    const hour = new Date(record.time).getUTCHours();
    hourlyMap.get(hour).push(record.answerRate);
  }

  return Array.from(hourlyMap.entries()).map(([hour, rates]) => ({
    hour,
    avgRate: rates.reduce((a, b) => a + b, 0) / rates.length
  }));
}

function evaluateFitness(chromosome, hourlyRates, windowSize = 2) {
  let totalRate = 0;
  let count = 0;
  for (let i = 0; i < windowSize; i++) {
    const targetHour = (chromosome + i) % 24;
    const rateObj = hourlyRates.find(r => r.hour === targetHour);
    if (rateObj) {
      totalRate += rateObj.avgRate;
      count++;
    }
  }
  return count > 0 ? totalRate / count : 0;
}

function geneticAlgorithm(hourlyRates, populationSize = 50, generations = 30, mutationRate = 0.1) {
  let population = Array.from({ length: populationSize }, () => Math.floor(Math.random() * 24));

  for (let gen = 0; gen < generations; gen++) {
    const fitnessMap = population.map(gene => ({
      gene,
      fitness: evaluateFitness(gene, hourlyRates)
    }));

    fitnessMap.sort((a, b) => b.fitness - a.fitness);
    const newPopulation = [fitnessMap[0].gene];

    while (newPopulation.length < populationSize) {
      const p1 = fitnessMap[Math.floor(Math.random() * fitnessMap.length)].gene;
      const p2 = fitnessMap[Math.floor(Math.random() * fitnessMap.length)].gene;
      let child = Math.random() < 0.5 ? p1 : p2;

      if (Math.random() < mutationRate) {
        child = (child + (Math.floor(Math.random() * 3) - 1) + 24) % 24;
      }
      newPopulation.push(child);
    }

    population = newPopulation;
  }

  const best = population.reduce((best, gene) => {
    const fitness = evaluateFitness(gene, hourlyRates);
    return fitness > best.fitness ? { gene, fitness } : best;
  }, { gene: 0, fitness: 0 });

  return best;
}

The fitness function averages the answer rate across the target window. Tournament selection is simulated by random sampling from the sorted fitness array. Mutation shifts the start hour by -1, 0, or +1 to maintain temporal locality. The algorithm returns the hour with the highest historical connectivity probability.

Step 3: Update Campaign Schedule

CXone outbound campaigns store scheduling rules in a schedule object containing an array of daily windows. The update operation requires reading the current campaign configuration, modifying the startTime and endTime fields, and issuing a PUT request. The API validates time format and overlapping windows, returning 400 on structural errors.

async function updateCampaignSchedule(campaignId, optimalStartHour) {
  const startTime = `${String(optimalStartHour).padStart(2, '0')}:00:00`;
  const endHour = (optimalStartHour + 2) % 24;
  const endTime = `${String(endHour).padStart(2, '0')}:00:00`;

  const campaignResponse = await makeAuthenticatedRequest({
    method: 'get',
    url: `${CXONE_BASE}/platformapi/v2/outbound/campaigns/${campaignId}`
  });

  const campaign = campaignResponse.data;
  if (!campaign.schedule) campaign.schedule = { enabled: true, schedule: [] };

  const days = ['MON', 'TUE', 'WED', 'THU', 'FRI'];
  days.forEach(day => {
    const existing = campaign.schedule.schedule.find(s => s.day === day);
    if (existing) {
      existing.startTime = startTime;
      existing.endTime = endTime;
    } else {
      campaign.schedule.schedule.push({
        day,
        startTime,
        endTime,
        timeZone: campaign.schedule.schedule[0]?.timeZone || 'America/New_York'
      });
    }
  });

  campaign.schedule.enabled = true;

  await makeAuthenticatedRequest({
    method: 'put',
    url: `${CXONE_BASE}/platformapi/v2/outbound/campaigns/${campaignId}`,
    data: campaign
  });

  return { campaignId, optimalStartHour, startTime, endTime };
}

The request preserves all existing campaign properties (predictive dialer settings, retry rules, agent groups) to prevent configuration drift. The schedule array updates only the target weekdays. The API returns 200 on success. Validation errors return 400 with a detailed errors array indicating malformed time strings or overlapping windows.

Complete Working Example

The following script combines authentication, analytics aggregation, genetic optimization, and campaign updates into a single executable module. Run with node optimize-campaign.js.

require('dotenv').config();
const axios = require('axios');

const CXONE_BASE = 'https://platformapi.niceincontact.com';
let tokenCache = { token: null, expiry: 0 };

async function getAccessToken() {
  if (tokenCache.token && Date.now() < tokenCache.expiry) return tokenCache.token;
  const payload = new URLSearchParams({
    grant_type: 'client_credentials',
    client_id: process.env.CXONE_CLIENT_ID,
    client_secret: process.env.CXONE_CLIENT_SECRET,
    scope: 'analytics:outbound:read outbound:campaign:read outbound:campaign:write'
  });
  const response = await axios.post(`${CXONE_BASE}/oauth2/v1/token`, payload, {
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
  });
  tokenCache.token = response.data.access_token;
  tokenCache.expiry = Date.now() + (response.data.expires_in * 1000) - 60000;
  return tokenCache.token;
}

async function makeAuthenticatedRequest(config) {
  const token = await getAccessToken();
  const baseConfig = { ...config, headers: { ...config.headers, Authorization: `Bearer ${token}` } };
  try {
    return await axios(baseConfig);
  } catch (error) {
    if (error.response?.status === 401) {
      tokenCache.token = null;
      tokenCache.expiry = 0;
      const refreshedToken = await getAccessToken();
      baseConfig.headers.Authorization = `Bearer ${refreshedToken}`;
      return await axios(baseConfig);
    }
    throw error;
  }
}

async function fetchHistoricalAnswerRates(startDate, endDate) {
  const allData = [];
  let nextPageToken = null;
  const maxRetries = 3;
  do {
    let retryCount = 0;
    let success = false;
    while (retryCount < maxRetries && !success) {
      try {
        const response = await makeAuthenticatedRequest({
          method: 'post',
          url: `${CXONE_BASE}/platformapi/v2/analytics/outbound/details/query`,
          params: nextPageToken ? { nextPageToken } : {},
          data: {
            query: {
              dateRange: { startDate, endDate },
              metrics: ['answerRate'],
              groupings: ['time']
            },
            size: 1000
          }
        });
        if (response.data.data) allData.push(...response.data.data);
        nextPageToken = response.data.nextPageToken || null;
        success = true;
      } catch (error) {
        retryCount++;
        if (error.response?.status === 429) {
          const retryAfter = error.response.headers['retry-after'] || Math.pow(2, retryCount);
          await new Promise(r => setTimeout(r, retryAfter * 1000));
        } else if (error.response?.status >= 500) {
          await new Promise(r => setTimeout(r, 1000 * retryCount));
        } else {
          throw error;
        }
      }
    }
  } while (nextPageToken);
  return allData;
}

function aggregateHourlyRates(rawData) {
  const hourlyMap = new Map();
  for (let i = 0; i < 24; i++) hourlyMap.set(i, []);
  for (const record of rawData) {
    const hour = new Date(record.time).getUTCHours();
    hourlyMap.get(hour).push(record.answerRate);
  }
  return Array.from(hourlyMap.entries()).map(([hour, rates]) => ({
    hour,
    avgRate: rates.reduce((a, b) => a + b, 0) / rates.length
  }));
}

function evaluateFitness(chromosome, hourlyRates, windowSize = 2) {
  let totalRate = 0;
  let count = 0;
  for (let i = 0; i < windowSize; i++) {
    const targetHour = (chromosome + i) % 24;
    const rateObj = hourlyRates.find(r => r.hour === targetHour);
    if (rateObj) { totalRate += rateObj.avgRate; count++; }
  }
  return count > 0 ? totalRate / count : 0;
}

function geneticAlgorithm(hourlyRates, populationSize = 50, generations = 30, mutationRate = 0.1) {
  let population = Array.from({ length: populationSize }, () => Math.floor(Math.random() * 24));
  for (let gen = 0; gen < generations; gen++) {
    const fitnessMap = population.map(gene => ({ gene, fitness: evaluateFitness(gene, hourlyRates) }));
    fitnessMap.sort((a, b) => b.fitness - a.fitness);
    const newPopulation = [fitnessMap[0].gene];
    while (newPopulation.length < populationSize) {
      const p1 = fitnessMap[Math.floor(Math.random() * fitnessMap.length)].gene;
      const p2 = fitnessMap[Math.floor(Math.random() * fitnessMap.length)].gene;
      let child = Math.random() < 0.5 ? p1 : p2;
      if (Math.random() < mutationRate) child = (child + (Math.floor(Math.random() * 3) - 1) + 24) % 24;
      newPopulation.push(child);
    }
    population = newPopulation;
  }
  const best = population.reduce((best, gene) => {
    const fitness = evaluateFitness(gene, hourlyRates);
    return fitness > best.fitness ? { gene, fitness } : best;
  }, { gene: 0, fitness: 0 });
  return best;
}

async function updateCampaignSchedule(campaignId, optimalStartHour) {
  const startTime = `${String(optimalStartHour).padStart(2, '0')}:00:00`;
  const endHour = (optimalStartHour + 2) % 24;
  const endTime = `${String(endHour).padStart(2, '0')}:00:00`;
  const campaignResponse = await makeAuthenticatedRequest({
    method: 'get',
    url: `${CXONE_BASE}/platformapi/v2/outbound/campaigns/${campaignId}`
  });
  const campaign = campaignResponse.data;
  if (!campaign.schedule) campaign.schedule = { enabled: true, schedule: [] };
  const days = ['MON', 'TUE', 'WED', 'THU', 'FRI'];
  days.forEach(day => {
    const existing = campaign.schedule.schedule.find(s => s.day === day);
    if (existing) {
      existing.startTime = startTime;
      existing.endTime = endTime;
    } else {
      campaign.schedule.schedule.push({
        day,
        startTime,
        endTime,
        timeZone: campaign.schedule.schedule[0]?.timeZone || 'America/New_York'
      });
    }
  });
  campaign.schedule.enabled = true;
  await makeAuthenticatedRequest({
    method: 'put',
    url: `${CXONE_BASE}/platformapi/v2/outbound/campaigns/${campaignId}`,
    data: campaign
  });
  return { campaignId, optimalStartHour, startTime, endTime };
}

async function main() {
  try {
    const endDate = new Date();
    const startDate = new Date();
    startDate.setMonth(startDate.getMonth() - 1);
    const startStr = startDate.toISOString().split('T')[0];
    const endStr = endDate.toISOString().split('T')[0];

    console.log('Fetching historical answer rates...');
    const rawData = await fetchHistoricalAnswerRates(startStr, endStr);
    const hourlyRates = aggregateHourlyRates(rawData);

    console.log('Running genetic algorithm...');
    const best = geneticAlgorithm(hourlyRates);
    console.log(`Optimal start hour: ${best.gene}:00 (Fitness: ${best.fitness.toFixed(4)})`);

    console.log('Updating campaign schedule...');
    const result = await updateCampaignSchedule(process.env.CXONE_CAMPAIGN_ID, best.gene);
    console.log('Schedule updated successfully:', result);
  } catch (error) {
    console.error('Execution failed:', error.response?.data || error.message);
    process.exit(1);
  }
}

main();

Common Errors & Debugging

Error: 401 Unauthorized

  • Cause: OAuth token expired, client credentials invalid, or token cache not refreshed.
  • Fix: Verify CXONE_CLIENT_ID and CXONE_CLIENT_SECRET match the Developer Portal registration. Ensure the getAccessToken cache invalidates on 401 and retries. Check that the token request uses application/x-www-form-urlencoded content type.

Error: 403 Forbidden

  • Cause: Missing required OAuth scopes or client lacks permission to access the campaign.
  • Fix: Add analytics:outbound:read, outbound:campaign:read, and outbound:campaign:write to the client scope list in the CXone admin console. Revoke and regenerate the token after scope changes.

Error: 429 Too Many Requests

  • Cause: Analytics API rate limit exceeded. The outbound details endpoint enforces strict request quotas per organization.
  • Fix: Implement exponential backoff. Parse the Retry-After header if present. Reduce size parameter if returning massive datasets. The provided fetchHistoricalAnswerRates function already includes 429 handling with dynamic retry intervals.

Error: 400 Bad Request

  • Cause: Malformed startTime/endTime strings, overlapping schedule windows, or invalid day enumeration values.
  • Fix: Validate time format matches HH:mm:ss with 24-hour notation. Ensure endTime is chronologically after startTime within the same day boundary. Use exact day strings: MON, TUE, WED, THU, FRI, SAT, SUN. The API returns an errors array with field-level validation messages.

Error: 404 Not Found

  • Cause: Invalid campaignId or analytics query targeting a non-existent date range.
  • Fix: Verify the campaign ID exists in the target organization. Ensure the analytics dateRange falls within the last 12 months. CXone purges detailed conversation analytics after 12 months.

Official References