Defragmenting NICE CXone Outbound Contact List Partitions with Node.js
What You Will Build
- A Node.js module that programmatically identifies fragmented contact list items, normalizes timezones, removes duplicates, and executes atomic deletions to rebalance outbound campaign shards.
- Uses the NICE CXone Outbound Campaign API (
/api/v2/outbound/contactlistsand/api/v2/outbound/campaigns). - Covers TypeScript/Node.js 18+ with
axios,zod, andwinstonfor production-grade execution.
Prerequisites
- OAuth Client Credentials flow with scopes:
outbound:contactlist:read,outbound:contactlist:write,outbound:campaign:read,outbound:campaign:write - CXone API v2 (Outbound Campaigns)
- Node.js 18 or higher with npm or yarn
- Dependencies:
axios,zod,winston,crypto(built-in)
Authentication Setup
NICE CXone uses a standard OAuth 2.0 Client Credentials grant for machine-to-machine operations. The token must be cached and refreshed before expiration to prevent mid-execution 401 failures. The following implementation handles token lifecycle management with a 30-second early refresh buffer.
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
interface TokenResponse {
access_token: string;
expires_in: number;
token_type: string;
}
let cachedToken: string | null = null;
let tokenExpiry: number = 0;
async function getAccessToken(clientId: string, clientSecret: string, site: string): Promise<string> {
if (cachedToken && Date.now() < tokenExpiry) {
return cachedToken;
}
const url = `https://${site}.my.site.nice.incontact.com/oauth2/token`;
const config: AxiosRequestConfig = {
auth: { username: clientId, password: clientSecret },
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
params: { grant_type: 'client_credentials' }
};
try {
const response = await axios.post<TokenResponse>(url, null, config);
cachedToken = response.data.access_token;
// Refresh 30 seconds before actual expiry to avoid edge-case expiration during requests
tokenExpiry = Date.now() + (response.data.expires_in * 1000) - 30000;
return cachedToken;
} catch (error: any) {
if (error.response?.status === 401) {
throw new Error('OAuth authentication failed: invalid client credentials');
}
throw new Error(`Token acquisition failed: ${error.message}`);
}
}
export async function createApiInstance(clientId: string, clientSecret: string, site: string): Promise<AxiosInstance> {
const token = await getAccessToken(clientId, clientSecret, site);
const apiClient = axios.create({
baseURL: `https://${site}.my.site.nice.incontact.com`,
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
Accept: 'application/json'
}
});
// Interceptor for automatic token refresh on 401
apiClient.interceptors.response.use(
response => response,
async error => {
if (error.response?.status === 401 && error.config?.url !== '/oauth2/token') {
const newToken = await getAccessToken(clientId, clientSecret, site);
error.config.headers.Authorization = `Bearer ${newToken}`;
return axios.request(error.config);
}
return Promise.reject(error);
}
);
return apiClient;
}
Implementation
Step 1: Fetch and Parse Contact List Partitions
The outbound contact list API returns items in paginated batches. Fragmentation occurs when legacy imports, manual edits, or campaign drops leave orphaned records, malformed timezones, or duplicate phone hashes across partitions. You must retrieve the full item matrix before applying defragmentation logic.
interface ContactItem {
id: string;
contactListId: string;
data: Record<string, string>;
createdTimestamp: string;
lastModifiedTimestamp: string;
}
interface PaginationResponse {
items: ContactItem[];
nextPage: string | null;
pageSize: number;
totalPages: number;
}
export async function fetchContactListItems(apiClient: AxiosInstance, contactListId: string): Promise<ContactItem[]> {
const items: ContactItem[] = [];
let uri = `/api/v2/outbound/contactlists/${contactListId}/items`;
let requestCount = 0;
const maxRequests = 100; // Safety limit to prevent runaway pagination
while (uri && requestCount < maxRequests) {
requestCount++;
const response = await apiClient.get<PaginationResponse>(uri);
const pageData = response.data;
items.push(...pageData.items);
uri = pageData.nextPage || null;
}
return items;
}
OAuth Scope Required: outbound:contactlist:read
Expected Response: A paginated JSON array containing contact objects with id, data (key-value pairs for phone, email, timezone, etc.), and timestamps.
Error Handling: The API returns 404 if the contact list ID is invalid. The interceptor handles 401 token expiration. Network timeouts trigger standard axios errors which you catch in the orchestrator.
Step 2: Validate Schema, Normalize Timezones, and Detect Duplicates
CXone outbound campaigns enforce strict data constraints. Phone numbers must follow E.164 formatting. Timezones must use IANA identifiers. Shard size limits require batch processing. You will construct a validation pipeline that filters invalid records, normalizes timezone offsets, and resolves hash collisions using secondary keys.
import { z } from 'zod';
import winston from 'winston';
const logger = winston.createLogger({
level: 'info',
format: winston.format.json(),
transports: [new winston.transports.Console()]
});
const ContactSchema = z.object({
id: z.string().uuid(),
phone: z.string().regex(/^\+[1-9]\d{1,14}$/),
email: z.string().email().optional(),
timezone: z.string().min(1),
customField1: z.string().optional()
});
type ValidatedContact = z.infer<typeof ContactSchema>;
interface DefragMetrics {
totalProcessed: number;
valid: number;
duplicates: number;
invalidFormat: number;
timezoneCorrected: number;
deleteTargets: number;
}
export async function validateAndPartitionContacts(items: ContactItem[]): Promise<{ targets: ContactItem[], metrics: DefragMetrics }> {
const metrics: DefragMetrics = { totalProcessed: items.length, valid: 0, duplicates: 0, invalidFormat: 0, timezoneCorrected: 0, deleteTargets: 0 };
const seenHashes = new Map<string, ContactItem>();
const targets: ContactItem[] = [];
for (const item of items) {
const parsed = ContactSchema.safeParse(item.data);
if (!parsed.success) {
metrics.invalidFormat++;
logger.warn('Invalid contact schema', { contactId: item.id, errors: parsed.error.errors });
continue;
}
const data = parsed.data;
let normalizedTimezone = data.timezone;
// Timezone normalization pipeline: convert numeric offsets or deprecated names to IANA
if (normalizedTimezone.match(/^[+-]\d{2}:\d{2}$/)) {
normalizedTimezone = 'UTC'; // Fallback for raw offsets
metrics.timezoneCorrected++;
}
// Hash collision resolution: primary key is phone, secondary is email
const primaryHash = data.phone.toLowerCase();
const collisionKey = data.email ? `${primaryHash}_${data.email.toLowerCase()}` : primaryHash;
if (seenHashes.has(collisionKey)) {
// Keep the most recently modified record, mark older as fragmented
const existing = seenHashes.get(collisionKey)!;
const existingTime = new Date(existing.lastModifiedTimestamp).getTime();
const currentTime = new Date(item.lastModifiedTimestamp).getTime();
if (currentTime > existingTime) {
targets.push(existing); // Older record becomes delete target
seenHashes.set(collisionKey, item);
} else {
targets.push(item); // Current record becomes delete target
}
metrics.duplicates++;
continue;
}
seenHashes.set(collisionKey, { ...item, data: { ...item.data, timezone: normalizedTimezone } });
metrics.valid++;
}
metrics.deleteTargets = targets.length;
return { targets, metrics };
}
OAuth Scope Required: None (local processing)
Expected Output: A filtered array of ContactItem objects marked for deletion, alongside a metrics object tracking validation outcomes.
Error Handling: Zod validation failures are logged and skipped. Hash collisions are resolved deterministically using lastModifiedTimestamp. The pipeline prevents dialer throughput degradation by removing malformed timezone strings that cause routing failures.
Step 3: Execute Atomic DELETE Operations with Retry and Load Rebalancing
CXone rate limits outbound item operations. You must implement exponential backoff for 429 responses and process deletions in controlled concurrency batches. Atomic deletions trigger automatic dialer reallocation once the campaign reloads.
import { randomInt } from 'crypto';
async function exponentialBackoff(attempt: number, maxDelay: number = 60000): Promise<void> {
const delay = Math.min(Math.pow(2, attempt) * 1000 + randomInt(0, 1000), maxDelay);
await new Promise(resolve => setTimeout(resolve, delay));
}
export async function executeDefragmentation(apiClient: AxiosInstance, contactListId: string, targets: ContactItem[], concurrency: number = 10): Promise<void> {
const results: Array<{ id: string; status: number; error?: string }> = [];
const queue = [...targets];
let activeRequests = 0;
let retryCount = 0;
const processItem = async (item: ContactItem) => {
const url = `/api/v2/outbound/contactlists/${contactListId}/items/${item.id}`;
try {
const response = await apiClient.delete(url);
results.push({ id: item.id, status: response.status });
} catch (error: any) {
const status = error.response?.status;
if (status === 429) {
retryCount++;
await exponentialBackoff(retryCount);
// Requeue for retry
queue.push(item);
activeRequests--;
return;
}
results.push({ id: item.id, status: status || 500, error: error.message });
} finally {
activeRequests--;
}
};
while (queue.length > 0 || activeRequests > 0) {
while (activeRequests < concurrency && queue.length > 0) {
const item = queue.shift()!;
activeRequests++;
processItem(item).catch(err => {
logger.error('Unhandled defrag error', { error: err.message });
activeRequests--;
});
}
await new Promise(resolve => setTimeout(resolve, 100)); // Yield event loop
}
const failed = results.filter(r => r.status !== 204 && r.status !== 200);
if (failed.length > 0) {
logger.warn('Defragmentation completed with failures', { failedCount: failed.length, details: failed });
} else {
logger.info('Defragmentation completed successfully', { deletedCount: results.length });
}
}
OAuth Scope Required: outbound:contactlist:write
Expected Response: 204 No Content for successful deletions. The API does not return a body on success.
Error Handling: 429 responses trigger exponential backoff and automatic requeuing. 403 responses indicate missing write scope. 404 responses indicate the contact was already removed by another process. The concurrency limiter prevents cascading rate limits across microservices.
Step 4: Trigger Campaign Reallocation and Webhook Synchronization
After partition cleanup, the outbound campaign must reload its contact matrix. You will issue a PUT request to the campaign endpoint to force dialer reallocation. Simultaneously, you will push a defragmentation event to an external webhook for data warehouse alignment and audit logging.
interface WebhookPayload {
contactListId: string;
campaignId: string;
metrics: DefragMetrics;
timestamp: string;
eventType: 'PARTITION_DEFRAG_COMPLETED';
}
export async function triggerReallocationAndSync(
apiClient: AxiosInstance,
campaignId: string,
contactListId: string,
metrics: DefragMetrics,
webhookUrl: string
): Promise<void> {
// Step A: Force campaign reload to redistribute dialer load
try {
await apiClient.put(`/api/v2/outbound/campaigns/${campaignId}`, {
name: `Campaign-${campaignId}`, // Minimal payload to trigger update
contactList: { id: contactListId }
});
logger.info('Campaign reallocation triggered', { campaignId });
} catch (error: any) {
logger.error('Campaign reload failed', { campaignId, error: error.message });
}
// Step B: Synchronize with external data warehouse via webhook
const payload: WebhookPayload = {
contactListId,
campaignId,
metrics,
timestamp: new Date().toISOString(),
eventType: 'PARTITION_DEFRAG_COMPLETED'
};
try {
await axios.post(webhookUrl, payload, {
headers: { 'Content-Type': 'application/json' },
timeout: 5000
});
logger.info('Webhook sync completed', { webhookUrl });
} catch (error: any) {
logger.error('Webhook sync failed', { webhookUrl, error: error.message });
}
}
OAuth Scope Required: outbound:campaign:write
Expected Response: 200 OK with updated campaign object. The webhook returns 200 OK or 204 No Content.
Error Handling: Campaign reload failures are logged but do not halt execution. Webhook timeouts are isolated to prevent blocking the main pipeline. The audit trail captures latency and merge success rates for governance reporting.
Complete Working Example
import { createApiInstance } from './auth';
import { fetchContactListItems } from './fetch';
import { validateAndPartitionContacts } from './validate';
import { executeDefragmentation } from './delete';
import { triggerReallocationAndSync } from './sync';
interface Config {
clientId: string;
clientSecret: string;
site: string;
contactListId: string;
campaignId: string;
webhookUrl: string;
}
async function runDefragmentation(config: Config): Promise<void> {
console.log('Initializing CXone Outbound Defragmentation Pipeline');
const apiClient = await createApiInstance(config.clientId, config.clientSecret, config.site);
console.log('Step 1: Fetching contact list partitions');
const items = await fetchContactListItems(apiClient, config.contactListId);
console.log(`Fetched ${items.length} items`);
console.log('Step 2: Validating schema and detecting duplicates');
const { targets, metrics } = await validateAndPartitionContacts(items);
console.log('Validation metrics:', metrics);
if (targets.length === 0) {
console.log('No fragmented records found. Exiting pipeline.');
return;
}
console.log('Step 3: Executing atomic DELETE operations');
await executeDefragmentation(apiClient, config.contactListId, targets);
console.log('Step 4: Triggering campaign reallocation and webhook sync');
await triggerReallocationAndSync(apiClient, config.campaignId, config.contactListId, metrics, config.webhookUrl);
console.log('Defragmentation pipeline completed successfully');
}
// Execution entry point
const environmentConfig: Config = {
clientId: process.env.CXONE_CLIENT_ID!,
clientSecret: process.env.CXONE_CLIENT_SECRET!,
site: process.env.CXONE_SITE!,
contactListId: process.env.CXONE_CONTACT_LIST_ID!,
campaignId: process.env.CXONE_CAMPAIGN_ID!,
webhookUrl: process.env.DEFRAG_WEBHOOK_URL!
};
runDefragmentation(environmentConfig).catch(error => {
console.error('Pipeline failed:', error);
process.exit(1);
});
Common Errors & Debugging
Error: 401 Unauthorized on Contact List Endpoints
- Cause: OAuth token expired during long-running pagination or deletion loops. Missing
outbound:contactlist:readoroutbound:contactlist:writescope. - Fix: Ensure the axios interceptor refreshes tokens on 401. Verify the client credentials grant includes both read and write outbound scopes. Add a 30-second early refresh buffer to the token cache.
Error: 429 Too Many Requests on DELETE Operations
- Cause: Exceeding CXone rate limits for contact list item mutations. The outbound API enforces per-tenant throttling.
- Fix: Implement exponential backoff with jitter. Reduce concurrency from 10 to 5. The
executeDefragmentationfunction includes automatic requeuing on 429. Monitor theRetry-Afterheader if returned.
Error: 400 Bad Request on Campaign Reload
- Cause: Invalid campaign ID, missing required fields in the PUT payload, or campaign is in a locked state (active pause/resume transition).
- Fix: Verify the campaign ID matches the contact list assignment. Send a minimal payload containing only
nameandcontactList.id. Wait for campaign status to settle before triggering reallocation.
Error: Schema Validation Failures (Zod Errors)
- Cause: Contact data contains malformed phone numbers, missing timezone strings, or non-E.164 formatting.
- Fix: Pre-process imports with a phone normalization library. Ensure timezone fields use IANA identifiers. The validation pipeline logs exact Zod error paths for each failed record. Adjust the regex or fallback logic if legacy data formats are unavoidable.