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
CXoneAuthManagerclass handles automatic refresh. - Code: The
get_access_tokenmethod checkstime.time() < self.token_expiry - 300to 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_sizeto 500. Stagger query submissions. - Code: The
_requestmethod catches429, readsRetry-After, and sleeps before retrying.
Error: HTTP 400 Bad Request
- Cause: Invalid date format,
sizeexceeds 10000, or missing required filter fields. - Fix: Validate payloads with Pydantic before submission. Ensure ISO 8601 dates include timezone offsets.
- Code: The
AnalyticsQueryPayloadclass enforcessize <= 10000and 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
botIdmatches the actual bot UUID in CXone. Check thatchannelTypematches deployed channels. - Code: The
submit_and_pollmethod raisesRuntimeErrorwith the platform message for immediate debugging.