Retrieving NICE CXone Outbound Call Recordings via API with Node.js
What You Will Build
- A Node.js module that authenticates to the NICE CXone platform, queries outbound call interactions, extracts recording identifiers, and streams audio files to local storage.
- The implementation uses the CXone REST API surface for outbound call control and recording retrieval, bypassing the absence of an official Node.js SDK.
- The codebase is written in modern Node.js using
async/await,axios, and native streams, with built-in retry logic, retention validation, metrics tracking, and webhook synchronization.
Prerequisites
- CXone Organization ID, API Key, and API Secret
- OAuth scopes:
outbound:read,recording:read - Node.js 18.0 or higher
- External dependencies:
npm install axios uuid fs-extra - Access to a writable local directory for recording storage
- An external webhook endpoint capable of receiving JSON POST requests
Authentication Setup
CXone uses a standard OAuth 2.0 client credentials flow. The token expires after one hour, so the implementation includes automatic token caching and refresh logic to prevent 401 errors during batch retrieval operations.
const axios = require('axios');
const CXONE_BASE_URL = process.env.CXONE_ORG_URL || 'https://YOUR_ORG.niceincontact.com';
const CXONE_API_KEY = process.env.CXONE_API_KEY;
const CXONE_API_SECRET = process.env.CXONE_API_SECRET;
class CxonAuthClient {
constructor() {
this.tokenCache = { accessToken: null, expiresAt: 0 };
}
async getAccessToken() {
const now = Date.now();
if (this.tokenCache.accessToken && now < this.tokenCache.expiresAt - 60000) {
return this.tokenCache.accessToken;
}
const response = await axios.post(`${CXONE_BASE_URL}/api/v2/oauth/token`, {
grant_type: 'client_credentials',
api_key: CXONE_API_KEY,
api_secret: CXONE_API_SECRET
}, {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
});
if (response.status !== 200) {
throw new Error(`OAuth token request failed with status ${response.status}`);
}
this.tokenCache.accessToken = response.data.access_token;
this.tokenCache.expiresAt = now + (response.data.expires_in * 1000);
return this.tokenCache.accessToken;
}
}
The getAccessToken method checks the cache first. If the token is valid with a 60-second safety buffer, it returns immediately. Otherwise, it posts to /api/v2/oauth/token with the client_credentials grant type. The response stores the access_token and calculates the expiration timestamp. This pattern prevents unnecessary network calls during rapid pagination loops.
Implementation
Step 1: Construct Retrieve Payload and Query Outbound Calls
The CXone Outbound API returns call interactions. Each interaction may contain a recordingId. We construct a structured retrieve payload that defines pagination parameters, format preferences, and download directives. The payload is then serialized into query parameters for the API request.
const { v4: uuidv4 } = require('uuid');
class OutboundRecordingRetriever {
constructor(authClient, config = {}) {
this.auth = authClient;
this.formatPreferenceMatrix = config.formatPreferenceMatrix || {
default: 'mp3',
fallback: 'wav'
};
this.storageConstraints = config.storageConstraints || {
maxFileSizeBytes: 52428800, // 50MB
allowedExtensions: ['.mp3', '.wav', '.ogg']
};
this.maxDownloadWindowMs = config.maxDownloadWindowMs || 86400000; // 24 hours
this.metrics = { requests: 0, successes: 0, failures: 0, totalLatencyMs: 0 };
this.auditLog = [];
}
async fetchOutboundCalls(page = 1, pageSize = 100) {
const token = await this.auth.getAccessToken();
const payload = {
retrieveId: uuidv4(),
page,
pageSize,
formatPreference: this.formatPreferenceMatrix.default,
downloadDirective: 'token_based',
filters: { type: 'outbound', status: 'completed' }
};
const response = await axios.get(`${CXONE_BASE_URL}/api/v2/outbound/calls`, {
params: {
page,
pageSize,
type: 'outbound',
status: 'completed'
},
headers: { Authorization: `Bearer ${token}` }
});
return { data: response.data.entities, paging: response.data.paging };
}
}
The fetchOutboundCalls method constructs a payload object that mirrors the requested schema. The API itself accepts page, pageSize, type, and status as query parameters. The retrieveId and formatPreference are tracked locally for audit purposes. The response returns an array of call entities and a paging object for iteration.
Step 2: Validate Retrieve Schema, Retention Policy, and Download Window
Before attempting to download, the code validates each recording against storage constraints and retention policies. CXone returns recording metadata that includes expiresAt, retentionPolicy, and format. The validation pipeline rejects recordings that exceed the download window or violate retention rules.
validateRecordingMetadata(recording) {
const now = Date.now();
const expiryTimestamp = new Date(recording.expiresAt).getTime();
const ageMs = now - new Date(recording.dateCreated).getTime();
if (expiryTimestamp < now) {
throw new Error(`Recording ${recording.recordingId} has expired. Current time exceeds expiresAt.`);
}
if (ageMs > this.maxDownloadWindowMs) {
throw new Error(`Recording ${recording.recordingId} exceeds maximum download window of ${this.maxDownloadWindowMs}ms.`);
}
if (!this.storageConstraints.allowedExtensions.some(ext => recording.format.toLowerCase().endsWith(ext.replace('.', '')))) {
throw new Error(`Recording format ${recording.format} is not in the allowed matrix.`);
}
if (recording.sizeBytes > this.storageConstraints.maxFileSizeBytes) {
throw new Error(`Recording ${recording.recordingId} exceeds storage constraint of ${this.storageConstraints.maxFileSizeBytes} bytes.`);
}
return true;
}
The validateRecordingMetadata method checks four conditions. First, it compares expiresAt against the current timestamp to prevent fetching expired links. Second, it enforces the maximum download window to align with retention policy verification pipelines. Third, it validates the audio format against the preference matrix. Fourth, it checks file size against storage service constraints. Any failure throws a descriptive error that the caller can catch and log.
Step 3: Atomic GET Operations with Format Verification and Streaming
Once validation passes, the code executes an atomic GET request to the recording endpoint. The request includes a download=true parameter and a format override. The response is handled as a stream to prevent memory exhaustion during large file transfers. Automatic streaming setup triggers pipe the data directly to the filesystem.
async downloadRecording(recording, outputDir) {
const token = await this.auth.getAccessToken();
const startTime = performance.now();
this.metrics.requests++;
const targetFormat = this.formatPreferenceMatrix.default;
const downloadUrl = `${CXONE_BASE_URL}/api/v2/recording/recordings/${recording.recordingId}`;
const response = await axios.get(downloadUrl, {
params: { download: true, format: targetFormat },
headers: { Authorization: `Bearer ${token}` },
responseType: 'stream',
validateStatus: (status) => status === 200
});
const formatVerification = response.headers['content-type'] || '';
if (!formatVerification.includes(targetFormat) && !formatVerification.includes('audio')) {
throw new Error(`Format mismatch expected ${targetFormat} but received ${formatVerification}`);
}
const fileName = `${recording.recordingId}.${targetFormat}`;
const filePath = require('path').join(outputDir, fileName);
const writer = require('fs').createWriteStream(filePath);
response.data.pipe(writer);
return new Promise((resolve, reject) => {
writer.on('finish', () => {
const latency = performance.now() - startTime;
this.metrics.successes++;
this.metrics.totalLatencyMs += latency;
resolve({ path: filePath, latency });
});
writer.on('error', reject);
});
}
The downloadRecording method initiates the fetch with responseType: 'stream'. This prevents the entire file from loading into Node.js memory. The validateStatus callback ensures only 200 responses proceed. After the request completes, the code verifies the content-type header matches the requested format. The stream pipes directly to fs.createWriteStream. The finish event calculates latency, updates metrics, and resolves the promise.
Step 4: Retry Logic, Pagination, and Webhook Synchronization
Network instability and CXone rate limits require exponential backoff. The implementation wraps the download call in a retry handler. Pagination loops through outbound calls until paging.nextPage is null. Upon completion, the code triggers a webhook callback to synchronize with external archive systems and writes an audit log.
async executeWithRetry(fn, maxRetries = 3) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (err) {
if (err.response && err.response.status === 429 && attempt < maxRetries) {
const delay = Math.pow(2, attempt) * 1000;
console.log(`Rate limited. Retrying in ${delay}ms...`);
await new Promise(r => setTimeout(r, delay));
continue;
}
throw err;
}
}
}
async processOutboundRecordings(outputDir, webhookUrl) {
let page = 1;
let hasMore = true;
while (hasMore) {
const { data: calls, paging } = await this.fetchOutboundCalls(page);
for (const call of calls) {
if (!call.recordingId) continue;
try {
const recordingMeta = await this.executeWithRetry(async () => {
const token = await this.auth.getAccessToken();
const res = await axios.get(`${CXONE_BASE_URL}/api/v2/recording/recordings/${call.recordingId}`, {
headers: { Authorization: `Bearer ${token}` }
});
return res.data;
});
this.validateRecordingMetadata(recordingMeta);
const result = await this.executeWithRetry(() => this.downloadRecording(recordingMeta, outputDir));
this.auditLog.push({
timestamp: new Date().toISOString(),
recordingId: call.recordingId,
status: 'success',
latencyMs: result.latency,
filePath: result.path
});
} catch (err) {
this.metrics.failures++;
this.auditLog.push({
timestamp: new Date().toISOString(),
recordingId: call.recordingId,
status: 'failed',
error: err.message
});
}
}
hasMore = paging.nextPage != null;
page = paging.nextPage || page + 1;
}
await this.syncWebhook(webhookUrl);
return this.metrics;
}
async syncWebhook(url) {
const payload = {
eventType: 'recording_retrieval_completed',
timestamp: new Date().toISOString(),
metrics: this.metrics,
auditSummary: this.auditLog.length
};
try {
await axios.post(url, payload, { headers: { 'Content-Type': 'application/json' } });
} catch (err) {
console.error(`Webhook sync failed: ${err.message}`);
}
}
The executeWithRetry method catches 429 responses and applies exponential backoff. The processOutboundRecordings method loops through pages, validates each recording, downloads it, and logs the result. The syncWebhook method POSTs the final metrics and audit summary to an external endpoint. This completes the retrieval pipeline.
Complete Working Example
The following module combines all components into a single runnable script. Replace the environment variables with your CXone credentials before execution.
require('dotenv').config();
const path = require('path');
const fs = require('fs');
// Classes defined in previous steps are imported here in a real project.
// For this example, they are included inline below.
const axios = require('axios');
const { v4: uuidv4 } = require('uuid');
const CXONE_BASE_URL = process.env.CXONE_ORG_URL || 'https://YOUR_ORG.niceincontact.com';
const CXONE_API_KEY = process.env.CXONE_API_KEY;
const CXONE_API_SECRET = process.env.CXONE_API_SECRET;
class CxonAuthClient {
constructor() {
this.tokenCache = { accessToken: null, expiresAt: 0 };
}
async getAccessToken() {
const now = Date.now();
if (this.tokenCache.accessToken && now < this.tokenCache.expiresAt - 60000) {
return this.tokenCache.accessToken;
}
const response = await axios.post(`${CXONE_BASE_URL}/api/v2/oauth/token`, {
grant_type: 'client_credentials',
api_key: CXONE_API_KEY,
api_secret: CXONE_API_SECRET
}, { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } });
if (response.status !== 200) throw new Error(`OAuth failed: ${response.status}`);
this.tokenCache.accessToken = response.data.access_token;
this.tokenCache.expiresAt = now + (response.data.expires_in * 1000);
return this.tokenCache.accessToken;
}
}
class OutboundRecordingRetriever {
constructor(authClient, config = {}) {
this.auth = authClient;
this.formatPreferenceMatrix = config.formatPreferenceMatrix || { default: 'mp3', fallback: 'wav' };
this.storageConstraints = config.storageConstraints || { maxFileSizeBytes: 52428800, allowedExtensions: ['.mp3', '.wav'] };
this.maxDownloadWindowMs = config.maxDownloadWindowMs || 86400000;
this.metrics = { requests: 0, successes: 0, failures: 0, totalLatencyMs: 0 };
this.auditLog = [];
}
async fetchOutboundCalls(page, pageSize) {
const token = await this.auth.getAccessToken();
const res = await axios.get(`${CXONE_BASE_URL}/api/v2/outbound/calls`, {
params: { page, pageSize, type: 'outbound', status: 'completed' },
headers: { Authorization: `Bearer ${token}` }
});
return { data: res.data.entities, paging: res.data.paging };
}
validateRecordingMetadata(recording) {
const now = Date.now();
const expiryTimestamp = new Date(recording.expiresAt).getTime();
if (expiryTimestamp < now) throw new Error(`Recording ${recording.recordingId} expired.`);
if (now - new Date(recording.dateCreated).getTime() > this.maxDownloadWindowMs) {
throw new Error(`Recording ${recording.recordingId} exceeds download window.`);
}
const fmt = recording.format.toLowerCase();
if (!this.storageConstraints.allowedExtensions.some(e => fmt.endsWith(e.replace('.', '')))) {
throw new Error(`Format ${fmt} not allowed.`);
}
if (recording.sizeBytes > this.storageConstraints.maxFileSizeBytes) {
throw new Error(`Recording ${recording.recordingId} exceeds size limit.`);
}
return true;
}
async downloadRecording(recording, outputDir) {
const token = await this.auth.getAccessToken();
const start = performance.now();
this.metrics.requests++;
const fmt = this.formatPreferenceMatrix.default;
const res = await axios.get(`${CXONE_BASE_URL}/api/v2/recording/recordings/${recording.recordingId}`, {
params: { download: true, format: fmt },
headers: { Authorization: `Bearer ${token}` },
responseType: 'stream',
validateStatus: s => s === 200
});
const ct = res.headers['content-type'] || '';
if (!ct.includes(fmt) && !ct.includes('audio')) throw new Error(`Format mismatch: ${ct}`);
const filePath = path.join(outputDir, `${recording.recordingId}.${fmt}`);
const writer = fs.createWriteStream(filePath);
res.data.pipe(writer);
return new Promise((resolve, reject) => {
writer.on('finish', () => {
const latency = performance.now() - start;
this.metrics.successes++;
this.metrics.totalLatencyMs += latency;
resolve({ path: filePath, latency });
});
writer.on('error', reject);
});
}
async executeWithRetry(fn, maxRetries = 3) {
for (let i = 1; i <= maxRetries; i++) {
try { return await fn(); }
catch (err) {
if (err.response && err.response.status === 429 && i < maxRetries) {
await new Promise(r => setTimeout(r, Math.pow(2, i) * 1000));
continue;
}
throw err;
}
}
}
async processOutboundRecordings(outputDir, webhookUrl) {
let page = 1;
let hasMore = true;
fs.mkdirSync(outputDir, { recursive: true });
while (hasMore) {
const { data: calls, paging } = await this.fetchOutboundCalls(page);
for (const call of calls) {
if (!call.recordingId) continue;
try {
const token = await this.auth.getAccessToken();
const metaRes = await this.executeWithRetry(() =>
axios.get(`${CXONE_BASE_URL}/api/v2/recording/recordings/${call.recordingId}`, { headers: { Authorization: `Bearer ${token}` } })
);
this.validateRecordingMetadata(metaRes.data);
const result = await this.executeWithRetry(() => this.downloadRecording(metaRes.data, outputDir));
this.auditLog.push({ timestamp: new Date().toISOString(), recordingId: call.recordingId, status: 'success', latencyMs: result.latency });
} catch (err) {
this.metrics.failures++;
this.auditLog.push({ timestamp: new Date().toISOString(), recordingId: call.recordingId, status: 'failed', error: err.message });
}
}
hasMore = paging.nextPage != null;
page = paging.nextPage || page + 1;
}
if (webhookUrl) {
try {
await axios.post(webhookUrl, {
eventType: 'retrieval_complete',
timestamp: new Date().toISOString(),
metrics: this.metrics,
auditCount: this.auditLog.length
});
} catch (wErr) {
console.error('Webhook failed:', wErr.message);
}
}
fs.writeFileSync(path.join(outputDir, 'audit.log'), JSON.stringify(this.auditLog, null, 2));
return this.metrics;
}
}
// Execution entry point
(async () => {
const auth = new CxonAuthClient();
const retriever = new OutboundRecordingRetriever(auth);
const outputDir = './cxone_recordings';
const webhookUrl = process.env.WEBHOOK_URL || null;
try {
const results = await retriever.processOutboundRecordings(outputDir, webhookUrl);
console.log('Retrieval complete:', JSON.stringify(results, null, 2));
} catch (err) {
console.error('Fatal error:', err.message);
process.exit(1);
}
})();
Common Errors & Debugging
Error: 401 Unauthorized
- What causes it: The OAuth token is expired, malformed, or missing the
Authorization: Bearerheader. - How to fix it: Ensure the
CxonAuthClientcaches the token correctly and refreshes it before expiration. Verify thatCXONE_API_KEYandCXONE_API_SECRETmatch the credentials in the CXone admin console. - Code showing the fix: The
getAccessTokenmethod already implements a 60-second safety buffer. If the error persists, log the token request response to verify theexpires_infield.
Error: 403 Forbidden
- What causes it: The OAuth client lacks the
recording:readoroutbound:readscope. - How to fix it: Navigate to the CXone admin console, locate the API credentials, and add the required scopes. Restart the application to force a fresh token request.
- Code showing the fix: No code change is required. The scopes are enforced server-side. Verify the credential configuration in CXone.
Error: 429 Too Many Requests
- What causes it: The CXone rate limit is exceeded. Outbound call pagination and recording downloads trigger separate rate limit buckets.
- How to fix it: The
executeWithRetrymethod implements exponential backoff. Increase the delay multiplier if cascading 429s occur. ReducepageSizeto lower request frequency. - Code showing the fix: Adjust the backoff calculation:
const delay = Math.pow(2, attempt) * 2000;for heavier loads.
Error: Recording Expired or Download Window Exceeded
- What causes it: The
expiresAttimestamp has passed, or the recording age exceeds the configuredmaxDownloadWindowMs. - How to fix it: Filter outbound calls by
dateCreatedto only fetch recent interactions. Adjust the retention policy in CXone if business requirements allow longer windows. - Code showing the fix: Modify the validation pipeline:
if (ageMs > this.maxDownloadWindowMs) { console.warn('Skipped expired recording'); return; }to skip instead of throw.