Reconciling NICE CXone Voice CDR Records with Node.js

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_ID and CXONE_CLIENT_SECRET. Ensure the getAccessToken function is called before each API request. Implement token caching as shown in the authentication setup.
  • Code Fix: The provided auth.js module automatically refreshes tokens when tokenExpiry is 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:read and cdr:write scopes to the OAuth client in the CXone admin console. Verify CXONE_EDGE_ID matches 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, or billableSeconds exceeding engine limits.
  • Fix: Validate all datetime fields against toISOString(). Ensure trunkId matches an active trunk configuration. Check that duration and billableSeconds are positive integers.
  • Code Fix: The buildReconcilePayload function includes format verification. Add explicit schema validation using zod or joi before 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 submitReconcilePayload function includes a retry loop with Math.pow(2, retries) * 1000 delay.
  • Code Fix: Increase the maxRetries threshold or add a global rate limiter using p-limit to cap concurrent requests.

Official References