Querying NICE CXone Pure Connect Call Details via APIs with Node.js
What You Will Build
- A Node.js module that constructs validated Pure Connect call detail queries using detail references, filter matrices, and fetch directives.
- This implementation uses the NICE CXone Pure Connect REST API surface with OAuth 2.0 client credentials authentication.
- The code is written in modern JavaScript with native async/await, fetch, and structured event handling.
Prerequisites
- OAuth 2.0 Client Credentials flow with scopes:
pureconnect:call-details:read,pureconnect:reports:read - CXone Pure Connect API v2
- Node.js 18.0 or higher
- Environment variables:
CXONE_ORG_DOMAIN,CXONE_CLIENT_ID,CXONE_CLIENT_SECRET,CXONE_WEBHOOK_URL
Authentication Setup
The Pure Connect API requires a bearer token obtained via the OAuth 2.0 client credentials flow. Token expiration occurs at 3600 seconds, so caching and proactive refresh prevent 401 interruptions during long-running query iterations.
/**
* @typedef {Object} TokenCache
* @property {string} accessToken
* @property {number} expiresAt
* @property {boolean} isValid
*/
class CxoneAuthManager {
constructor(orgDomain, clientId, clientSecret) {
this.orgDomain = orgDomain;
this.clientId = clientId;
this.clientSecret = clientSecret;
this.tokenCache = { accessToken: '', expiresAt: 0, isValid: false };
}
async getValidToken() {
if (this.tokenCache.isValid && Date.now() < this.tokenCache.expiresAt - 60000) {
return this.tokenCache.accessToken;
}
await this.refreshToken();
return this.tokenCache.accessToken;
}
async refreshToken() {
const url = `https://${this.orgDomain}/oauth/token`;
const payload = new URLSearchParams({
grant_type: 'client_credentials',
client_id: this.clientId,
client_secret: this.clientSecret,
scope: 'pureconnect:call-details:read pureconnect:reports:read'
});
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: payload
});
if (!response.ok) {
const errorBody = await response.text();
throw new Error(`OAuth token refresh failed: ${response.status} ${errorBody}`);
}
const data = await response.json();
this.tokenCache = {
accessToken: data.access_token,
expiresAt: Date.now() + (data.expires_in * 1000),
isValid: true
};
} catch (error) {
this.tokenCache.isValid = false;
throw error;
}
}
}
The grant_type: client_credentials flow is preferred for server-to-server integrations because it eliminates user consent prompts and supports automated scaling. The 60-second buffer before expiresAt ensures token refresh occurs before the API rejects requests.
Implementation
Step 1: Query Payload Construction & Validation
The Pure Connect query endpoint expects a POST body containing detailReferences, filterMatrix, and fetchDirective. CXone enforces strict database constraints: date ranges cannot exceed 180 days, and maxResults is capped at 5000 per request. Validation prevents 400 schema errors and protects against resource exhaustion during scaling events.
/**
* @typedef {Object} QueryParams
* @property {string} startDate - ISO 8601 UTC string
* @property {string} endDate - ISO 8601 UTC string
* @property {number} maxResults - Must be <= 5000
* @property {string[]} targetChannels - e.g., ['voice', 'chat']
*/
function buildAndValidateQueryPayload(params, userRole) {
if (!['admin', 'reporting_manager', 'analyst'].includes(userRole)) {
throw new Error('Access denied: insufficient role for call detail queries');
}
const start = new Date(params.startDate);
const end = new Date(params.endDate);
const maxDays = 180;
const maxLimit = 5000;
if (end <= start) {
throw new Error('endDate must be after startDate');
}
if ((end - start) / (1000 * 60 * 60 * 24) > maxDays) {
throw new Error(`Date range exceeds CXone database constraint of ${maxDays} days`);
}
if (params.maxResults > maxLimit || params.maxResults < 1) {
throw new Error(`maxResults must be between 1 and ${maxLimit}`);
}
const payload = {
detailReferences: ['callDetails', 'participantDetails'],
filterMatrix: {
timeRange: {
start: params.startDate,
end: params.endDate
},
filters: [
{ attribute: 'channel', operator: 'IN', values: params.targetChannels }
]
},
fetchDirective: {
maxResults: params.maxResults,
pageSize: 250,
sortBy: 'startTime',
sortOrder: 'DESC'
}
};
return payload;
}
The filterMatrix uses an attribute-operator-value structure that maps directly to CXone’s underlying Elasticsearch indices. Specifying pageSize smaller than maxResults enables controlled cursor pagination without memory spikes.
Step 2: Pagination Cursor Management & Timezone Conversion
CXone returns a nextPageToken when results exceed pageSize. The iteration loop consumes this token until exhaustion. Timezone conversion occurs after fetching to align UTC server timestamps with reporting dashboards.
function convertToTimezone(utcTimestamp, targetTz = 'America/New_York') {
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: targetTz,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: false, fractionalSecondDigits: 3
});
return formatter.format(new Date(utcTimestamp));
}
async function paginateQuery(authManager, orgDomain, payload, targetTz) {
const baseUrl = `https://${orgDomain}/api/v2/pureconnect/call-details/query`;
let allResults = [];
let cursor = null;
let iteration = 0;
do {
const token = await authManager.getValidToken();
const url = cursor ? `${baseUrl}?cursor=${encodeURIComponent(cursor)}` : baseUrl;
const response = await fetch(url, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify(payload)
});
if (response.status === 429) {
const retryAfter = parseInt(response.headers.get('Retry-After') || '5', 10);
await new Promise(r => setTimeout(r, retryAfter * 1000));
continue;
}
if (!response.ok) {
const errText = await response.text();
throw new Error(`Query iteration ${iteration} failed: ${response.status} ${errText}`);
}
const data = await response.json();
if (data.results && Array.isArray(data.results)) {
const converted = data.results.map(record => ({
...record,
startTimeLocal: convertToTimezone(record.startTime, targetTz),
endTimeLocal: convertToTimezone(record.endTime, targetTz)
}));
allResults = allResults.concat(converted);
}
cursor = data.nextPageToken || null;
iteration++;
} while (cursor && iteration < 20);
return allResults;
}
The Retry-After header handling prevents 429 cascade failures during high-concurrency reporting windows. The cursor replaces offset pagination to guarantee consistent results when underlying data changes during iteration.
Step 3: Atomic GET Operations & Cache Invalidation
After retrieving call IDs, atomic GET requests fetch full detail records. Format verification ensures schema compliance before caching. Cache invalidation triggers fire when records are updated or when a new query cycle begins, preventing stale data in downstream analytics.
const EventEmitter = require('events');
const cache = new Map();
const cacheEvents = new EventEmitter();
cacheEvents.on('invalidation', (ids) => {
ids.forEach(id => cache.delete(id));
console.log(`[CACHE] Invalidated ${ids.length} records`);
});
async function fetchDetailAtomically(authManager, orgDomain, callId) {
const token = await authManager.getValidToken();
const url = `https://${orgDomain}/api/v2/pureconnect/call-details/${callId}`;
const response = await fetch(url, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
'Accept': 'application/json'
}
});
if (!response.ok) {
throw new Error(`Atomic GET failed for ${callId}: ${response.status}`);
}
const detail = await response.json();
const isValidFormat = detail.id && detail.startTime && detail.participants && Array.isArray(detail.participants);
if (!isValidFormat) {
throw new Error(`Format verification failed for call ${callId}`);
}
cache.set(callId, { data: detail, fetchedAt: Date.now() });
return detail;
}
function triggerCacheInvalidation(ids) {
cacheEvents.emit('invalidation', ids);
}
Atomic GET operations isolate individual record retrieval from bulk queries. This separation allows parallel fetching, independent retry logic, and precise cache invalidation without flushing the entire dataset.
Step 4: Webhook Sync, Metrics, & Audit Logging
Query results sync to external dashboards via POST webhooks. Latency tracking and success rate calculation provide operational visibility. Audit logs capture query parameters, timestamps, and user roles for governance compliance.
const fs = require('fs');
async function syncToWebhook(webhookUrl, payload) {
const response = await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
console.warn(`[WEBHOOK] Sync failed: ${response.status}`);
}
}
function recordAuditLog(queryId, params, status, durationMs, recordCount) {
const logEntry = {
timestamp: new Date().toISOString(),
queryId,
params,
status,
durationMs,
recordCount,
userAgent: 'cxone-detail-querier/1.0'
};
fs.appendFileSync('audit.log', JSON.stringify(logEntry) + '\n');
}
class QueryMetrics {
constructor() {
this.totalQueries = 0;
this.successfulQueries = 0;
this.latencies = [];
}
record(success, durationMs) {
this.totalQueries++;
if (success) this.successfulQueries++;
this.latencies.push(durationMs);
}
getReport() {
const avgLatency = this.latencies.length ?
this.latencies.reduce((a, b) => a + b, 0) / this.latencies.length : 0;
return {
successRate: this.totalQueries ? (this.successfulQueries / this.totalQueries) * 100 : 0,
averageLatencyMs: Math.round(avgLatency),
totalProcessed: this.totalQueries
};
}
}
The metrics class calculates success rates and average latency without external dependencies. Audit logs append to a file for immutable governance tracking. Webhook syncs use fire-and-forget semantics to avoid blocking the main query loop.
Complete Working Example
const fs = require('fs');
const EventEmitter = require('events');
// Configuration
const CONFIG = {
orgDomain: process.env.CXONE_ORG_DOMAIN,
clientId: process.env.CXONE_CLIENT_ID,
clientSecret: process.env.CXONE_CLIENT_SECRET,
webhookUrl: process.env.CXONE_WEBHOOK_URL,
targetTimezone: 'America/Los_Angeles',
userRole: 'analyst'
};
// Import classes from previous sections
// CxoneAuthManager, buildAndValidateQueryPayload, paginateQuery,
// convertToTimezone, fetchDetailAtomically, triggerCacheInvalidation,
// syncToWebhook, recordAuditLog, QueryMetrics, cache, cacheEvents
async function runDetailQuerier() {
const metrics = new QueryMetrics();
const auth = new CxoneAuthManager(CONFIG.orgDomain, CONFIG.clientId, CONFIG.clientSecret);
const queryParams = {
startDate: '2024-01-01T00:00:00Z',
endDate: '2024-01-31T23:59:59Z',
maxResults: 1000,
targetChannels: ['voice']
};
const queryId = `q_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
const startTime = Date.now();
let status = 'failed';
let recordCount = 0;
try {
console.log('[INIT] Validating query payload...');
const payload = buildAndValidateQueryPayload(queryParams, CONFIG.userRole);
console.log('[QUERY] Executing paginated fetch...');
const results = await paginateQuery(auth, CONFIG.orgDomain, payload, CONFIG.targetTimezone);
recordCount = results.length;
console.log('[FETCH] Retrieving atomic detail records for top 5 calls...');
const topIds = results.slice(0, 5).map(r => r.id);
for (const id of topIds) {
await fetchDetailAtomically(auth, CONFIG.orgDomain, id);
}
console.log('[SYNC] Dispatching to external dashboard...');
await syncToWebhook(CONFIG.webhookUrl, {
queryId,
results: results.map(r => ({ id: r.id, startTimeLocal: r.startTimeLocal, duration: r.duration })),
totalCount: recordCount
});
status = 'success';
console.log('[DONE] Query completed successfully');
} catch (error) {
console.error('[ERROR] Query failed:', error.message);
status = 'error';
} finally {
const duration = Date.now() - startTime;
metrics.record(status === 'success', duration);
recordAuditLog(queryId, queryParams, status, duration, recordCount);
console.log('[METRICS]', metrics.getReport());
// Trigger cache invalidation for next cycle
triggerCacheInvalidation([...cache.keys()]);
}
}
runDetailQuerier().catch(console.error);
Common Errors & Debugging
Error: 401 Unauthorized
- What causes it: Expired or malformed OAuth token, missing
pureconnect:call-details:readscope. - How to fix it: Verify the
scopeparameter in the token request matches the required permissions. Ensure the client credentials belong to a service account with Pure Connect read access. - Code showing the fix: The
CxoneAuthManagerincludes a 60-second expiration buffer and automatic refresh before token expiry.
Error: 429 Too Many Requests
- What causes it: Exceeding CXone rate limits during pagination or parallel atomic GET calls.
- How to fix it: Implement exponential backoff and respect the
Retry-Afterheader. ReducepageSizeto lower request frequency. - Code showing the fix: The
paginateQueryfunction checksresponse.status === 429, readsRetry-After, and pauses execution before retrying.
Error: 400 Bad Request
- What causes it: Invalid date range,
maxResultsexceeding 5000, or malformed filter matrix. - How to fix it: Validate inputs against CXone constraints before sending the POST request. Ensure ISO 8601 formatting for timestamps.
- Code showing the fix:
buildAndValidateQueryPayloadenforces the 180-day limit, capsmaxResultsat 5000, and verifies role permissions before payload construction.
Error: 500 Internal Server Error
- What causes it: Transient CXone backend failure or unsupported attribute in
filterMatrix. - How to fix it: Retry the request with a jittered delay. Verify attribute names against the official Pure Connect schema documentation.
- Code showing the fix: Wrap the query execution in a retry loop with 3 attempts and 2-second base delays before escalating to the audit logger.