Reconciling NICE CXone Voice CDR Records with Node.js
What You Will Build
This tutorial builds a Node.js service that fetches voice call detail records from NICE CXone, calculates timestamp and duration deltas against external billing data, constructs correction directives, and submits atomic reconciliation updates to the Voice API while enforcing telephony engine constraints and maximum reconciliation window limits. The code uses the CXone REST API directly with axios, implements pagination, exponential backoff for rate limits, and structured audit logging. The language covered is JavaScript/Node.js.
Prerequisites
- OAuth 2.0 Client Credentials grant with scopes:
telephony:edge:read,cdr:read,cdr:write - CXone API version:
v2 - Node.js 18 or higher
- External dependencies:
npm install axios uuid pino
Authentication Setup
CXone uses a standard OAuth 2.0 client credentials flow. The token expires after 3600 seconds and must be cached to avoid unnecessary authentication requests. The following module handles token acquisition and automatic refresh.
const axios = require('axios');
const CXONE_CONFIG = {
authUrl: 'https://api-us-01.nice-incontact.com/oauth/token',
baseUrl: 'https://api-us-01.nice-incontact.com/api/v2',
clientId: process.env.CXONE_CLIENT_ID,
clientSecret: process.env.CXONE_CLIENT_SECRET,
edgeId: process.env.CXONE_EDGE_ID
};
let cachedToken = null;
let tokenExpiry = 0;
async function getAccessToken() {
const now = Date.now();
if (cachedToken && now < tokenExpiry) {
return cachedToken;
}
const authResponse = await axios.post(
CXONE_CONFIG.authUrl,
new URLSearchParams({
grant_type: 'client_credentials',
client_id: CXONE_CONFIG.clientId,
client_secret: CXONE_CONFIG.clientSecret
}),
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
);
if (!authResponse.data.access_token) {
throw new Error('OAuth response missing access_token');
}
cachedToken = authResponse.data.access_token;
tokenExpiry = now + (authResponse.data.expires_in * 1000) - 5000; // 5 second buffer
return cachedToken;
}
module.exports = { CXONE_CONFIG, getAccessToken };
OAuth Scope Requirement: telephony:edge:read, cdr:read, cdr:write
Implementation
Step 1: Fetch CDR Records and Enforce Reconciliation Window Limits
The CXone CDR endpoint returns records in paginated batches. The telephony engine enforces a maximum reconciliation window of 24 hours. Records older than this window must be excluded to prevent reconciliation failure. The following function fetches all records within the valid window and handles pagination.
const axios = require('axios');
const { CXONE_CONFIG, getAccessToken } = require('./auth');
async function fetchCdrs() {
const maxReconciliationWindowMs = 24 * 60 * 60 * 1000;
const cutoffTime = new Date(Date.now() - maxReconciliationWindowMs).toISOString();
const validCdrs = [];
let nextPageToken = null;
const pageSize = 100;
const token = await getAccessToken();
do {
const params = {
pageSize,
'startTime.gte': cutoffTime,
'endTime.lte': new Date().toISOString()
};
if (nextPageToken) params.pageToken = nextPageToken;
try {
const response = await axios.get(
`${CXONE_CONFIG.baseUrl}/telephony/providers/edges/${CXONE_CONFIG.edgeId}/cdr`,
{
headers: { Authorization: `Bearer ${token}` },
params
}
);
if (response.status !== 200) {
throw new Error(`CDR fetch failed with status ${response.status}`);
}
validCdrs.push(...response.data.entities);
nextPageToken = response.data.nextPageToken || null;
} catch (error) {
if (error.response?.status === 429) {
console.warn('Rate limited. Retrying in 2 seconds...');
await new Promise(r => setTimeout(r, 2000));
continue;
}
throw error;
}
} while (nextPageToken);
return validCdrs;
}
module.exports = { fetchCdrs };
Expected Response Structure:
{
"entities": [
{
"id": "cdr-abc-123",
"callId": "call-xyz-789",
"trunkId": "trunk-primary-01",
"startTime": "2024-05-10T14:30:00.000Z",
"endTime": "2024-05-10T14:32:15.000Z",
"duration": 135000,
"billableSeconds": 136,
"direction": "outbound",
"codec": "G.711"
}
],
"nextPageToken": "eyJwYWdlIjoyfQ=="
}
Step 2: Construct Reconcile Payloads with Timestamp Delta Matrices and Correction Directives
Reconciliation requires comparing CXone CDR timestamps and durations against an external billing system. This step calculates a timestamp delta matrix, verifies call duration alignment, checks for codec mismatches, and constructs a correction directive payload. The payload must match the CXone CDR schema exactly.
const { v4: uuidv4 } = require('uuid');
function buildReconcilePayload(cdrRecord, externalBillingRecord) {
// Timestamp delta matrix calculation
const cxoneStart = new Date(cdrRecord.startTime).getTime();
const cxoneEnd = new Date(cdrRecord.endTime).getTime();
const extStart = new Date(externalBillingRecord.startTime).getTime();
const extEnd = new Date(externalBillingRecord.endTime).getTime();
const startDeltaMs = cxoneStart - extStart;
const endDeltaMs = cxoneEnd - extEnd;
const durationDeltaMs = (cxoneEnd - cxoneStart) - (extEnd - extStart);
// Call duration checking pipeline
const cxoneBillable = cdrRecord.billableSeconds;
const extBillable = externalBillingRecord.billableSeconds;
const durationMismatch = Math.abs(cxoneBillable - extBillable) > 2; // Allow 2 second tolerance
// Codec mismatch verification pipeline
const codecMismatch = cdrRecord.codec !== externalBillingRecord.codec;
if (codecMismatch) {
console.warn(`Codec mismatch detected for call ${cdrRecord.callId}: CXone=${cdrRecord.codec}, External=${externalBillingRecord.codec}`);
}
// Correction directive construction
const correctionDirective = {
reason: durationMismatch ? 'DURATION_ADJUSTMENT' : 'TIMESTAMP_ALIGNMENT',
deltaMatrix: { startDeltaMs, endDeltaMs, durationDeltaMs },
codecVerification: { cxoneCodec: cdrRecord.codec, externalCodec: externalBillingRecord.codec, matched: !codecMismatch },
billingAdjustmentTrigger: durationMismatch ? 'AUTO_ADJUST' : 'NONE'
};
// Atomic reconcile payload matching CXone schema
return {
id: cdrRecord.id,
callId: cdrRecord.callId,
trunkId: cdrRecord.trunkId,
startTime: externalBillingRecord.startTime,
endTime: externalBillingRecord.endTime,
duration: extEnd - extStart,
billableSeconds: extBillable,
direction: cdrRecord.direction,
codec: externalBillingRecord.codec,
metadata: {
reconciliationId: uuidv4(),
correctionDirective,
reconciledAt: new Date().toISOString()
}
};
}
module.exports = { buildReconcilePayload };
Step 3: Execute Atomic POST Operations with Format Verification and Billing Adjustment Triggers
CXone accepts CDR updates via POST to the collection endpoint when creating new reconciled records, or PUT for direct updates. This example uses PUT for atomic record alignment. The function includes exponential backoff for 429 rate limits, validates the payload format before submission, and triggers an external billing synchronization callback upon success.
const axios = require('axios');
const { CXONE_CONFIG, getAccessToken } = require('./auth');
const { buildReconcilePayload } = require('./payload');
const BILLING_WEBHOOK_URL = process.env.BILLING_WEBHOOK_URL;
async function submitReconcilePayload(cdrRecord, externalRecord) {
const payload = buildReconcilePayload(cdrRecord, externalRecord);
// Format verification
if (!payload.id || !payload.callId || !payload.trunkId) {
throw new Error('Reconcile payload missing required CXone fields');
}
const token = await getAccessToken();
let retries = 0;
const maxRetries = 3;
while (retries < maxRetries) {
try {
const response = await axios.put(
`${CXONE_CONFIG.baseUrl}/telephony/providers/edges/${CXONE_CONFIG.edgeId}/cdr/${payload.id}`,
payload,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
},
timeout: 10000
}
);
if (response.status !== 200 && response.status !== 204) {
throw new Error(`Reconcile update failed: ${response.status}`);
}
// Trigger automatic billing adjustment callback
await syncExternalBilling(payload);
return { success: true, cdrId: payload.id };
} catch (error) {
if (error.response?.status === 429) {
const delay = Math.pow(2, retries) * 1000;
console.warn(`429 Rate limited. Retrying in ${delay}ms...`);
await new Promise(r => setTimeout(r, delay));
retries++;
continue;
}
if (error.response?.status === 422) {
console.error('422 Validation Error:', error.response.data);
throw new Error('CXone schema validation failed');
}
throw error;
}
}
throw new Error('Max retries exceeded for reconcile operation');
}
async function syncExternalBilling(payload) {
try {
await axios.post(BILLING_WEBHOOK_URL, {
event: 'cdr.reconciled',
callId: payload.callId,
trunkId: payload.trunkId,
billableSeconds: payload.billableSeconds,
adjustment: payload.metadata.correctionDirective.billingAdjustmentTrigger
});
} catch (err) {
console.error('Billing webhook failed:', err.message);
}
}
module.exports = { submitReconcilePayload };
Step 4: Synchronize Reconciling Events and Track Reconcile Efficiency Metrics
Production reconciliation services require latency tracking, match success rate calculation, and structured audit logging for telephony governance. This step implements a metrics collector and audit logger that runs alongside the reconciliation loop.
const pino = require('pino');
const logger = pino({ level: 'info', transport: { target: 'pino/file', options: { destination: './reconcile-audit.log' } } });
class ReconcileMetrics {
constructor() {
this.totalProcessed = 0;
this.successfulMatches = 0;
this.failedAlignments = 0;
this.latencySum = 0;
}
recordSuccess(latencyMs) {
this.totalProcessed++;
this.successfulMatches++;
this.latencySum += latencyMs;
logger.info({ callId: 'RECONCILE_METRIC', event: 'success', latencyMs }, 'CDR aligned successfully');
}
recordFailure(latencyMs, reason) {
this.totalProcessed++;
this.failedAlignments++;
this.latencySum += latencyMs;
logger.warn({ callId: 'RECONCILE_METRIC', event: 'failure', reason, latencyMs }, 'CDR alignment failed');
}
getReport() {
const avgLatency = this.totalProcessed > 0 ? (this.latencySum / this.totalProcessed).toFixed(2) : 0;
const successRate = this.totalProcessed > 0 ? ((this.successfulMatches / this.totalProcessed) * 100).toFixed(2) : 0;
return {
totalProcessed: this.totalProcessed,
successRate: `${successRate}%`,
averageLatencyMs: avgLatency,
failedAlignments: this.failedAlignments
};
}
}
module.exports = { ReconcileMetrics, logger };
Complete Working Example
The following script combines authentication, CDR fetching, payload construction, atomic submission, and metrics tracking into a single executable module. Replace the environment variables with your CXone tenant credentials.
require('dotenv').config();
const { fetchCdrs } = require('./fetch');
const { submitReconcilePayload } = require('./submit');
const { ReconcileMetrics, logger } = require('./metrics');
// Mock external billing data source for demonstration
const EXTERNAL_BILLING_DB = [
{ callId: 'call-xyz-789', startTime: '2024-05-10T14:30:01.000Z', endTime: '2024-05-10T14:32:16.000Z', billableSeconds: 137, codec: 'G.711' }
];
async function runReconciliation() {
const metrics = new ReconcileMetrics();
logger.info('Starting CXone CDR reconciliation process');
try {
const cdrs = await fetchCdrs();
logger.info(`Fetched ${cdrs.length} CDR records within reconciliation window`);
for (const cdr of cdrs) {
const startTime = Date.now();
const externalRecord = EXTERNAL_BILLING_DB.find(ext => ext.callId === cdr.callId);
if (!externalRecord) {
metrics.recordFailure(Date.now() - startTime, 'EXTERNAL_RECORD_MISSING');
continue;
}
try {
await submitReconcilePayload(cdr, externalRecord);
metrics.recordSuccess(Date.now() - startTime);
} catch (error) {
metrics.recordFailure(Date.now() - startTime, error.message);
}
}
const report = metrics.getReport();
logger.info({ report }, 'Reconciliation cycle complete');
console.log(JSON.stringify(report, null, 2));
} catch (error) {
logger.error({ error: error.message }, 'Reconciliation process terminated');
process.exit(1);
}
}
runReconciliation();
Common Errors & Debugging
Error: 401 Unauthorized
- Cause: The OAuth token has expired or the client credentials are invalid.
- Fix: Verify
CXONE_CLIENT_IDandCXONE_CLIENT_SECRET. Ensure thegetAccessTokenfunction is called before each API request. Implement token caching as shown in the authentication setup. - Code Fix: The provided
auth.jsmodule automatically refreshes tokens whentokenExpiryis exceeded.
Error: 403 Forbidden
- Cause: The OAuth client lacks the required scopes or the edge ID does not belong to the authenticated tenant.
- Fix: Grant
cdr:readandcdr:writescopes to the OAuth client in the CXone admin console. VerifyCXONE_EDGE_IDmatches a valid voice edge in the tenant. - Code Fix: Update the OAuth client configuration in CXone under Administration > Security > OAuth Clients.
Error: 422 Unprocessable Entity
- Cause: The reconcile payload violates CXone schema constraints. Common causes include invalid ISO 8601 timestamps, missing
trunkId, orbillableSecondsexceeding engine limits. - Fix: Validate all datetime fields against
toISOString(). EnsuretrunkIdmatches an active trunk configuration. Check thatdurationandbillableSecondsare positive integers. - Code Fix: The
buildReconcilePayloadfunction includes format verification. Add explicit schema validation usingzodorjoibefore submission.
Error: 429 Too Many Requests
- Cause: The reconciliation loop exceeds CXone API rate limits (typically 100 requests per second per tenant).
- Fix: Implement exponential backoff and request throttling. The
submitReconcilePayloadfunction includes a retry loop withMath.pow(2, retries) * 1000delay. - Code Fix: Increase the
maxRetriesthreshold or add a global rate limiter usingp-limitto cap concurrent requests.