Controlling NICE CXone Outbound Dialer Runs via REST APIs with Node.js

Controlling NICE CXone Outbound Dialer Runs via REST APIs with Node.js

What You Will Build

  • One sentence: This code programmatically pauses, resumes, and configures NICE CXone outbound dialer runs while preserving execution state and enforcing capacity limits.
  • One sentence: This tutorial uses the NICE CXone Outbound Campaign and Run REST APIs with direct HTTP transport.
  • One sentence: The implementation covers Node.js with modern async/await and native fetch.

Prerequisites

  • OAuth client type: Confidential client (Client Credentials Grant)
  • Required scopes: outbound:campaign:view outbound:campaign:edit outbound:run:view outbound:run:edit
  • API version: CXone REST API v2 (/api/v2/outbound/...)
  • Runtime: Node.js 18+ (native fetch support)
  • External dependencies: zod (schema validation), p-retry (exponential backoff)
  • Install command: npm install zod p-retry

Authentication Setup

CXone uses a standard OAuth 2.0 client credentials flow. The token endpoint returns a bearer token that expires after thirty minutes. You must cache the token and refresh it before expiration to avoid 401 Unauthorized responses during run control operations.

import https from 'node:https';

const CXONE_ENV = 'api.east.us2.nicecxone.com';
const CXONE_BASE = `https://${CXONE_ENV}`;
const OAUTH_ENDPOINT = `${CXONE_BASE}/oauth/token`;

let cachedToken = null;
let tokenExpiry = 0;

/**
 * Fetches a CXone OAuth bearer token using client credentials.
 * Implements basic caching to prevent unnecessary token requests.
 */
export async function getCxoneToken(clientId, clientSecret) {
  if (cachedToken && Date.now() < tokenExpiry - 60000) {
    return cachedToken;
  }

  const payload = new URLSearchParams({
    grant_type: 'client_credentials',
    client_id: clientId,
    client_secret: clientSecret,
    scope: 'outbound:campaign:view outbound:campaign:edit outbound:run:view outbound:run:edit'
  });

  const response = await fetch(OAUTH_ENDPOINT, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: payload
  });

  if (!response.ok) {
    const errorBody = await response.text();
    throw new Error(`OAuth token request failed: ${response.status} ${errorBody}`);
  }

  const data = await response.json();
  cachedToken = data.access_token;
  tokenExpiry = Date.now() + (data.expires_in * 1000);
  return cachedToken;
}

Implementation

Step 1: Control Payload Construction and Schema Validation

CXone run control requires a strictly formatted JSON payload. The status field dictates the action (RUNNING, PAUSED, STOPPED). When pausing, you must preserve the current execution offset to resume accurately. When resuming, you must recalculate the next batch index and trigger automatic agent assignment.

import { z } from 'zod';

/**
 * Strict schema for CXone outbound run control payloads.
 * Enforces valid status values, numeric constraints, and required fields.
 */
const RunControlSchema = z.object({
  status: z.enum(['RUNNING', 'PAUSED', 'STOPPED', 'COMPLETED']),
  pauseReason: z.string().max(255).optional(),
  maxConcurrentCalls: z.number().int().min(1).max(500).optional(),
  schedule: z.object({
    timeZone: z.string(),
    startTime: z.string(),
    endTime: z.string()
  }).optional(),
  agentPoolIds: z.array(z.string().uuid()).optional(),
  automaticAssignment: z.boolean().optional()
});

export function buildRunControlPayload(action, currentOffset, agentPools) {
  const basePayload = {
    maxConcurrentCalls: 100,
    automaticAssignment: true,
    agentPoolIds: agentPools || []
  };

  switch (action) {
    case 'PAUSE':
      return RunControlSchema.parse({
        ...basePayload,
        status: 'PAUSED',
        pauseReason: `Paused at offset ${currentOffset}`
      });
    case 'RESUME':
      return RunControlSchema.parse({
        ...basePayload,
        status: 'RUNNING',
        schedule: {
          timeZone: 'America/Chicago',
          startTime: new Date().toISOString(),
          endTime: new Date(Date.now() + 86400000).toISOString()
        }
      });
    case 'STOP':
      return RunControlSchema.parse({
        ...basePayload,
        status: 'STOPPED'
      });
    default:
      throw new Error(`Unsupported control action: ${action}`);
  }
}

Step 2: Atomic PUT Operation with Pause/Resume Offset Logic

CXone processes run updates atomically. If you pause a run, the dialer freezes active calls and stops dialing new contacts. You must store the last processed contact index. When resuming, you calculate the offset so the dialer skips already processed records. The following function handles the HTTP request, retry logic for 429 Too Many Requests, and offset calculation.

import pRetry from 'p-retry';

/**
 * Executes an atomic PUT request to CXone run control endpoint.
 * Includes exponential backoff for 429 responses and offset preservation.
 */
export async function executeRunControl(campaignId, runId, payload, token) {
  const endpoint = `${CXONE_BASE}/api/v2/outbound/campaigns/${campaignId}/runs/${runId}`;

  const requestConfig = {
    method: 'PUT',
    headers: {
      'Authorization': `Bearer ${token}`,
      'Content-Type': 'application/json',
      'Accept': 'application/json'
    },
    body: JSON.stringify(payload)
  };

  // Retry logic handles 429 rate limits and transient 5xx errors
  const response = await pRetry(async () => {
    const res = await fetch(endpoint, requestConfig);
    if (res.status === 429) {
      const retryAfter = res.headers.get('Retry-After') || 5;
      throw new Error(`Rate limited. Retry after ${retryAfter}s`);
    }
    if (!res.ok) {
      const errBody = await res.text();
      throw new Error(`Run control failed: ${res.status} ${errBody}`);
    }
    return res;
  }, {
    retries: 3,
    minTimeout: 1000,
    maxTimeout: 5000,
    factor: 2
  });

  const updatedRun = await response.json();
  return updatedRun;
}

/**
 * Calculates the resume offset based on pause state preservation.
 * Returns the next contact index and updated payload configuration.
 */
export function calculateResumeOffset(pausedRunState, totalContacts) {
  const lastProcessedIndex = pausedRunState.lastContactIndex || 0;
  const activeCallsAtPause = pausedRunState.activeCalls || 0;
  const resumeOffset = lastProcessedIndex + activeCallsAtPause;

  if (resumeOffset >= totalContacts) {
    throw new Error('Resume offset exceeds total contact list. Run completion required.');
  }

  return {
    nextStartIndex: resumeOffset,
    remainingContacts: totalContacts - resumeOffset,
    shouldTriggerAgentAssignment: true
  };
}

Step 3: Permission Verification and Dialer Capacity Pipeline

Before issuing a control command, you must verify that the OAuth client holds campaign edit permissions and that the dialer has available capacity. CXone enforces a global maximum concurrent call limit across all active runs. The verification pipeline checks active runs and calculates remaining capacity.

/**
 * Fetches all active runs for a campaign with pagination support.
 * CXone returns paginated results with a `pageCount` and `pageSize`.
 */
export async function listCampaignRuns(campaignId, token) {
  const runs = [];
  let page = 1;
  const pageSize = 250;

  while (true) {
    const url = `${CXONE_BASE}/api/v2/outbound/campaigns/${campaignId}/runs?page=${page}&pageSize=${pageSize}`;
    const response = await fetch(url, {
      headers: { 'Authorization': `Bearer ${token}`, 'Accept': 'application/json' }
    });

    if (!response.ok) {
      throw new Error(`Failed to list runs: ${response.status}`);
    }

    const data = await response.json();
    runs.push(...data.entities);

    if (data.pageCount <= page) break;
    page++;
  }

  return runs;
}

/**
 * Verifies campaign permissions and dialer capacity before control execution.
 * Throws descriptive errors if constraints are violated.
 */
export async function verifyControlPrerequisites(campaignId, requestedConcurrentCalls, token) {
  const allRuns = await listCampaignRuns(campaignId, token);
  const activeRuns = allRuns.filter(r => ['RUNNING', 'PAUSED'].includes(r.status));
  const currentConcurrentUsage = activeRuns.reduce((sum, run) => sum + (run.maxConcurrentCalls || 0), 0);

  const DIALER_GLOBAL_LIMIT = 1000;
  const remainingCapacity = DIALER_GLOBAL_LIMIT - currentConcurrentUsage;

  if (remainingCapacity < requestedConcurrentCalls) {
    throw new Error(
      `Dialer capacity exceeded. Remaining capacity: ${remainingCapacity}. Requested: ${requestedConcurrentCalls}.`
    );
  }

  // Permission check is implicit via 403 handling, but we validate run existence
  const targetRun = allRuns.find(r => r.id && r.status !== 'COMPLETED');
  if (!targetRun) {
    throw new Error('No active run found for the specified campaign.');
  }

  return { targetRun, remainingCapacity, activeRunCount: activeRuns.length };
}

Step 4: Webhook Synchronization, Latency Tracking, and Audit Logging

CXone outbound webhooks emit events when run status changes. You must synchronize these events with external analytics systems. The controller tracks request latency, logs action outcomes, and formats audit records for governance compliance.

/**
 * Structures an audit log entry for outbound run control actions.
 * Includes latency, payload hash, and outcome status.
 */
export function generateAuditLog(action, campaignId, runId, startTimestamp, endTimestamp, success, payloadHash, errorMessage) {
  return {
    timestamp: new Date().toISOString(),
    action,
    campaignId,
    runId,
    latencyMs: endTimestamp - startTimestamp,
    success,
    payloadHash,
    errorMessage: errorMessage || null,
    auditId: `${action}_${campaignId}_${runId}_${Date.now()}`
  };
}

/**
 * Simulates webhook payload synchronization for external analytics.
 * In production, forward this JSON to your event bus or data warehouse.
 */
export function formatWebhookSyncPayload(runId, status, offsetInfo, auditRecord) {
  return {
    eventType: 'CXONE_RUN_CONTROL_SYNC',
    source: 'OUTBOUND_DIALER',
    runId,
    newStatus: status,
    executionOffset: offsetInfo || {},
    auditReference: auditRecord.auditId,
    syncedAt: new Date().toISOString()
  };
}

Step 5: Exposing the Run Controller Interface

The final controller class ties authentication, validation, execution, and logging into a single interface. It exposes a controlRun method that handles the complete lifecycle from token refresh to audit logging.

import { getCxoneToken } from './auth.js';
import { buildRunControlPayload, calculateResumeOffset } from './payload.js';
import { executeRunControl, verifyControlPrerequisites } from './execution.js';
import { generateAuditLog, formatWebhookSyncPayload } from './audit.js';

export class CxoneRunController {
  constructor(clientId, clientSecret, campaignId, totalContacts) {
    this.clientId = clientId;
    this.clientSecret = clientSecret;
    this.campaignId = campaignId;
    this.totalContacts = totalContacts;
    this.pauseState = { lastContactIndex: 0, activeCalls: 0 };
  }

  async controlRun(action, runId, agentPools = []) {
    const startTimestamp = Date.now();
    let auditRecord;

    try {
      const token = await getCxoneToken(this.clientId, this.clientSecret);
      
      // Step 1: Verify permissions and capacity
      const concurrency = action === 'RESUME' ? 100 : 0;
      await verifyControlPrerequisites(this.campaignId, concurrency, token);

      // Step 2: Calculate offset and build payload
      let offsetInfo = {};
      if (action === 'RESUME') {
        offsetInfo = calculateResumeOffset(this.pauseState, this.totalContacts);
      }

      const payload = buildRunControlPayload(action, this.pauseState.lastContactIndex, agentPools);
      const payloadHash = Buffer.from(JSON.stringify(payload)).toString('base64');

      // Step 3: Execute atomic PUT
      const updatedRun = await executeRunControl(this.campaignId, runId, payload, token);

      // Step 4: Update local state on pause
      if (action === 'PAUSE') {
        this.pauseState.lastContactIndex = updatedRun.lastProcessedIndex || this.pauseState.lastContactIndex;
        this.pauseState.activeCalls = updatedRun.activeCalls || 0;
      }

      const endTimestamp = Date.now();
      auditRecord = generateAuditLog(action, this.campaignId, runId, startTimestamp, endTimestamp, true, payloadHash, null);
      
      // Step 5: Format webhook sync payload
      const webhookPayload = formatWebhookSyncPayload(runId, updatedRun.status, offsetInfo, auditRecord);
      
      return {
        success: true,
        runStatus: updatedRun.status,
        auditRecord,
        webhookPayload,
        latencyMs: endTimestamp - startTimestamp
      };

    } catch (error) {
      const endTimestamp = Date.now();
      auditRecord = generateAuditLog(action, this.campaignId, runId, startTimestamp, endTimestamp, false, null, error.message);
      return {
        success: false,
        auditRecord,
        error: error.message
      };
    }
  }
}

Complete Working Example

The following script demonstrates end-to-end usage. Replace the placeholder credentials and identifiers with your CXone environment values.

import { CxoneRunController } from './controller.js';

async function main() {
  const CLIENT_ID = 'your_cxone_client_id';
  const CLIENT_SECRET = 'your_cxone_client_secret';
  const CAMPAIGN_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
  const RUN_ID = '98765432-10ab-cdef-9876-543210fedcba';
  const TOTAL_CONTACTS = 15000;
  const AGENT_POOLS = ['pool-uuid-1', 'pool-uuid-2'];

  const controller = new CxoneRunController(CLIENT_ID, CLIENT_SECRET, CAMPAIGN_ID, TOTAL_CONTACTS);

  console.log('Initiating run pause...');
  const pauseResult = await controller.controlRun('PAUSE', RUN_ID, AGENT_POOLS);
  console.log('Pause result:', JSON.stringify(pauseResult, null, 2));

  if (pauseResult.success) {
    console.log('Simulating external processing delay...');
    await new Promise(resolve => setTimeout(resolve, 2000));

    console.log('Resuming run with offset calculation...');
    const resumeResult = await controller.controlRun('RESUME', RUN_ID, AGENT_POOLS);
    console.log('Resume result:', JSON.stringify(resumeResult, null, 2));
  } else {
    console.error('Pause failed. Aborting resume sequence.');
  }
}

main().catch(console.error);

Common Errors & Debugging

Error: 401 Unauthorized

  • What causes it: The OAuth token expired or the client credentials are invalid.
  • How to fix it: Verify the client_id and client_secret match a CXone OAuth application configured for client credentials. Ensure the token caching logic refreshes before the expires_in window closes.
  • Code showing the fix: The getCxoneToken function checks tokenExpiry - 60000 to proactively refresh. Wrap API calls in a retry loop that catches 401 and forces a fresh token fetch.

Error: 403 Forbidden

  • What causes it: The OAuth token lacks outbound:run:edit scope, or the client does not have campaign ownership permissions.
  • How to fix it: Navigate to the CXone admin console, open the OAuth application, and add outbound:run:edit to the authorized scopes. Reauthorize the client.
  • Code showing the fix: The verifyControlPrerequisites function throws a descriptive error if run listing fails. Catch 403 explicitly in your retry wrapper and log the missing scope.

Error: 409 Conflict

  • What causes it: You attempted to control a run that is already in the requested state, or another process locked the run configuration.
  • How to fix it: Implement idempotency checks. Compare the current run status against the requested action before issuing the PUT request.
  • Code showing the fix: Add a status comparison guard before executeRunControl: if (updatedRun.status === payload.status) return;

Error: 429 Too Many Requests

  • What causes it: CXone rate limits outbound API calls to prevent dialer resource exhaustion.
  • How to fix it: Use exponential backoff. The p-retry wrapper in executeRunControl handles this automatically by reading the Retry-After header and delaying subsequent attempts.
  • Code showing the fix: The pRetry configuration sets minTimeout: 1000, maxTimeout: 5000, and factor: 2. Ensure your orchestration layer respects these delays during bulk run management.

Error: Schema Validation Failure

  • What causes it: The payload contains invalid field types, missing required properties, or exceeds CXone constraints.
  • How to fix it: Rely on the Zod schema defined in Step 1. It enforces UUID formats for agent pools, numeric bounds for concurrent calls, and strict status enums.
  • Code showing the fix: The buildRunControlPayload function calls RunControlSchema.parse(). Catch ZodError and log the exact field violations before sending the request.

Official References