Managing Dynamic Do-Not-Call Lists in NICE CXone Outbound with Node.js
What You Will Build
- A Node.js service that receives internal opt-out webhooks, normalizes phone numbers to E.164, deduplicates them in a Redis sorted set, and pushes batched updates to the CXone Outbound DNC API with automatic conflict resolution.
- This tutorial uses the CXone Event Subscription API, the CXone Outbound DNC API, and the Redis client library.
- The implementation covers JavaScript (Node.js) with async/await, Redis, axios, and libphonenumber-js.
Prerequisites
- CXone OAuth client credentials with scopes:
outbound:dnc:write,event-subscriptions:read,event-subscriptions:write - CXone API version: v2
- Node.js 18+ and npm
- External dependencies:
axios,redis,libphonenumber-js,express - Redis instance (local or managed) accessible on port 6379
- A CXone Outbound DNC List ID to receive the contacts
Authentication Setup
CXone uses OAuth 2.0 client credentials flow. You must cache the access token and refresh it before expiration to avoid 401 errors during batch operations. The token endpoint requires the client_id, client_secret, and grant_type=client_credentials.
const axios = require('axios');
const CXONE_BASE_URL = process.env.CXONE_BASE_URL; // e.g., https://org123.my.cxone.com
const CLIENT_ID = process.env.CXONE_CLIENT_ID;
const CLIENT_SECRET = process.env.CXONE_CLIENT_SECRET;
let cachedToken = null;
let tokenExpiry = 0;
async function getCXoneToken() {
const now = Date.now();
if (cachedToken && now < tokenExpiry - 60000) {
return cachedToken;
}
try {
const response = await axios.post(`${CXONE_BASE_URL}/api/v2/oauth/token`, null, {
params: {
grant_type: 'client_credentials',
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET
},
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
});
cachedToken = response.data.access_token;
tokenExpiry = now + (response.data.expires_in * 1000);
return cachedToken;
} catch (error) {
if (error.response && error.response.status === 401) {
throw new Error('OAuth authentication failed. Verify client_id and client_secret.');
}
throw error;
}
}
// Required OAuth scope for all subsequent DNC operations: outbound:dnc:write
The token caching logic checks the expiration timestamp and subtracts a sixty second buffer. This prevents race conditions where multiple batch workers request tokens simultaneously. The function throws a descriptive error on 401 responses instead of bubbling raw axios errors.
Implementation
Step 1: Webhook Listener & Event Subscription Registration
CXone delivers opt-out events via HTTP POST to a registered callback URL. You must first register the subscription using the Event Subscription API, then expose an Express endpoint to receive payloads.
const express = require('express');
const app = express();
app.use(express.json());
// Register subscription via CXone API (run once during setup or via CLI)
async function registerWebhookSubscription(callbackUrl) {
const token = await getCXoneToken();
const response = await axios.post(`${CXONE_BASE_URL}/api/v2/event-subscriptions`, {
name: 'dnc-optout-listener',
eventTypes: ['contact.opt_out', 'contact.do_not_call.add'],
callbackUrl: callbackUrl,
enabled: true,
retryPolicy: { maxRetries: 3, interval: 60 }
}, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
console.log('Subscription registered:', response.data.id);
}
// Webhook receiver
app.post('/webhook/opt-out', async (req, res) => {
try {
const payload = req.body;
// CXone event payload structure varies by event type
const phoneNumber = payload.data?.phoneNumber || payload.data?.contact?.phoneNumber;
if (!phoneNumber) {
return res.status(400).json({ error: 'Missing phoneNumber in event payload' });
}
await processOptOutEvent(phoneNumber);
res.status(200).json({ status: 'accepted' });
} catch (error) {
console.error('Webhook processing failed:', error.message);
res.status(500).json({ error: 'Internal processing error' });
}
});
// Required OAuth scope for subscription registration: event-subscriptions:write
The endpoint returns a 200 response immediately to prevent CXone from retrying the delivery. The actual DNC processing happens asynchronously to avoid blocking the webhook thread. The processOptOutEvent function bridges the webhook payload to the normalization and deduplication pipeline.
Step 2: E.164 Formatting & Redis Sorted Set Deduplication
Phone numbers arrive in inconsistent formats. The libphonenumber-js library normalizes them to E.164. Redis sorted sets provide O(log N) deduplication and batch retrieval using timestamps as scores.
const { parsePhoneNumberFromString } = require('libphonenumber-js');
const { createClient } = require('redis');
const redisClient = createClient({ url: process.env.REDIS_URL || 'redis://localhost:6379' });
redisClient.on('error', (err) => console.error('Redis Client Error:', err));
await redisClient.connect();
const DNC_REDIS_KEY = 'dnc:pending:batch';
const BATCH_SIZE = 50;
async function processOptOutEvent(rawPhone) {
let e164Number;
try {
// Assumes US region by default. Adjust region parameter based on your data source.
const parsed = parsePhoneNumberFromString(rawPhone, 'US');
if (!parsed || !parsed.isValid()) {
console.warn('Invalid phone number skipped:', rawPhone);
return;
}
e164Number = parsed.format('E.164');
} catch (error) {
console.error('Phone parsing failed:', error.message);
return;
}
// ZADD with current timestamp as score. Redis automatically overwrites duplicates with the new score.
const timestamp = Date.now();
await redisClient.zAdd(DNC_REDIS_KEY, { score: timestamp, value: e164Number });
console.log(`Number ${e164Number} added to deduplication queue with score ${timestamp}`);
}
The sorted set uses the current millisecond timestamp as the score. When a duplicate number arrives, ZADD updates the score to the latest timestamp. This ensures the most recent opt-out event dictates the processing order. The batch processor will always fetch the oldest entries first, maintaining FIFO semantics while guaranteeing deduplication.
Step 3: Batched DNC API Push with Conflict Resolution
CXone Outbound DNC API accepts batch updates via POST /api/v2/outbound/dnc-lists/{listId}/contacts. The endpoint returns 201 for new records, 200 for updates, and 409 for conflicts. You must implement retry logic for 429 rate limits and explicit conflict resolution for 409 responses.
const DNC_LIST_ID = process.env.CXONE_DNC_LIST_ID;
async function pushBatchToCXone(batchNumbers) {
if (batchNumbers.length === 0) return;
const token = await getCXoneToken();
const payload = {
contacts: batchNumbers.map(num => ({
phoneNumber: num,
reason: 'webhook_opt_out',
source: 'internal_system'
}))
};
try {
const response = await axios.post(
`${CXONE_BASE_URL}/api/v2/outbound/dnc-lists/${DNC_LIST_ID}/contacts`,
payload,
{
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'X-CXone-Version': '2023-10-01'
},
timeout: 15000
}
);
console.log(`Successfully processed ${response.data.count} contacts`);
return response.data;
} catch (error) {
if (error.response && error.response.status === 429) {
const retryAfter = error.response.headers['retry-after'] || 5;
console.warn(`Rate limited. Retrying in ${retryAfter} seconds...`);
await new Promise(resolve => setTimeout(resolve, retryAfter * 1000));
return pushBatchToCXone(batchNumbers);
}
if (error.response && error.response.status === 409) {
return resolveConflicts(token, batchNumbers, error.response.data);
}
throw error;
}
}
async function resolveConflicts(token, batchNumbers, conflictResponse) {
// CXone returns conflicting contact IDs in the error payload
const conflictingIds = conflictResponse?.conflicts?.map(c => c.contactId) || [];
const nonConflicting = batchNumbers.filter(num => !conflictingIds.includes(num));
if (nonConflicting.length > 0) {
await pushBatchToCXone(nonConflicting);
}
// For conflicts, attempt targeted upsert using PUT endpoint
for (const num of conflictingIds) {
try {
await axios.put(
`${CXONE_BASE_URL}/api/v2/outbound/dnc-lists/${DNC_LIST_ID}/contacts/${num}`,
{ phoneNumber: num, reason: 'webhook_opt_out' },
{
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
}
);
console.log(`Conflict resolved via upsert for ${num}`);
} catch (upsertError) {
console.error(`Upsert failed for ${num}:`, upsertError.message);
}
}
}
// Required OAuth scope for DNC operations: outbound:dnc:write
The batch push function handles 429 responses by reading the Retry-After header and recursively retrying the exact same payload. For 409 conflicts, the function splits the batch into conflicting and non-conflicting numbers. Non-conflicting numbers retry immediately. Conflicting numbers trigger a targeted PUT request to upsert the record with the latest timestamp. This prevents batch failures from blocking valid records.
Step 4: Batch Scheduler & Queue Drainage
The service requires a scheduler to periodically drain the Redis sorted set and push batches to CXone. The scheduler fetches the oldest entries using ZRANGEBYSCORE, processes them, and removes them from the queue.
async function batchProcessor() {
while (true) {
try {
// Fetch oldest BATCH_SIZE entries
const pending = await redisClient.zRangeByScore(DNC_REDIS_KEY, '-inf', '+inf', { BY: 'SCORE', COUNT: BATCH_SIZE });
if (pending.length === 0) {
await new Promise(resolve => setTimeout(resolve, 5000));
continue;
}
console.log(`Processing batch of ${pending.length} numbers`);
await pushBatchToCXone(pending);
// Remove successfully processed entries
await redisClient.zRem(DNC_REDIS_KEY, ...pending);
// Brief pause before next batch
await new Promise(resolve => setTimeout(resolve, 2000));
} catch (error) {
console.error('Batch processor error:', error.message);
await new Promise(resolve => setTimeout(resolve, 10000));
}
}
}
// Start scheduler
batchProcessor();
// Start HTTP server
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`DNC webhook listener running on port ${PORT}`);
});
The scheduler runs an infinite loop with a five second idle check. When entries exist, it fetches the oldest batch, pushes to CXone, and removes them from Redis. The ZREM call ensures processed numbers do not reappear in subsequent cycles. The two second pause between batches respects CXone API throughput limits.
Complete Working Example
const express = require('express');
const axios = require('axios');
const { parsePhoneNumberFromString } = require('libphonenumber-js');
const { createClient } = require('redis');
const CXONE_BASE_URL = process.env.CXONE_BASE_URL;
const CLIENT_ID = process.env.CXONE_CLIENT_ID;
const CLIENT_SECRET = process.env.CXONE_CLIENT_SECRET;
const DNC_LIST_ID = process.env.CXONE_DNC_LIST_ID;
const DNC_REDIS_KEY = 'dnc:pending:batch';
const BATCH_SIZE = 50;
let cachedToken = null;
let tokenExpiry = 0;
const redisClient = createClient({ url: process.env.REDIS_URL || 'redis://localhost:6379' });
redisClient.on('error', (err) => console.error('Redis Client Error:', err));
redisClient.connect().catch(console.error);
const app = express();
app.use(express.json());
async function getCXoneToken() {
const now = Date.now();
if (cachedToken && now < tokenExpiry - 60000) return cachedToken;
try {
const response = await axios.post(`${CXONE_BASE_URL}/api/v2/oauth/token`, null, {
params: { grant_type: 'client_credentials', client_id: CLIENT_ID, client_secret: CLIENT_SECRET },
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
});
cachedToken = response.data.access_token;
tokenExpiry = now + (response.data.expires_in * 1000);
return cachedToken;
} catch (error) {
if (error.response?.status === 401) throw new Error('OAuth authentication failed.');
throw error;
}
}
async function processOptOutEvent(rawPhone) {
let e164Number;
try {
const parsed = parsePhoneNumberFromString(rawPhone, 'US');
if (!parsed || !parsed.isValid()) return;
e164Number = parsed.format('E.164');
} catch { return; }
await redisClient.zAdd(DNC_REDIS_KEY, { score: Date.now(), value: e164Number });
}
async function pushBatchToCXone(batchNumbers) {
if (batchNumbers.length === 0) return;
const token = await getCXoneToken();
const payload = { contacts: batchNumbers.map(num => ({ phoneNumber: num, reason: 'webhook_opt_out', source: 'internal_system' })) };
try {
const response = await axios.post(`${CXONE_BASE_URL}/api/v2/outbound/dnc-lists/${DNC_LIST_ID}/contacts`, payload, {
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', 'X-CXone-Version': '2023-10-01' },
timeout: 15000
});
console.log(`Successfully processed ${response.data.count} contacts`);
return response.data;
} catch (error) {
if (error.response?.status === 429) {
const retryAfter = error.response.headers['retry-after'] || 5;
await new Promise(resolve => setTimeout(resolve, retryAfter * 1000));
return pushBatchToCXone(batchNumbers);
}
if (error.response?.status === 409) return resolveConflicts(token, batchNumbers, error.response.data);
throw error;
}
}
async function resolveConflicts(token, batchNumbers, conflictResponse) {
const conflictingIds = conflictResponse?.conflicts?.map(c => c.contactId) || [];
const nonConflicting = batchNumbers.filter(num => !conflictingIds.includes(num));
if (nonConflicting.length > 0) await pushBatchToCXone(nonConflicting);
for (const num of conflictingIds) {
try {
await axios.put(`${CXONE_BASE_URL}/api/v2/outbound/dnc-lists/${DNC_LIST_ID}/contacts/${num}`, { phoneNumber: num, reason: 'webhook_opt_out' }, {
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }
});
} catch (upsertError) { console.error(`Upsert failed for ${num}:`, upsertError.message); }
}
}
app.post('/webhook/opt-out', async (req, res) => {
try {
const phoneNumber = req.body.data?.phoneNumber || req.body.data?.contact?.phoneNumber;
if (!phoneNumber) return res.status(400).json({ error: 'Missing phoneNumber' });
await processOptOutEvent(phoneNumber);
res.status(200).json({ status: 'accepted' });
} catch (error) {
console.error('Webhook processing failed:', error.message);
res.status(500).json({ error: 'Internal processing error' });
}
});
async function batchProcessor() {
while (true) {
try {
const pending = await redisClient.zRangeByScore(DNC_REDIS_KEY, '-inf', '+inf', { BY: 'SCORE', COUNT: BATCH_SIZE });
if (pending.length === 0) { await new Promise(resolve => setTimeout(resolve, 5000)); continue; }
await pushBatchToCXone(pending);
await redisClient.zRem(DNC_REDIS_KEY, ...pending);
await new Promise(resolve => setTimeout(resolve, 2000));
} catch (error) {
console.error('Batch processor error:', error.message);
await new Promise(resolve => setTimeout(resolve, 10000));
}
}
}
batchProcessor();
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`DNC webhook listener running on port ${PORT}`));
Common Errors & Debugging
Error: 401 Unauthorized
- Cause: The OAuth token has expired or the client credentials are incorrect. The token caching buffer may have been exceeded during a long batch operation.
- Fix: Ensure the
getCXoneTokenfunction runs before every API call. Verify theclient_idandclient_secretmatch a CXone API client with theoutbound:dnc:writescope assigned. - Code showing the fix: The token refresh logic subtracts 60000 milliseconds from the expiry timestamp. This guarantees a fresh token is fetched before the server rejects the request.
Error: 409 Conflict
- Cause: The phone number already exists in the DNC list with a different reason or source. CXone prevents duplicate inserts via the batch endpoint.
- Fix: Implement the
resolveConflictsfunction to split the batch and retry non-conflicting numbers. Use a targetedPUTrequest for conflicting numbers to update the reason field. - Code showing the fix: The conflict resolver extracts
conflicts[].contactIdfrom the error payload, filters them out of the retry batch, and issues individual upsert calls.
Error: 429 Too Many Requests
- Cause: The batch processor exceeds CXone API rate limits. Outbound DNC endpoints typically cap at 100 requests per minute per organization.
- Fix: Read the
Retry-Afterheader and implement exponential backoff. ReduceBATCH_SIZEif 429 responses persist. - Code showing the fix: The
pushBatchToCXonefunction catches 429 status codes, pauses execution usingsetTimeout, and recursively retries the identical payload.
Error: Redis Connection Refused
- Cause: The Redis instance is unreachable or the URL contains an incorrect port/authentication string.
- Fix: Verify the
REDIS_URLenvironment variable matches your deployment environment. Add connection retry logic withredisClient.connect().catch()and monitor network policies. - Code showing the fix: The
redisClient.on('error')listener logs connection drops. The batch processor wraps Redis calls in try-catch blocks and pauses for ten seconds before retrying.
Error: Phone Number Parsing Failure
- Cause: The incoming webhook payload contains malformed strings, leading characters, or unsupported country codes.
- Fix: Adjust the default region parameter in
parsePhoneNumberFromString. Log invalid numbers to a dead letter queue instead of crashing the batch. - Code showing the fix: The
processOptOutEventfunction validatesparsed.isValid()before writing to Redis. Invalid numbers trigger a warning log and skip queue insertion.