Playing NICE CXone Outbound DTMF Tones via Call Control API with Node.js

Playing NICE CXone Outbound DTMF Tones via Call Control API with Node.js

What You Will Build

  • You will build a production-ready Node.js module that sends DTMF tone sequences to active NICE CXone outbound calls using the Call Control API.
  • You will use the CXone REST API v2 endpoint /api/v2/outbound/calls/{callId}/dtmf with axios for HTTP transport.
  • The tutorial covers Node.js 18+ with async/await, axios, and dotenv for environment management.

Prerequisites

  • OAuth 2.0 Confidential Client configured in the CXone Admin Console with the calls:control scope.
  • CXone REST API v2.
  • Node.js 18 or newer.
  • External dependencies: axios, dotenv, uuid.
  • A running CXone outbound campaign with active calls to test against.

Authentication Setup

CXone uses standard OAuth 2.0 Client Credentials flow. The token endpoint varies by region. You must cache the access token and implement a refresh buffer to prevent 401 errors during high-volume outbound scaling.

const axios = require('axios');

class CxoneAuthManager {
  constructor(config) {
    this.region = config.region || 'global';
    this.clientId = config.clientId;
    this.clientSecret = config.clientSecret;
    this.token = null;
    this.expiresAt = 0;
    this.refreshBufferMs = 60000;
  }

  _getBaseUrl() {
    const regions = {
      global: 'https://platform.nicecxone.com',
      eu: 'https://platform.eu.nicecxone.com',
      us: 'https://platform.us.nicecxone.com'
    };
    return regions[this.region] || regions.global;
  }

  async getAccessToken() {
    if (this.token && Date.now() < this.expiresAt) {
      return this.token;
    }

    const url = `${this._getBaseUrl()}/oauth2/token`;
    const payload = new URLSearchParams({
      grant_type: 'client_credentials',
      client_id: this.clientId,
      client_secret: this.clientSecret
    });

    try {
      const response = await axios.post(url, payload, {
        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
        timeout: 5000
      });

      this.token = response.data.access_token;
      this.expiresAt = Date.now() + (response.data.expires_in * 1000) - this.refreshBufferMs;
      return this.token;
    } catch (error) {
      if (error.response) {
        throw new Error(`OAuth authentication failed: ${error.response.status} ${error.response.data.error_description}`);
      }
      throw error;
    }
  }
}

The refreshBufferMs subtracts sixty seconds from the actual expiration to guarantee token validity during concurrent request batching. The Client Credentials flow requires only client_id and client_secret. The calls:control scope authorizes DTMF playback and call status queries.

Implementation

Step 1: Initialize the DTMF Player Client and Schema Validation

Telephony gateways enforce strict character sets and sequence lengths. CXone accepts standard DTMF digits 0-9, *, #, and A-D. Gateway constraints typically cap sequences at fifteen digits to prevent buffer overflow in downstream SIP trunks. The validation function rejects invalid payloads before network transmission.

class DtmfPlayer {
  constructor(authManager, config = {}) {
    this.auth = authManager;
    this.baseUrl = authManager._getBaseUrl();
    this.maxSequenceLength = config.maxSequenceLength || 15;
    this.defaultInterDigitPause = config.defaultInterDigitPause || 200;
    this.auditLog = [];
    this.successCount = 0;
    this.failureCount = 0;
    this.totalLatencyMs = 0;
    this.onWebhookSync = config.onWebhookSync || (() => Promise.resolve());
  }

  _validateDtmfSequence(sequence) {
    const validPattern = /^[0-9*#A-D]+$/;
    if (!validPattern.test(sequence)) {
      throw new Error(`Invalid DTMF characters detected: ${sequence}. Allowed: 0-9, *, #, A-D.`);
    }
    if (sequence.length > this.maxSequenceLength) {
      throw new Error(`Sequence exceeds telephony gateway constraint of ${this.maxSequenceLength} digits.`);
    }
    return true;
  }
}

The _validateDtmfSequence method enforces schema compliance. It prevents playing failure caused by malformed payloads or gateway rejection. The constructor initializes tracking counters for latency and success rates.

Step 2: Execute Atomic POST Operations with Retry Logic

DTMF playback requires an atomic POST operation. The API returns 200 OK when the tone sequence is queued for delivery. You must implement exponential backoff for 429 Too Many Requests responses to avoid cascading rate limits during outbound scaling.

  async _playDtmfAtomic(callUuid, sequence, interDigitPause) {
    this._validateDtmfSequence(sequence);

    const payload = {
      dtmf: sequence,
      interDigitPause: interDigitPause,
      sipInfoVerification: true
    };

    const startTime = Date.now();
    let response;
    let retries = 0;
    const maxRetries = 3;

    while (retries <= maxRetries) {
      try {
        const token = await this.auth.getAccessToken();
        response = await axios.post(
          `${this.baseUrl}/api/v2/outbound/calls/${callUuid}/dtmf`,
          payload,
          {
            headers: {
              'Authorization': `Bearer ${token}`,
              'Content-Type': 'application/json',
              'Accept': 'application/json'
            },
            timeout: 8000
          }
        );
        break;
      } catch (error) {
        if (error.response && error.response.status === 429 && retries < maxRetries) {
          const retryAfterSeconds = error.response.headers['retry-after'] || Math.pow(2, retries);
          await new Promise(resolve => setTimeout(resolve, retryAfterSeconds * 1000));
          retries++;
          continue;
        }
        throw error;
      }
    }

    return {
      statusCode: response.status,
      data: response.data,
      latencyMs: Date.now() - startTime
    };
  }

The sipInfoVerification: true flag instructs the CXone telephony gateway to verify tone delivery via SIP INFO messages rather than in-band RDTMF. This reduces call drops during IVR navigation. The retry loop respects the Retry-After header and applies exponential backoff when the header is absent.

Step 3: Track Latency, Audit Logs, and Webhook Synchronization

Production systems require deterministic audit trails. You must record every playback attempt, calculate delivery success rates, and synchronize events with external telephony logs via webhook callbacks.

  async playDtmf(callUuid, sequence, interDigitPause = this.defaultInterDigitPause) {
    let result;
    try {
      result = await this._playDtmfAtomic(callUuid, sequence, interDigitPause);
      this.successCount++;
    } catch (error) {
      this.failureCount++;
      result = {
        statusCode: error.response?.status || 500,
        data: null,
        latencyMs: Date.now() - (error.config?.startTime || Date.now()),
        error: error.message
      };
    }

    const auditEntry = {
      timestamp: new Date().toISOString(),
      callUuid,
      sequence,
      interDigitPause,
      status: result.statusCode === 200 ? 'SUCCESS' : 'FAILURE',
      latencyMs: result.latencyMs,
      httpStatus: result.statusCode,
      sipInfoVerified: result.data?.sipInfoVerified || false,
      errorMessage: result.error || null
    };

    this.totalLatencyMs += result.latencyMs;
    this.auditLog.push(auditEntry);

    await this.onWebhookSync(auditEntry);

    return auditEntry;
  }

  getMetrics() {
    const totalAttempts = this.successCount + this.failureCount;
    return {
      totalAttempts,
      successCount: this.successCount,
      failureCount: this.failureCount,
      successRate: totalAttempts > 0 ? (this.successCount / totalAttempts).toFixed(4) : 0,
      averageLatencyMs: totalAttempts > 0 ? (this.totalLatencyMs / totalAttempts).toFixed(2) : 0,
      auditLog: this.auditLog
    };
  }

The playDtmf method wraps the atomic call with error capture, latency calculation, and audit generation. The getMetrics method exposes real-time success rates and average latency for monitoring dashboards. The webhook synchronization callback executes asynchronously to prevent blocking the main playback thread.

Step 4: Safe Play Iteration and Call Status Verification

Playing multiple DTMF sequences requires verifying call state between iterations. Sending tones to a disconnected or transferred call triggers 404 Not Found responses and wastes gateway resources. You must query the call status before each sequence in a matrix.

  async _verifyCallStatus(callUuid) {
    const token = await this.auth.getAccessToken();
    const response = await axios.get(
      `${this.baseUrl}/api/v2/outbound/calls/${callUuid}`,
      {
        headers: { 'Authorization': `Bearer ${token}` },
        timeout: 5000
      }
    );
    return response.data;
  }

  async playSequenceMatrix(callUuid, matrix) {
    const results = [];
    for (const step of matrix) {
      const callData = await this._verifyCallStatus(callUuid);
      const activeStates = ['connected', 'in_progress', 'alerting'];
      
      if (!activeStates.includes(callData.state)) {
        throw new Error(`Call ${callUuid} is no longer active. Current state: ${callData.state}`);
      }

      const result = await this.playDtmf(callUuid, step.sequence, step.pause);
      results.push(result);

      if (step.postPlayWaitMs) {
        await new Promise(resolve => setTimeout(resolve, step.postPlayWaitMs));
      }
    }
    return results;
  }

The playSequenceMatrix method accepts an array of step objects containing sequence, pause, and postPlayWaitMs. It verifies the call remains in an active state before each playback. The postPlayWaitMs directive allows the IVR system to process the tone before the next sequence transmits. Pagination is not applicable for atomic DTMF playback endpoints.

Complete Working Example

The following script combines authentication, validation, playback, tracking, and webhook synchronization into a single runnable module. Replace placeholder values with your CXone credentials.

require('dotenv').config();
const axios = require('axios');

// --- Authentication Manager (from Step 1) ---
class CxoneAuthManager {
  constructor(config) {
    this.region = config.region || 'global';
    this.clientId = config.clientId;
    this.clientSecret = config.clientSecret;
    this.token = null;
    this.expiresAt = 0;
    this.refreshBufferMs = 60000;
  }

  _getBaseUrl() {
    const regions = {
      global: 'https://platform.nicecxone.com',
      eu: 'https://platform.eu.nicecxone.com',
      us: 'https://platform.us.nicecxone.com'
    };
    return regions[this.region] || regions.global;
  }

  async getAccessToken() {
    if (this.token && Date.now() < this.expiresAt) return this.token;
    const url = `${this._getBaseUrl()}/oauth2/token`;
    const payload = new URLSearchParams({
      grant_type: 'client_credentials',
      client_id: this.clientId,
      client_secret: this.clientSecret
    });
    try {
      const response = await axios.post(url, payload, {
        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
        timeout: 5000
      });
      this.token = response.data.access_token;
      this.expiresAt = Date.now() + (response.data.expires_in * 1000) - this.refreshBufferMs;
      return this.token;
    } catch (error) {
      throw new Error(`OAuth authentication failed: ${error.response?.status} ${error.response?.data?.error_description}`);
    }
  }
}

// --- DTMF Player (from Steps 2-4) ---
class DtmfPlayer {
  constructor(authManager, config = {}) {
    this.auth = authManager;
    this.baseUrl = authManager._getBaseUrl();
    this.maxSequenceLength = config.maxSequenceLength || 15;
    this.defaultInterDigitPause = config.defaultInterDigitPause || 200;
    this.auditLog = [];
    this.successCount = 0;
    this.failureCount = 0;
    this.totalLatencyMs = 0;
    this.onWebhookSync = config.onWebhookSync || (() => Promise.resolve());
  }

  _validateDtmfSequence(sequence) {
    const validPattern = /^[0-9*#A-D]+$/;
    if (!validPattern.test(sequence)) {
      throw new Error(`Invalid DTMF characters detected: ${sequence}. Allowed: 0-9, *, #, A-D.`);
    }
    if (sequence.length > this.maxSequenceLength) {
      throw new Error(`Sequence exceeds telephony gateway constraint of ${this.maxSequenceLength} digits.`);
    }
    return true;
  }

  async _playDtmfAtomic(callUuid, sequence, interDigitPause) {
    this._validateDtmfSequence(sequence);
    const payload = { dtmf: sequence, interDigitPause, sipInfoVerification: true };
    const startTime = Date.now();
    let response;
    let retries = 0;
    const maxRetries = 3;

    while (retries <= maxRetries) {
      try {
        const token = await this.auth.getAccessToken();
        response = await axios.post(
          `${this.baseUrl}/api/v2/outbound/calls/${callUuid}/dtmf`,
          payload,
          {
            headers: {
              'Authorization': `Bearer ${token}`,
              'Content-Type': 'application/json',
              'Accept': 'application/json'
            },
            timeout: 8000
          }
        );
        break;
      } catch (error) {
        if (error.response && error.response.status === 429 && retries < maxRetries) {
          const retryAfterSeconds = error.response.headers['retry-after'] || Math.pow(2, retries);
          await new Promise(resolve => setTimeout(resolve, retryAfterSeconds * 1000));
          retries++;
          continue;
        }
        throw error;
      }
    }
    return { statusCode: response.status, data: response.data, latencyMs: Date.now() - startTime };
  }

  async playDtmf(callUuid, sequence, interDigitPause = this.defaultInterDigitPause) {
    let result;
    try {
      result = await this._playDtmfAtomic(callUuid, sequence, interDigitPause);
      this.successCount++;
    } catch (error) {
      this.failureCount++;
      result = { statusCode: error.response?.status || 500, data: null, latencyMs: 0, error: error.message };
    }
    const auditEntry = {
      timestamp: new Date().toISOString(), callUuid, sequence, interDigitPause,
      status: result.statusCode === 200 ? 'SUCCESS' : 'FAILURE',
      latencyMs: result.latencyMs, httpStatus: result.statusCode,
      sipInfoVerified: result.data?.sipInfoVerified || false, errorMessage: result.error || null
    };
    this.totalLatencyMs += result.latencyMs;
    this.auditLog.push(auditEntry);
    await this.onWebhookSync(auditEntry);
    return auditEntry;
  }

  async _verifyCallStatus(callUuid) {
    const token = await this.auth.getAccessToken();
    const response = await axios.get(`${this.baseUrl}/api/v2/outbound/calls/${callUuid}`, {
      headers: { 'Authorization': `Bearer ${token}` }, timeout: 5000
    });
    return response.data;
  }

  async playSequenceMatrix(callUuid, matrix) {
    const results = [];
    for (const step of matrix) {
      const callData = await this._verifyCallStatus(callUuid);
      if (!['connected', 'in_progress', 'alerting'].includes(callData.state)) {
        throw new Error(`Call ${callUuid} is no longer active. Current state: ${callData.state}`);
      }
      const result = await this.playDtmf(callUuid, step.sequence, step.pause);
      results.push(result);
      if (step.postPlayWaitMs) await new Promise(resolve => setTimeout(resolve, step.postPlayWaitMs));
    }
    return results;
  }

  getMetrics() {
    const total = this.successCount + this.failureCount;
    return {
      totalAttempts: total, successCount: this.successCount, failureCount: this.failureCount,
      successRate: total > 0 ? (this.successCount / total).toFixed(4) : 0,
      averageLatencyMs: total > 0 ? (this.totalLatencyMs / total).toFixed(2) : 0,
      auditLog: this.auditLog
    };
  }
}

// --- Execution ---
async function main() {
  const auth = new CxoneAuthManager({
    region: process.env.CXONE_REGION || 'global',
    clientId: process.env.CXONE_CLIENT_ID,
    clientSecret: process.env.CXONE_CLIENT_SECRET
  });

  const player = new DtmfPlayer(auth, {
    maxSequenceLength: 15,
    defaultInterDigitPause: 250,
    onWebhookSync: async (auditEntry) => {
      console.log(`[WEBHOOK SYNC] ${auditEntry.callUuid} -> ${auditEntry.status} (${auditEntry.latencyMs}ms)`);
      // Replace with actual axios.post to your telephony logging endpoint
    }
  });

  const targetCallUuid = process.env.TARGET_CALL_UUID || '12345678-1234-1234-1234-123456789abc';
  
  const ivrMatrix = [
    { sequence: '1', pause: 200, postPlayWaitMs: 1500 },
    { sequence: '3', pause: 200, postPlayWaitMs: 2000 },
    { sequence: '#', pause: 300, postPlayWaitMs: 1000 }
  ];

  try {
    console.log(`Initiating DTMF matrix playback for call ${targetCallUuid}...`);
    const results = await player.playSequenceMatrix(targetCallUuid, ivrMatrix);
    console.log('Playback complete. Results:', results);
    console.log('Metrics:', player.getMetrics());
  } catch (error) {
    console.error('Playback failed:', error.message);
  }
}

main().catch(console.error);

Run the script with node dtmf-player.js. The environment variables CXONE_REGION, CXONE_CLIENT_ID, CXONE_CLIENT_SECRET, and TARGET_CALL_UUID must be set in a .env file. The script outputs audit logs, latency metrics, and webhook synchronization events.

Common Errors and Debugging

Error: 400 Bad Request

  • Cause: The DTMF sequence contains invalid characters or exceeds the fifteen-digit gateway limit. The payload schema validation failed.
  • Fix: Verify the sequence matches the regex /^[0-9*#A-D]+$/. Reduce sequence length if crossing trunk buffers. Enable sipInfoVerification: true to bypass in-band detection failures.
  • Code Fix: The _validateDtmfSequence method throws a descriptive error before the HTTP request. Catch it and log the malformed input.

Error: 404 Not Found

  • Cause: The call UUID references a terminated, transferred, or non-existent outbound call. CXone purges call records after hangup.
  • Fix: Query /api/v2/outbound/calls/{callId} before playback. Abort the sequence matrix if the state is completed, failed, or cancelled.
  • Code Fix: The _verifyCallStatus method checks callData.state against active states. It throws immediately if the call is inactive.

Error: 429 Too Many Requests

  • Cause: Outbound scaling triggers rate limits on the Call Control API. CXone enforces per-client and per-tenant throttling.
  • Fix: Implement exponential backoff. Respect the Retry-After header. Batch DTMF requests asynchronously with concurrency limits.
  • Code Fix: The _playDtmfAtomic method retries up to three times with dynamic backoff. It reads Retry-After or falls back to Math.pow(2, retries).

Error: SIP INFO Verification Mismatch

  • Cause: The telephony gateway cannot verify tone delivery via SIP INFO due to codec negotiation or trunk provider restrictions.
  • Fix: Ensure the call uses G.711 codec. Check trunk provider DTMF relay settings. Switch to sipInfoVerification: false if RDTMF is required, but accept higher latency.
  • Code Fix: Monitor the sipInfoVerified field in the audit log. Alert when verification fails consistently across a campaign.

Official References