Automating NICE CXone Outbound Campaign Launches with TypeScript
What You Will Build
- A Node.js scheduler that reads YAML configuration files, calculates UTC start windows from contact timezone attributes, and creates Outbound campaigns via the CXone REST API.
- The solution uses the CXone Outbound API v2 for campaign definition, status polling, and deployment tracking.
- The implementation uses TypeScript, Node.js 18+, and standard HTTP libraries.
Prerequisites
- OAuth 2.0 Service Account (Client Credentials) with
outbound:campaign:writeandoutbound:campaign:readscopes. - CXone API v2 (Outbound module).
- Node.js 18+ with TypeScript 5+.
- Dependencies:
js-yaml,luxon,dotenv.
Authentication Setup
CXone uses the OAuth 2.0 Client Credentials flow. The authentication client must cache tokens and automatically refresh them before expiration to avoid interrupting the polling loop. The following class handles token acquisition, expiration tracking, and scope validation.
interface OAuthToken {
access_token: string;
token_type: string;
expires_in: number;
scope: string;
}
export class ConeAuth {
private token: OAuthToken | null = null;
private expiresAt: number = 0;
constructor(
private instance: string,
private clientId: string,
private clientSecret: string
) {}
private async fetchToken(): Promise<void> {
const response = await fetch(`https://${this.instance}.cxone.com/api/v2/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: this.clientId,
client_secret: this.clientSecret,
scope: 'outbound:campaign:write outbound:campaign:read'
})
});
if (!response.ok) {
const errorBody = await response.text();
throw new Error(`OAuth token fetch failed (${response.status}): ${errorBody}`);
}
const data = (await response.json()) as OAuthToken;
this.token = data;
// Refresh 60 seconds before actual expiration to handle clock drift
this.expiresAt = Date.now() + (data.expires_in * 1000) - 60000;
}
async getAccessToken(): Promise<string> {
if (!this.token || Date.now() >= this.expiresAt) {
await this.fetchToken();
}
return this.token!.access_token;
}
}
Implementation
Step 1: Parse YAML Configuration & Calculate Timezone-Adjusted Windows
The scheduler reads campaign parameters from a YAML file. Contact lists in CXone often segment audiences by region. The configuration specifies the target timezone attribute, local start time, and local end time. The scheduler converts these values to UTC ISO 8601 strings required by the Outbound API.
import * as fs from 'fs';
import * as yaml from 'js-yaml';
import { DateTime } from 'luxon';
interface CampaignConfig {
name: string;
contactListId: string;
timeZone: string;
localStartTime: string;
localEndTime: string;
}
export function loadCampaignConfig(filePath: string): CampaignConfig {
const fileContents = fs.readFileSync(filePath, 'utf8');
const parsed = yaml.load(fileContents) as CampaignConfig;
return parsed;
}
export function calculateUtcWindow(config: CampaignConfig): { start: string; end: string } {
const today = DateTime.now();
const startHour = parseInt(config.localStartTime.split(':')[0], 10);
const startMinute = parseInt(config.localStartTime.split(':')[1], 10);
const endHour = parseInt(config.localEndTime.split(':')[0], 10);
const endMinute = parseInt(config.localEndTime.split(':')[1], 10);
const startLocal = today.set({ hour: startHour, minute: startMinute }).setZone(config.timeZone);
const endLocal = today.set({ hour: endHour, minute: endMinute }).setZone(config.timeZone);
if (!startLocal.isValid || !endLocal.isValid) {
throw new Error(`Invalid timezone configuration: ${config.timeZone}`);
}
return {
start: startLocal.toUTC().toISO(),
end: endLocal.toUTC().toISO()
};
}
Step 2: Construct & Submit Campaign Definition
The CXone Outbound API expects a structured payload with a schedule object containing UTC timestamps. The API design separates campaign metadata from execution windows to allow reuse of templates across different time zones. The following function constructs the payload and submits it with automatic 429 retry logic.
export async function createCampaign(
auth: ConeAuth,
config: CampaignConfig,
utcWindow: { start: string; end: string }
): Promise<string> {
const payload = {
name: config.name,
contactListId: config.contactListId,
schedule: {
start: utcWindow.start,
end: utcWindow.end,
type: 'ONCE'
},
status: 'DRAFT',
dialStrategy: 'PREDICTIVE',
maxAttempts: 3,
language: 'en-US',
wrapUpCode: 'CALL_COMPLETE',
skills: []
};
const maxRetries = 3;
let attempt = 0;
while (attempt < maxRetries) {
attempt++;
const token = await auth.getAccessToken();
const response = await fetch(`https://${auth.instance}.cxone.com/api/v2/outbound/campaigns`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
if (response.status === 429) {
const retryAfter = parseInt(response.headers.get('Retry-After') || '5', 10);
console.log(`Campaign creation rate limited. Waiting ${retryAfter}s...`);
await new Promise(resolve => setTimeout(resolve, retryAfter * 1000));
continue;
}
if (!response.ok) {
const errorBody = await response.text();
throw new Error(`Campaign creation failed (${response.status}): ${errorBody}`);
}
const result = await response.json();
return result.id;
}
throw new Error('Campaign creation failed after maximum retries');
}
Step 3: Monitor Deployment Status with Adaptive Polling
Campaign creation returns a DRAFT status. CXone transitions campaigns through DEPLOYING to RUNNING. The polling loop uses adaptive intervals: it starts at 5 seconds, increases to 15 seconds after three consecutive pending states, and caps at 30 seconds to respect API rate limits. The loop terminates on RUNNING, FAILED, or STOPPED.
export async function pollCampaignStatus(
auth: ConeAuth,
campaignId: string,
maxAttempts: number = 20
): Promise<string> {
let interval = 5000;
let attempts = 0;
let consecutivePending = 0;
while (attempts < maxAttempts) {
attempts++;
const token = await auth.getAccessToken();
const response = await fetch(`https://${auth.instance}.cxone.com/api/v2/outbound/campaigns/${campaignId}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (response.status === 429) {
const retryAfter = parseInt(response.headers.get('Retry-After') || '5', 10);
console.log(`Status poll rate limited. Waiting ${retryAfter}s...`);
await new Promise(resolve => setTimeout(resolve, retryAfter * 1000));
continue;
}
if (!response.ok) {
throw new Error(`Status poll failed (${response.status}): ${await response.text()}`);
}
const data = await response.json();
const status = data.status;
if (status === 'RUNNING') {
return status;
}
if (status === 'FAILED' || status === 'STOPPED') {
return status;
}
consecutivePending++;
if (consecutivePending >= 3) {
interval = Math.min(interval * 1.5, 30000);
}
console.log(`Attempt ${attempts}: Status is ${status}. Waiting ${interval / 1000}s...`);
await new Promise(resolve => setTimeout(resolve, interval));
}
throw new Error('Campaign deployment timed out');
}
Step 4: Trigger Slack Notifications
The scheduler sends structured messages to a Slack Incoming Webhook. The payload includes color coding for success versus error states and embeds the campaign identifier for traceability.
export async function sendSlackNotification(
webhookUrl: string,
campaignId: string,
status: string,
isError: boolean
): Promise<void> {
const message = `CXone Campaign ${status}: ${campaignId}`;
await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: message,
attachments: [{
color: isError ? 'danger' : 'good',
fields: [
{ title: 'Campaign ID', value: campaignId, short: true },
{ title: 'Status', value: status, short: true },
{ title: 'Timestamp', value: new Date().toISOString(), short: false }
]
}]
})
});
}
Complete Working Example
The following TypeScript module combines all components into a runnable scheduler. Save it as campaign-scheduler.ts, install dependencies with npm install js-yaml luxon dotenv, and execute with ts-node campaign-scheduler.ts.
import * as dotenv from 'dotenv';
dotenv.config();
import { ConeAuth } from './auth';
import { loadCampaignConfig, calculateUtcWindow } from './config';
import { createCampaign, pollCampaignStatus, sendSlackNotification } from './api';
async function main() {
const instance = process.env.CXONE_INSTANCE!;
const clientId = process.env.CXONE_CLIENT_ID!;
const clientSecret = process.env.CXONE_CLIENT_SECRET!;
const slackWebhook = process.env.SLACK_WEBHOOK_URL!;
const configPath = process.env.CAMPAIGN_CONFIG_PATH!;
if (!instance || !clientId || !clientSecret || !slackWebhook || !configPath) {
console.error('Missing required environment variables');
process.exit(1);
}
const auth = new ConeAuth(instance, clientId, clientSecret);
const config = loadCampaignConfig(configPath);
console.log(`Processing campaign: ${config.name}`);
const utcWindow = calculateUtcWindow(config);
console.log(`UTC Schedule: ${utcWindow.start} to ${utcWindow.end}`);
try {
const campaignId = await createCampaign(auth, config, utcWindow);
console.log(`Campaign created: ${campaignId}`);
const finalStatus = await pollCampaignStatus(auth, campaignId);
const isError = finalStatus !== 'RUNNING';
await sendSlackNotification(slackWebhook, campaignId, finalStatus, isError);
console.log(`Final status: ${finalStatus}. Notification sent.`);
} catch (error) {
console.error('Scheduler failed:', error);
await sendSlackNotification(slackWebhook, 'UNKNOWN', 'ERROR', true);
process.exit(1);
}
}
main();
Common Errors & Debugging
Error: 401 Unauthorized
- What causes it: The OAuth token has expired, the client credentials are incorrect, or the service account lacks the required scopes.
- How to fix it: Verify the
CXONE_CLIENT_IDandCXONE_CLIENT_SECRETenvironment variables. Ensure the service account in the CXone admin console hasoutbound:campaign:writeandoutbound:campaign:readassigned. The authentication class automatically refreshes tokens, but a fresh restart clears the cache. - Code showing the fix: The
ConeAuthclass already implements expiration tracking. If the error persists, log the raw OAuth response to verify scope rejection.
Error: 400 Bad Request
- What causes it: The campaign payload contains invalid date formats, a missing
contactListId, or a timezone that luxon cannot parse. - How to fix it: Validate that
utcWindow.startandutcWindow.endare valid ISO 8601 strings withZsuffixes. Confirm thecontactListIdmatches an existing list in your CXone instance. - Code showing the fix: Add payload validation before submission.
if (!utcWindow.start || !utcWindow.end) { throw new Error('UTC window calculation failed'); } if (!config.contactListId || !config.contactListId.includes('-')) { throw new Error('Invalid contactListId format'); }
Error: 429 Too Many Requests
- What causes it: CXone enforces rate limits on campaign creation and status polling endpoints. Rapid scheduler execution or concurrent deployments trigger this limit.
- How to fix it: The implementation already reads the
Retry-Afterheader and pauses execution. Ensure the polling interval does not drop below 5 seconds. If multiple schedulers run simultaneously, stagger their start times using a randomized initial delay. - Code showing the fix: The
createCampaignandpollCampaignStatusfunctions both checkresponse.status === 429and apply theRetry-Afterdelay.