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
fetchsupport) - 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_idandclient_secretmatch a CXone OAuth application configured for client credentials. Ensure the token caching logic refreshes before theexpires_inwindow closes. - Code showing the fix: The
getCxoneTokenfunction checkstokenExpiry - 60000to proactively refresh. Wrap API calls in a retry loop that catches401and forces a fresh token fetch.
Error: 403 Forbidden
- What causes it: The OAuth token lacks
outbound:run:editscope, 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:editto the authorized scopes. Reauthorize the client. - Code showing the fix: The
verifyControlPrerequisitesfunction throws a descriptive error if run listing fails. Catch403explicitly 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-retrywrapper inexecuteRunControlhandles this automatically by reading theRetry-Afterheader and delaying subsequent attempts. - Code showing the fix: The
pRetryconfiguration setsminTimeout: 1000,maxTimeout: 5000, andfactor: 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
buildRunControlPayloadfunction callsRunControlSchema.parse(). CatchZodErrorand log the exact field violations before sending the request.