Routing NICE CXone Interactions Based on External Inventory with Node.js

Routing NICE CXone Interactions Based on External Inventory with Node.js

What You Will Build

You will build a production-grade Node.js event consumer that intercepts CXone routing events, queries an external inventory management API for real-time product availability, maps availability states to CXone routing skills using a deterministic configuration map, and updates the interaction routing criteria via the CXone Routing API. The service includes explicit timeout handling for the inventory API with automatic fallback to general support queues, implements exponential backoff for CXone rate limits, and tracks end-to-end routing decision latency for performance optimization.

Prerequisites

  • OAuth 2.0 confidential client registered in CXone with routing:interaction:write and routing:interaction:read scopes
  • CXone API base URL (typically https://platform.nicecxone.com or region-specific equivalent)
  • Node.js 18.0 or later
  • External inventory API endpoint with product SKU lookup capability
  • NPM packages: express, axios, dotenv, uuid

Authentication Setup

CXone uses OAuth 2.0 client credentials flow for server-to-server authentication. You must cache the access token and refresh it before expiration. The token endpoint returns a JWT valid for one hour. You will implement a token manager that handles initial retrieval, caching, and automatic refresh on 401 Unauthorized responses.

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

const CXONE_BASE_URL = process.env.CXONE_BASE_URL || 'https://platform.nicecxone.com';
const CLIENT_ID = process.env.CXONE_CLIENT_ID;
const CLIENT_SECRET = process.env.CXONE_CLIENT_SECRET;

let cachedToken = null;
let tokenExpiry = 0;

async function getAccessToken() {
  const now = Date.now();
  if (cachedToken && now < tokenExpiry - 60000) {
    return cachedToken;
  }

  const tokenResponse = await axios.post(
    `${CXONE_BASE_URL}/oauth/token`,
    new URLSearchParams({
      grant_type: 'client_credentials',
      client_id: CLIENT_ID,
      client_secret: CLIENT_SECRET
    }),
    { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
  );

  cachedToken = tokenResponse.data.access_token;
  tokenExpiry = now + (tokenResponse.data.expires_in * 1000);
  return cachedToken;
}

async function cxoneRequest(method, path, data = null) {
  const token = await getAccessToken();
  const config = {
    method,
    url: `${CXONE_BASE_URL}${path}`,
    headers: {
      Authorization: `Bearer ${token}`,
      'Content-Type': 'application/json',
      'Accept': 'application/json'
    },
    timeout: 5000
  };
  if (data) config.data = data;

  try {
    const response = await axios(config);
    return response;
  } catch (error) {
    if (error.response && error.response.status === 401) {
      cachedToken = null;
      tokenExpiry = 0;
      const freshToken = await getAccessToken();
      config.headers.Authorization = `Bearer ${freshToken}`;
      return await axios(config);
    }
    throw error;
  }
}

OAuth Scopes Required: routing:interaction:write, routing:interaction:read
API Endpoint: POST /oauth/token
Error Handling: The cxoneRequest wrapper catches 401 responses, invalidates the cached token, triggers a fresh token request, and retries the original call. This prevents cascading authentication failures during high-throughput event processing.

Implementation

Step 1: Subscribe to Routing Events and Parse Payloads

CXone emits routing events via webhooks or the Events API. You will create an Express endpoint that accepts POST requests containing routing event payloads. The event payload includes the interaction identifier, media type, and initial routing context. You must validate the event structure before processing.

const express = require('express');
const app = express();
app.use(express.json());

const INVENTORY_SKILL_MAP = {
  in_stock: { skills: ['skill-premium-support-id'], queues: [] },
  low_stock: { skills: ['skill-expedited-support-id'], queues: [] },
  out_of_stock: { skills: [], queues: ['queue-general-support-id'] },
  unknown: { skills: [], queues: ['queue-general-support-id'] }
};

const INVENTORY_TIMEOUT_FALLBACK = { skills: [], queues: ['queue-general-support-id'] };

app.post('/webhook/routing-events', async (req, res) => {
  const startTime = performance.now();
  const event = req.body;

  if (!event || !event.data || !event.data.interactionId) {
    return res.status(400).json({ error: 'Invalid CXone routing event payload' });
  }

  const { interactionId, mediaType } = event.data;

  try {
    const routingUpdate = await processRoutingDecision(interactionId, mediaType);
    
    const latency = performance.now() - startTime;
    console.log(`[LATENCY] Interaction ${interactionId} routing decision: ${latency.toFixed(2)}ms`);

    await updateInteractionRouting(interactionId, routingUpdate);
    res.status(200).json({ status: 'processed', latency_ms: latency.toFixed(2) });
  } catch (error) {
    const latency = performance.now() - startTime;
    console.error(`[ERROR] Interaction ${interactionId} failed after ${latency.toFixed(2)}ms:`, error.message);
    res.status(500).json({ error: 'Routing decision failed', latency_ms: latency.toFixed(2) });
  }
});

Expected Event Payload:

{
  "event_type": "routing.interaction.created",
  "timestamp": "2024-05-15T10:30:00Z",
  "data": {
    "interactionId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "mediaType": "voice",
    "channel": "phone",
    "routing": {
      "skills": [],
      "queues": []
    }
  }
}

Error Handling: The endpoint validates the presence of interactionId. Missing or malformed payloads return 400 Bad Request. All processing errors return 500 with latency metrics attached for observability.

Step 2: Query Inventory API with Timeout and Fallback Logic

You will call an external inventory management API to determine product availability. External services frequently experience latency spikes or temporary unavailability. You must enforce a strict timeout and route interactions to a general support queue when the inventory API fails to respond within the threshold.

const INVENTORY_API_URL = process.env.INVENTORY_API_URL || 'https://inventory.example.com/api/v1/products';
const INVENTORY_TIMEOUT_MS = parseInt(process.env.INVENTORY_TIMEOUT_MS) || 2000;

async function fetchInventoryStatus(sku) {
  try {
    const response = await axios.get(`${INVENTORY_API_URL}/${sku}`, {
      timeout: INVENTORY_TIMEOUT_MS,
      headers: { 'Accept': 'application/json' }
    });
    return response.data.status;
  } catch (error) {
    if (error.code === 'ECONNABORTED' || error.message.includes('timeout')) {
      console.warn(`Inventory API timeout for SKU ${sku}. Applying fallback routing.`);
      return 'timeout';
    }
    if (error.response && error.response.status >= 500) {
      console.error(`Inventory API server error for SKU ${sku}: ${error.response.status}`);
      return 'timeout';
    }
    throw error;
  }
}

API Endpoint: GET /api/v1/products/{sku} (External Inventory Service)
Error Handling: The function catches ECONNABORTED and HTTP 5xx responses from the inventory service. Both conditions trigger a fallback state of timeout, which maps to general support routing. Network errors that are not timeouts propagate upward for explicit handling in the routing decision flow.

Step 3: Map Inventory Status to Routing Skills and Queues

CXone routing relies on skill-based and queue-based assignment. You will translate the inventory status into a routing configuration object that matches CXone’s expected schema. The mapping uses a deterministic configuration map to prevent routing drift during system updates.

async function processRoutingDecision(interactionId, mediaType) {
  const sku = extractSkuFromInteraction(interactionId, mediaType);
  
  let inventoryStatus;
  try {
    inventoryStatus = await fetchInventoryStatus(sku);
  } catch (error) {
    console.error(`Failed to fetch inventory for ${sku}: ${error.message}`);
    inventoryStatus = 'unknown';
  }

  const routingConfig = INVENTORY_SKILL_MAP[inventoryStatus] || INVENTORY_SKILL_MAP.unknown;
  return routingConfig;
}

function extractSkuFromInteraction(interactionId, mediaType) {
  if (mediaType === 'chat' || mediaType === 'email') {
    return `SKU-${interactionId.slice(-6).toUpperCase()}`;
  }
  return `SKU-DEFAULT-${mediaType.toUpperCase()}`;
}

Configuration Map Behavior: The INVENTORY_SKILL_MAP object defines explicit skill and queue arrays for each inventory state. When the inventory API returns in_stock, the interaction receives premium support skills. When it returns low_stock, expedited support skills are assigned. Both out_of_stock and unknown states route to general support queues. This deterministic mapping ensures consistent routing behavior regardless of external API volatility.

Step 4: Update Interaction Routing Criteria via CXone API

You will send a PATCH request to the CXone Routing API to update the interaction’s routing criteria. The request body must conform to CXone’s interaction schema. You will implement retry logic with exponential backoff to handle 429 Too Many Requests responses from the CXone platform.

const MAX_RETRIES = 3;
const BASE_DELAY = 1000;

async function updateInteractionRouting(interactionId, routingConfig) {
  const payload = {
    routing: {
      skills: routingConfig.skills.map(skillId => ({ id: skillId })),
      queues: routingConfig.queues.map(queueId => ({ id: queueId }))
    }
  };

  let attempt = 0;
  while (attempt <= MAX_RETRIES) {
    try {
      const response = await cxoneRequest('PATCH', `/api/v2/routing/interactions/${interactionId}`, payload);
      return response.data;
    } catch (error) {
      if (error.response && error.response.status === 429 && attempt < MAX_RETRIES) {
        const delay = BASE_DELAY * Math.pow(2, attempt) + Math.random() * 500;
        console.log(`Rate limited (429). Retrying in ${delay.toFixed(0)}ms...`);
        await new Promise(resolve => setTimeout(resolve, delay));
        attempt++;
        continue;
      }
      if (error.response && error.response.status === 404) {
        throw new Error(`Interaction ${interactionId} not found in CXone`);
      }
      if (error.response && error.response.status === 403) {
        throw new Error(`Insufficient permissions to update interaction ${interactionId}`);
      }
      throw error;
    }
  }
}

OAuth Scopes Required: routing:interaction:write
API Endpoint: PATCH /api/v2/routing/interactions/{interactionId}
Request Body:

{
  "routing": {
    "skills": [
      { "id": "skill-premium-support-id" }
    ],
    "queues": []
  }
}

Expected Response:

{
  "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "routing": {
    "skills": [
      { "id": "skill-premium-support-id", "priority": 1 }
    ],
    "queues": [],
    "wrapUpCode": null,
    "routingStatus": "ACTIVE"
  },
  "mediaType": "voice",
  "channel": "phone"
}

Error Handling: The retry loop catches 429 responses and applies exponential backoff with jitter. 404 responses indicate the interaction was already routed or expired. 403 responses indicate missing OAuth scopes. All other errors propagate immediately to prevent silent failures.

Complete Working Example

The following script combines all components into a single executable service. Replace the environment variables with your CXone credentials and inventory API endpoint before running.

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

const CXONE_BASE_URL = process.env.CXONE_BASE_URL || 'https://platform.nicecxone.com';
const CLIENT_ID = process.env.CXONE_CLIENT_ID;
const CLIENT_SECRET = process.env.CXONE_CLIENT_SECRET;
const INVENTORY_API_URL = process.env.INVENTORY_API_URL || 'https://inventory.example.com/api/v1/products';
const INVENTORY_TIMEOUT_MS = parseInt(process.env.INVENTORY_TIMEOUT_MS) || 2000;
const MAX_RETRIES = 3;
const BASE_DELAY = 1000;

let cachedToken = null;
let tokenExpiry = 0;

const INVENTORY_SKILL_MAP = {
  in_stock: { skills: ['skill-premium-support-id'], queues: [] },
  low_stock: { skills: ['skill-expedited-support-id'], queues: [] },
  out_of_stock: { skills: [], queues: ['queue-general-support-id'] },
  unknown: { skills: [], queues: ['queue-general-support-id'] }
};

async function getAccessToken() {
  const now = Date.now();
  if (cachedToken && now < tokenExpiry - 60000) {
    return cachedToken;
  }

  const tokenResponse = await axios.post(
    `${CXONE_BASE_URL}/oauth/token`,
    new URLSearchParams({
      grant_type: 'client_credentials',
      client_id: CLIENT_ID,
      client_secret: CLIENT_SECRET
    }),
    { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
  );

  cachedToken = tokenResponse.data.access_token;
  tokenExpiry = now + (tokenResponse.data.expires_in * 1000);
  return cachedToken;
}

async function cxoneRequest(method, path, data = null) {
  const token = await getAccessToken();
  const config = {
    method,
    url: `${CXONE_BASE_URL}${path}`,
    headers: {
      Authorization: `Bearer ${token}`,
      'Content-Type': 'application/json',
      'Accept': 'application/json'
    },
    timeout: 5000
  };
  if (data) config.data = data;

  try {
    const response = await axios(config);
    return response;
  } catch (error) {
    if (error.response && error.response.status === 401) {
      cachedToken = null;
      tokenExpiry = 0;
      const freshToken = await getAccessToken();
      config.headers.Authorization = `Bearer ${freshToken}`;
      return await axios(config);
    }
    throw error;
  }
}

async function fetchInventoryStatus(sku) {
  try {
    const response = await axios.get(`${INVENTORY_API_URL}/${sku}`, {
      timeout: INVENTORY_TIMEOUT_MS,
      headers: { 'Accept': 'application/json' }
    });
    return response.data.status;
  } catch (error) {
    if (error.code === 'ECONNABORTED' || error.message.includes('timeout')) {
      console.warn(`Inventory API timeout for SKU ${sku}. Applying fallback routing.`);
      return 'timeout';
    }
    if (error.response && error.response.status >= 500) {
      console.error(`Inventory API server error for SKU ${sku}: ${error.response.status}`);
      return 'timeout';
    }
    throw error;
  }
}

async function processRoutingDecision(interactionId, mediaType) {
  const sku = `SKU-${mediaType.toUpperCase()}-${interactionId.slice(-6)}`;
  
  let inventoryStatus;
  try {
    inventoryStatus = await fetchInventoryStatus(sku);
  } catch (error) {
    console.error(`Failed to fetch inventory for ${sku}: ${error.message}`);
    inventoryStatus = 'unknown';
  }

  return INVENTORY_SKILL_MAP[inventoryStatus] || INVENTORY_SKILL_MAP.unknown;
}

async function updateInteractionRouting(interactionId, routingConfig) {
  const payload = {
    routing: {
      skills: routingConfig.skills.map(skillId => ({ id: skillId })),
      queues: routingConfig.queues.map(queueId => ({ id: queueId }))
    }
  };

  let attempt = 0;
  while (attempt <= MAX_RETRIES) {
    try {
      const response = await cxoneRequest('PATCH', `/api/v2/routing/interactions/${interactionId}`, payload);
      return response.data;
    } catch (error) {
      if (error.response && error.response.status === 429 && attempt < MAX_RETRIES) {
        const delay = BASE_DELAY * Math.pow(2, attempt) + Math.random() * 500;
        console.log(`Rate limited (429). Retrying in ${delay.toFixed(0)}ms...`);
        await new Promise(resolve => setTimeout(resolve, delay));
        attempt++;
        continue;
      }
      if (error.response && error.response.status === 404) {
        throw new Error(`Interaction ${interactionId} not found in CXone`);
      }
      if (error.response && error.response.status === 403) {
        throw new Error(`Insufficient permissions to update interaction ${interactionId}`);
      }
      throw error;
    }
  }
}

const app = express();
app.use(express.json());

app.post('/webhook/routing-events', async (req, res) => {
  const startTime = performance.now();
  const event = req.body;

  if (!event || !event.data || !event.data.interactionId) {
    return res.status(400).json({ error: 'Invalid CXone routing event payload' });
  }

  const { interactionId, mediaType } = event.data;

  try {
    const routingUpdate = await processRoutingDecision(interactionId, mediaType);
    const latency = performance.now() - startTime;
    console.log(`[LATENCY] Interaction ${interactionId} routing decision: ${latency.toFixed(2)}ms`);

    await updateInteractionRouting(interactionId, routingUpdate);
    res.status(200).json({ status: 'processed', latency_ms: latency.toFixed(2) });
  } catch (error) {
    const latency = performance.now() - startTime;
    console.error(`[ERROR] Interaction ${interactionId} failed after ${latency.toFixed(2)}ms:`, error.message);
    res.status(500).json({ error: 'Routing decision failed', latency_ms: latency.toFixed(2) });
  }
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Routing event consumer listening on port ${PORT}`);
});

Common Errors & Debugging

Error: 401 Unauthorized

Cause: The OAuth access token expired or the client credentials are incorrect.
Fix: Verify CXONE_CLIENT_ID and CXONE_CLIENT_SECRET in your environment. Ensure the token refresh logic executes before the expires_in window closes. The provided cxoneRequest wrapper automatically refreshes tokens on 401 responses.

Error: 403 Forbidden

Cause: The OAuth client lacks the routing:interaction:write scope.
Fix: Navigate to the CXone admin console, locate the OAuth client configuration, and add routing:interaction:write to the allowed scopes. Regenerate the access token after updating the client permissions.

Error: 429 Too Many Requests

Cause: The CXone Routing API enforces rate limits per tenant or per client. High event throughput triggers throttling.
Fix: The updateInteractionRouting function implements exponential backoff with jitter. If throttling persists, implement a message queue between the webhook endpoint and the CXone API calls. Process events asynchronously with controlled concurrency.

Error: Inventory API Timeout

Cause: The external inventory service exceeds INVENTORY_TIMEOUT_MS or returns a 5xx status.
Fix: The service catches timeout conditions and returns timeout status, which maps to queue-general-support-id. Adjust INVENTORY_TIMEOUT_MS based on your inventory API SLA. Implement circuit breaker patterns if timeout frequency exceeds acceptable thresholds.

Error: 404 Interaction Not Found

Cause: The interaction was already routed, completed, or expired before the routing update arrived.
Fix: CXone processes routing events asynchronously. If the interaction reaches an agent before your consumer executes, the PATCH request returns 404. Log the interaction ID and skip further processing. This is expected behavior for high-velocity routing scenarios.

Official References