Extracting NICE CXone Bot Conversation Analytics with Python

Extracting NICE CXone Bot Conversation Analytics with Python

What You Will Build

A production-grade Python module that submits detailed conversation analytics queries, polls for asynchronous results, validates PII redaction, calculates conversation drop-off points, logs audit trails, and synchronizes extracted data to external warehouses. The implementation uses the NICE CXone Conversational Analytics API with httpx for robust network handling.

Prerequisites

  • OAuth confidential client credentials configured in the NICE CXone admin console
  • Required OAuth scopes: analytics:conversations:view, analytics:query:view, webhooks:manage
  • Python 3.10 or higher
  • External dependencies: httpx, pydantic, python-dotenv
  • Base URL format: https://{tenant}.mypurecloud.com/api/v2

Authentication Setup

The NICE CXone platform uses OAuth 2.0 Client Credentials flow. You must cache the access token and handle expiration before making analytics requests. The following code demonstrates token acquisition, caching, and refresh logic.

import os
import time
import logging
from typing import Optional
import httpx

logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)s | %(message)s")
logger = logging.getLogger(__name__)

class CXoneAuthManager:
    def __init__(self, tenant: str, client_id: str, client_secret: str):
        self.base_url = f"https://{tenant}.mypurecloud.com/api/v2"
        self.client_id = client_id
        self.client_secret = client_secret
        self.token: Optional[str] = None
        self.token_expiry: float = 0.0
        self.http = httpx.Client(timeout=30.0)

    def _request_token(self) -> dict:
        url = f"{self.base_url}/oauth/token"
        headers = {"Content-Type": "application/x-www-form-urlencoded"}
        data = {
            "grant_type": "client_credentials",
            "client_id": self.client_id,
            "client_secret": self.client_secret,
            "scope": "analytics:conversations:view analytics:query:view"
        }
        response = self.http.post(url, headers=headers, data=data)
        response.raise_for_status()
        return response.json()

    def get_access_token(self) -> str:
        if self.token and time.time() < self.token_expiry - 300:
            return self.token
        
        logger.info("Requesting OAuth access token")
        token_data = self._request_token()
        self.token = token_data["access_token"]
        self.token_expiry = time.time() + token_data["expires_in"]
        logger.info("OAuth token cached successfully")
        return self.token

Implementation

Step 1: Configure HTTP Client with Retry and Token Management

Analytics queries are computationally heavy and frequently trigger rate limits. You must implement exponential backoff for HTTP 429 responses. The following client wrapper attaches the bearer token automatically and handles retry logic.

import json
from httpx import Response

class AnalyticsHttpClient:
    def __init__(self, auth: CXoneAuthManager, max_retries: int = 4):
        self.auth = auth
        self.max_retries = max_retries
        self.http = httpx.Client(timeout=45.0)

    def _headers(self) -> dict:
        return {
            "Authorization": f"Bearer {self.auth.get_access_token()}",
            "Content-Type": "application/json",
            "Accept": "application/json"
        }

    def post(self, url: str, payload: dict) -> Response:
        return self._request("POST", url, json=payload)

    def get(self, url: str) -> Response:
        return self._request("GET", url)

    def _request(self, method: str, url: str, **kwargs) -> Response:
        for attempt in range(1, self.max_retries + 1):
            response = self.http.request(method, url, headers=self._headers(), **kwargs)
            
            if response.status_code == 429:
                retry_after = int(response.headers.get("Retry-After", 2 ** attempt))
                logger.warning(f"Rate limited (429). Retrying in {retry_after}s (attempt {attempt})")
                time.sleep(retry_after)
                continue
            
            if response.status_code in (401, 403):
                logger.error(f"Authentication failure: {response.status_code}")
                response.raise_for_status()
                
            return response
            
        raise httpx.HTTPStatusError("Max retries exceeded", request=response.request, response=response)

Step 2: Construct and Validate the Analytics Query Payload

The analytics query endpoint requires a strictly formatted JSON body. You must validate date ranges, enforce maximum export volume limits, and structure the filter matrix correctly. The official API caps size at 10000 per request.

from pydantic import BaseModel, field_validator
from datetime import datetime

class AnalyticsQueryPayload(BaseModel):
    dateFrom: str
    dateTo: str
    groupBy: str = "conversationId"
    size: int = 1000
    filter: dict = {}
    metrics: list[str] = ["botConversationDuration", "botConversationSteps"]

    @field_validator("size")
    @classmethod
    def validate_max_volume(cls, v: int) -> int:
        if v > 10000:
            raise ValueError("Maximum export volume limit is 10000. Reduce size or use pagination.")
        return v

    @field_validator("dateFrom", "dateTo")
    @classmethod
    def validate_date_format(cls, v: str) -> str:
        try:
            datetime.fromisoformat(v.replace("Z", "+00:00"))
        except ValueError:
            raise ValueError("Dates must be ISO 8601 format with timezone.")
        return v

def build_query_payload(
    start_date: str,
    end_date: str,
    bot_id: str,
    size: int = 1000
) -> dict:
    payload = AnalyticsQueryPayload(
        dateFrom=start_date,
        dateTo=end_date,
        size=size,
        filter={
            "type": "bot",
            "botId": bot_id,
            "channelType": "webchat"
        },
        metrics=["botConversationDuration", "botConversationSteps", "conversationEndReason"]
    )
    return payload.model_dump()

Step 3: Submit Query and Poll for Results with Pagination

Detailed conversation extraction is asynchronous. You submit the query, receive a queryId, and poll the status endpoint until status equals completed. The response includes a cursor for pagination. You must handle cursor iteration safely.

def submit_query(client: AnalyticsHttpClient, payload: dict) -> str:
    url = f"{client.auth.base_url}/analytics/conversations/details/query"
    response = client.post(url, payload)
    response.raise_for_status()
    data = response.json()
    logger.info(f"Query submitted with ID: {data['id']}")
    return data["id"]

def poll_query_status(client: AnalyticsHttpClient, query_id: str) -> dict:
    url = f"{client.auth.base_url}/analytics/conversations/details/query/{query_id}"
    while True:
        response = client.get(url)
        response.raise_for_status()
        data = response.json()
        status = data.get("status")
        
        if status == "completed":
            logger.info("Query processing completed. Fetching results.")
            return data
        elif status == "failed":
            raise RuntimeError(f"Query failed: {data.get('message')}")
        else:
            logger.info(f"Query status: {status}. Waiting 5s...")
            time.sleep(5)

def fetch_page(client: AnalyticsHttpClient, query_id: str, cursor: Optional[str] = None) -> dict:
    url = f"{client.auth.base_url}/analytics/conversations/details/query/{query_id}"
    if cursor:
        url += f"?cursor={cursor}"
    response = client.get(url)
    response.raise_for_status()
    return response.json()

Step 4: Process Conversation Paths and Identify Drop-Off Points

The detailed response contains a steps array representing the conversation matrix. You must parse node transitions, verify format integrity, and calculate where conversations terminate. This logic runs atomically per conversation record.

from typing import List, Dict, Any

def analyze_conversation_path(conversation: Dict[str, Any]) -> Dict[str, Any]:
    steps = conversation.get("steps", [])
    if not steps:
        return {"node_id": None, "drop_off_reason": "empty_conversation", "step_count": 0}
    
    last_step = steps[-1]
    drop_off_reason = last_step.get("outcome", "unknown")
    node_id = last_step.get("nodeId", "root")
    
    # Verify format: ensure each step contains required analytics fields
    for step in steps:
        if "nodeId" not in step or "timestamp" not in step:
            logger.warning(f"Incomplete step format detected in conversation {conversation.get('conversationId')}")
            
    return {
        "conversation_id": conversation.get("conversationId"),
        "node_id": node_id,
        "drop_off_reason": drop_off_reason,
        "step_count": len(steps),
        "duration_ms": conversation.get("metrics", {}).get("botConversationDuration", {}).get("value", 0)
    }

Step 5: Validate PII Redaction and Synchronize to Webhooks

You must validate that sensitive data is redacted before exporting. The platform replaces PII with markers like **REDACTED**. You will also trigger a webhook synchronization event and record telemetry for audit compliance.

import uuid

def validate_pii_redaction(transcript: str) -> bool:
    redaction_markers = ["**REDACTED**", "[REDACTED]", "***"]
    for marker in redaction_markers:
        if marker in transcript:
            return True
    return False

def sync_to_warehouse(data: List[Dict], webhook_url: str, client: AnalyticsHttpClient) -> None:
    payload = {
        "event_id": str(uuid.uuid4()),
        "timestamp": datetime.utcnow().isoformat() + "Z",
        "record_count": len(data),
        "data": data
    }
    try:
        response = client.http.post(webhook_url, json=payload, headers={"Content-Type": "application/json"})
        if response.status_code >= 400:
            logger.error(f"Webhook sync failed with status {response.status_code}")
        else:
            logger.info(f"Synchronized {len(data)} records to external warehouse")
    except Exception as e:
        logger.error(f"Webhook synchronization error: {e}")

def log_audit_event(event_type: str, details: Dict, success: bool, latency_ms: float) -> None:
    audit_record = {
        "event": event_type,
        "status": "success" if success else "failure",
        "latency_ms": latency_ms,
        "details": details,
        "generated_at": datetime.utcnow().isoformat() + "Z"
    }
    logger.info(f"AUDIT_LOG: {json.dumps(audit_record)}")

Complete Working Example

The following module combines all components into a single extractor class. It handles token management, query submission, pagination, PII validation, drop-off analysis, telemetry, and webhook synchronization.

import os
import time
import json
import logging
from typing import List, Dict, Optional
from datetime import datetime
import httpx
from pydantic import BaseModel, field_validator

logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)s | %(message)s")
logger = logging.getLogger(__name__)

class CXoneAuthManager:
    def __init__(self, tenant: str, client_id: str, client_secret: str):
        self.base_url = f"https://{tenant}.mypurecloud.com/api/v2"
        self.client_id = client_id
        self.client_secret = client_secret
        self.token: Optional[str] = None
        self.token_expiry: float = 0.0
        self.http = httpx.Client(timeout=30.0)

    def _request_token(self) -> dict:
        url = f"{self.base_url}/oauth/token"
        headers = {"Content-Type": "application/x-www-form-urlencoded"}
        data = {
            "grant_type": "client_credentials",
            "client_id": self.client_id,
            "client_secret": self.client_secret,
            "scope": "analytics:conversations:view analytics:query:view"
        }
        response = self.http.post(url, headers=headers, data=data)
        response.raise_for_status()
        return response.json()

    def get_access_token(self) -> str:
        if self.token and time.time() < self.token_expiry - 300:
            return self.token
        logger.info("Requesting OAuth access token")
        token_data = self._request_token()
        self.token = token_data["access_token"]
        self.token_expiry = time.time() + token_data["expires_in"]
        return self.token

class AnalyticsExtractor:
    def __init__(self, tenant: str, client_id: str, client_secret: str, webhook_url: str):
        self.auth = CXoneAuthManager(tenant, client_id, client_secret)
        self.webhook_url = webhook_url
        self.max_retries = 4
        self.http = httpx.Client(timeout=45.0)
        self.success_count = 0
        self.failure_count = 0

    def _headers(self) -> dict:
        return {
            "Authorization": f"Bearer {self.auth.get_access_token()}",
            "Content-Type": "application/json",
            "Accept": "application/json"
        }

    def _request(self, method: str, url: str, **kwargs) -> httpx.Response:
        for attempt in range(1, self.max_retries + 1):
            response = self.http.request(method, url, headers=self._headers(), **kwargs)
            if response.status_code == 429:
                retry_after = int(response.headers.get("Retry-After", 2 ** attempt))
                logger.warning(f"Rate limited (429). Retrying in {retry_after}s (attempt {attempt})")
                time.sleep(retry_after)
                continue
            if response.status_code in (401, 403):
                logger.error(f"Authentication failure: {response.status_code}")
                response.raise_for_status()
            return response
        raise httpx.HTTPStatusError("Max retries exceeded", request=response.request, response=response)

    def submit_and_poll(self, payload: dict) -> str:
        url = f"{self.auth.base_url}/analytics/conversations/details/query"
        response = self._request("POST", url, json=payload)
        response.raise_for_status()
        query_id = response.json()["id"]
        logger.info(f"Query submitted with ID: {query_id}")
        
        while True:
            status_resp = self._request("GET", f"{url}/{query_id}")
            status_resp.raise_for_status()
            status_data = status_resp.json()
            if status_data["status"] == "completed":
                return query_id
            elif status_data["status"] == "failed":
                raise RuntimeError(f"Query failed: {status_data.get('message')}")
            time.sleep(5)

    def extract_all(self, start_date: str, end_date: str, bot_id: str, page_size: int = 1000) -> List[Dict]:
        payload = {
            "dateFrom": start_date,
            "dateTo": end_date,
            "groupBy": "conversationId",
            "size": page_size,
            "filter": {"type": "bot", "botId": bot_id, "channelType": "webchat"},
            "metrics": ["botConversationDuration", "botConversationSteps", "conversationEndReason"]
        }
        
        query_id = self.submit_and_poll(payload)
        all_results = []
        cursor = None
        
        start_time = time.perf_counter()
        
        while True:
            url = f"{self.auth.base_url}/analytics/conversations/details/query/{query_id}"
            if cursor:
                url += f"?cursor={cursor}"
                
            resp = self._request("GET", url)
            resp.raise_for_status()
            page_data = resp.json()
            
            conversations = page_data.get("conversations", [])
            if not conversations:
                break
                
            for conv in conversations:
                transcript = conv.get("transcript", "")
                if not validate_pii_redaction(transcript):
                    logger.warning(f"PII redaction check failed for conversation {conv.get('conversationId')}")
                    self.failure_count += 1
                    continue
                    
                analysis = analyze_conversation_path(conv)
                all_results.append(analysis)
                self.success_count += 1
                
            cursor = page_data.get("nextPageCursor")
            if not cursor:
                break
                
        latency_ms = (time.perf_counter() - start_time) * 1000
        sync_to_warehouse(all_results, self.webhook_url, self)
        log_audit_event("analytics_extract", {
            "query_id": query_id,
            "records_processed": len(all_results),
            "success_rate": f"{self.success_count}/{self.success_count + self.failure_count}"
        }, self.failure_count == 0, latency_ms)
        
        return all_results

def validate_pii_redaction(transcript: str) -> bool:
    markers = ["**REDACTED**", "[REDACTED]", "***"]
    return any(marker in transcript for marker in markers)

def analyze_conversation_path(conversation: Dict) -> Dict:
    steps = conversation.get("steps", [])
    if not steps:
        return {"conversation_id": conversation.get("conversationId"), "node_id": None, "drop_off_reason": "empty", "step_count": 0}
    last = steps[-1]
    return {
        "conversation_id": conversation.get("conversationId"),
        "node_id": last.get("nodeId", "root"),
        "drop_off_reason": last.get("outcome", "unknown"),
        "step_count": len(steps),
        "duration_ms": conversation.get("metrics", {}).get("botConversationDuration", {}).get("value", 0)
    }

def sync_to_warehouse(data: List[Dict], webhook_url: str, extractor: AnalyticsExtractor) -> None:
    payload = {
        "event_id": str(__import__("uuid").uuid4()),
        "timestamp": datetime.utcnow().isoformat() + "Z",
        "record_count": len(data),
        "data": data
    }
    try:
        resp = extractor.http.post(webhook_url, json=payload, headers={"Content-Type": "application/json"})
        if resp.status_code >= 400:
            logger.error(f"Webhook sync failed: {resp.status_code}")
    except Exception as e:
        logger.error(f"Webhook error: {e}")

def log_audit_event(event_type: str, details: Dict, success: bool, latency_ms: float) -> None:
    logger.info(f"AUDIT_LOG: {json.dumps({
        'event': event_type, 'status': 'success' if success else 'failure',
        'latency_ms': latency_ms, 'details': details,
        'generated_at': datetime.utcnow().isoformat() + "Z"
    })}")

if __name__ == "__main__":
    extractor = AnalyticsExtractor(
        tenant=os.getenv("CXONE_TENANT"),
        client_id=os.getenv("CXONE_CLIENT_ID"),
        client_secret=os.getenv("CXONE_CLIENT_SECRET"),
        webhook_url="https://your-warehouse.example.com/api/v1/sync"
    )
    results = extractor.extract_all(
        start_date="2023-10-01T00:00:00.000Z",
        end_date="2023-10-31T23:59:59.999Z",
        bot_id="bot-12345",
        page_size=500
    )
    print(f"Extraction complete. Processed {len(results)} conversations.")

Common Errors & Debugging

Error: HTTP 401 Unauthorized

  • Cause: The OAuth token expired or the client credentials are incorrect.
  • Fix: Verify the client ID and secret match the confidential client in the admin console. Ensure the token cache refreshes before expiration. The CXoneAuthManager class handles automatic refresh.
  • Code: The get_access_token method checks time.time() < self.token_expiry - 300 to proactively refresh.

Error: HTTP 429 Too Many Requests

  • Cause: Analytics queries exceed tenant rate limits. Detailed conversation extraction is resource intensive.
  • Fix: Implement exponential backoff. Reduce page_size to 500. Stagger query submissions.
  • Code: The _request method catches 429, reads Retry-After, and sleeps before retrying.

Error: HTTP 400 Bad Request

  • Cause: Invalid date format, size exceeds 10000, or missing required filter fields.
  • Fix: Validate payloads with Pydantic before submission. Ensure ISO 8601 dates include timezone offsets.
  • Code: The AnalyticsQueryPayload class enforces size <= 10000 and validates date formats.

Error: Query Status Failed

  • Cause: The platform could not process the filter matrix or the bot ID does not exist.
  • Fix: Verify botId matches the actual bot UUID in CXone. Check that channelType matches deployed channels.
  • Code: The submit_and_poll method raises RuntimeError with the platform message for immediate debugging.

Official References