Retrieving NICE CXone Outbound Call Recordings via API with Python
What You Will Build
- This script queries the CXone Recordings API, polls until audio is available, streams large files using Range headers, converts formats with FFmpeg, syncs metadata to a quality management webhook, logs compliance access, tracks latency, and serves a web player for agent review.
- It uses the NICE CXone v2 REST API endpoints for recording search, status polling, and direct audio streaming.
- The implementation covers Python 3.9+ using
httpx,fastapi,subprocess, and standard library logging.
Prerequisites
- OAuth 2.0 Client Credentials grant with scopes:
recording:read,recording:download - CXone v2 API access enabled for your tenant
- Python 3.9 or higher
- External dependencies:
pip install httpx fastapi uvicorn - System dependency:
ffmpegbinary available in$PATH - Write access to a local directory for temporary WAV downloads and converted MP3 files
Authentication Setup
CXone uses standard OAuth 2.0 Client Credentials for server-to-server integrations. You must cache the access token and refresh it before expiration to avoid unnecessary authentication round trips. The token endpoint requires your client ID and secret.
import httpx
import time
from typing import Optional
class CxoneAuth:
def __init__(self, client_id: str, client_secret: str, base_url: str):
self.client_id = client_id
self.client_secret = client_secret
self.base_url = base_url.rstrip("/")
self.token: Optional[str] = None
self.token_expiry: float = 0.0
async def get_token(self) -> str:
if self.token and time.time() < self.token_expiry:
return self.token
async with httpx.AsyncClient() as client:
response = await client.post(
f"{self.base_url}/oauth2/token",
data={"grant_type": "client_credentials"},
auth=(self.client_id, self.client_secret),
timeout=10.0
)
response.raise_for_status()
payload = response.json()
self.token = payload["access_token"]
self.token_expiry = time.time() + payload["expires_in"] - 60
return self.token
HTTP Request Cycle for Authentication:
- Method:
POST - Path:
/oauth2/token - Headers:
Content-Type: application/x-www-form-urlencoded - Body:
grant_type=client_credentials - Response:
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "recording:read recording:download"
}
Implementation
Step 1: Query Recordings and Poll for Availability with Jitter
Outbound recordings enter a processing state before becoming available. You must poll the recording endpoint until the status changes. Exponential backoff with random jitter prevents thundering herd issues when multiple agents trigger concurrent retrieval.
import asyncio
import random
import logging
logger = logging.getLogger("cxone_recordings")
async def poll_recording_status(auth: CxoneAuth, recording_id: str, max_attempts: int = 15) -> dict:
for attempt in range(max_attempts):
async with httpx.AsyncClient() as client:
token = await auth.get_token()
response = await client.get(
f"{auth.base_url}/api/v2/recording/{recording_id}",
headers={"Authorization": f"Bearer {token}"},
timeout=10.0
)
response.raise_for_status()
data = response.json()
status = data.get("status")
if status == "available":
logger.info("Recording %s is available for download", recording_id)
return data
if status == "failed":
raise RuntimeError(f"Recording {recording_id} failed during processing")
jitter = random.uniform(0.5, 1.5)
wait_time = min(2 ** attempt * jitter, 30.0)
logger.debug("Polling attempt %d/%d. Status: %s. Waiting %.2f s", attempt + 1, max_attempts, status, wait_time)
await asyncio.sleep(wait_time)
raise TimeoutError(f"Recording {recording_id} did not become available within polling window")
Required OAuth Scope: recording:read
Expected Response Fields: id, status, startTime, duration, privacy, links.download
Step 2: Stream Large Audio Files with Range Headers and Compliance Logging
CXone returns audio as WAV files that frequently exceed 50 MB. You must use HTTP Range headers to support resumable downloads and chunked transfer encoding to manage memory. The logging module records access events for compliance audits.
import os
import time
async def stream_recording_with_range(auth: CxoneAuth, recording_id: str, output_path: str, chunk_size: int = 1024 * 1024) -> float:
start_time = time.time()
download_url = f"{auth.base_url}/api/v2/recording/{recording_id}/download"
token = await auth.get_token()
headers = {
"Authorization": f"Bearer {token}",
"Range": "bytes=0-",
"Accept-Encoding": "identity"
}
async with httpx.AsyncClient() as client:
async with client.stream("GET", download_url, headers=headers, timeout=300.0) as response:
response.raise_for_status()
content_range = response.headers.get("Content-Range")
if not content_range:
raise RuntimeError("CXone response does not include Content-Range. Range requests unsupported.")
total_size = int(content_range.split("/")[-1])
logger.info("Initiating stream for %s. Total size: %d bytes", recording_id, total_size)
with open(output_path, "wb") as file_handle:
async for chunk in response.aiter_bytes(chunk_size):
file_handle.write(chunk)
latency = time.time() - start_time
logger.info("Download complete. id=%s, size=%d, latency=%.2f s", recording_id, total_size, latency)
return latency
HTTP Request Cycle for Streaming:
- Method:
GET - Path:
/api/v2/recording/{id}/download - Headers:
Authorization: Bearer <token>,Range: bytes=0-,Accept-Encoding: identity - Response Headers:
Content-Type: audio/wav,Content-Range: bytes 0-52428799/52428800,Transfer-Encoding: chunked - Required OAuth Scope:
recording:download
Step 3: Validate Permissions and Convert with FFmpeg
CXone enforces privacy constraints at the recording level. You must verify that the requesting user holds the appropriate role before proceeding. FFmpeg converts the raw WAV stream to MP3 for browser compatibility and reduced storage footprint.
import subprocess
import sys
def validate_recording_access(recording_data: dict, user_role: str) -> bool:
privacy_level = recording_data.get("privacy", "standard")
if privacy_level == "restricted" and user_role != "supervisor":
logger.warning("Access denied. Recording %s is restricted. User role: %s", recording_data["id"], user_role)
return False
return True
def convert_wav_to_mp3(input_path: str, output_path: str) -> None:
command = [
"ffmpeg", "-i", input_path,
"-codec:a", "libmp3lame",
"-q:a", "2",
"-map_metadata", "0",
"-y", output_path
]
process = subprocess.run(
command,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True
)
if process.returncode != 0:
logger.error("FFmpeg conversion failed. stderr: %s", process.stderr)
raise RuntimeError(f"Audio conversion failed: {process.stderr}")
logger.info("Converted %s to %s", input_path, output_path)
Step 4: Sync Metadata via Webhook and Expose Player Endpoint
After conversion, you push metadata to an external quality management system. FastAPI serves the converted audio and renders a lightweight HTML5 audio player for agent review.
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse, HTMLResponse
app = FastAPI()
async def sync_to_qm_system(recording_id: str, duration: int, converted_path: str) -> None:
webhook_url = "https://qm-system.example.com/api/v1/recordings"
payload = {
"recordingId": recording_id,
"durationSeconds": duration,
"filePath": converted_path,
"syncedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
}
async with httpx.AsyncClient() as client:
response = await client.post(webhook_url, json=payload, timeout=10.0)
if response.status_code >= 400:
logger.warning("Webhook sync returned %d: %s", response.status_code, response.text)
else:
logger.info("Metadata synced to QM system for %s", recording_id)
@app.get("/play/{recording_id}")
async def render_player(recording_id: str):
return HTMLResponse(content=f"""
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Agent Review Player</title></head>
<body style="font-family: sans-serif; padding: 2rem;">
<h2>Recording Review: {recording_id}</h2>
<audio controls style="width: 100%;">
<source src="/audio/{recording_id}.mp3" type="audio/mpeg">
Your browser does not support the audio element.
</audio>
</body>
</html>
""")
@app.get("/audio/{recording_id}.mp3")
async def serve_converted_audio(recording_id: str):
file_path = f"./converted/{recording_id}.mp3"
if not os.path.exists(file_path):
raise HTTPException(status_code=404, detail="Audio file not yet processed")
return FileResponse(file_path, media_type="audio/mpeg")
Complete Working Example
The following module combines authentication, polling, streaming, validation, conversion, webhook sync, and the FastAPI player into a single runnable application. Create a .env file or pass arguments for your CXone credentials.
import os
import asyncio
import logging
import time
import random
import subprocess
from typing import Optional
import httpx
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse, HTMLResponse
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger("cxone_recordings")
class CxoneAuth:
def __init__(self, client_id: str, client_secret: str, base_url: str):
self.client_id = client_id
self.client_secret = client_secret
self.base_url = base_url.rstrip("/")
self.token: Optional[str] = None
self.token_expiry: float = 0.0
async def get_token(self) -> str:
if self.token and time.time() < self.token_expiry:
return self.token
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{self.base_url}/oauth2/token",
data={"grant_type": "client_credentials"},
auth=(self.client_id, self.client_secret),
timeout=10.0
)
resp.raise_for_status()
data = resp.json()
self.token = data["access_token"]
self.token_expiry = time.time() + data["expires_in"] - 60
return self.token
async def poll_recording_status(auth: CxoneAuth, recording_id: str, max_attempts: int = 15) -> dict:
for attempt in range(max_attempts):
async with httpx.AsyncClient() as client:
token = await auth.get_token()
resp = await client.get(
f"{auth.base_url}/api/v2/recording/{recording_id}",
headers={"Authorization": f"Bearer {token}"},
timeout=10.0
)
resp.raise_for_status()
data = resp.json()
status = data.get("status")
if status == "available":
return data
if status == "failed":
raise RuntimeError(f"Recording {recording_id} failed processing")
jitter = random.uniform(0.5, 1.5)
await asyncio.sleep(min(2 ** attempt * jitter, 30.0))
raise TimeoutError("Recording not available")
async def stream_recording(auth: CxoneAuth, recording_id: str, output_path: str) -> float:
start = time.time()
url = f"{auth.base_url}/api/v2/recording/{recording_id}/download"
token = await auth.get_token()
headers = {"Authorization": f"Bearer {token}", "Range": "bytes=0-", "Accept-Encoding": "identity"}
async with httpx.AsyncClient() as client:
async with client.stream("GET", url, headers=headers, timeout=300.0) as resp:
resp.raise_for_status()
total = int(resp.headers["Content-Range"].split("/")[-1])
with open(output_path, "wb") as f:
async for chunk in resp.aiter_bytes(1024 * 1024):
f.write(chunk)
latency = time.time() - start
logger.info("Downloaded %s (%d bytes) in %.2f s", recording_id, total, latency)
return latency
def validate_access(data: dict, role: str) -> bool:
return not (data.get("privacy") == "restricted" and role != "supervisor")
def convert_audio(in_path: str, out_path: str) -> None:
subprocess.run(["ffmpeg", "-i", in_path, "-codec:a", "libmp3lame", "-q:a", "2", "-y", out_path], check=True, capture_output=True)
async def sync_webhook(rec_id: str, duration: int, path: str) -> None:
async with httpx.AsyncClient() as c:
await c.post("https://qm-system.example.com/api/v1/recordings", json={"id": rec_id, "duration": duration, "path": path})
app = FastAPI()
os.makedirs("./converted", exist_ok=True)
@app.post("/ingest/{recording_id}")
async def ingest_recording(recording_id: str, user_role: str = "agent"):
auth = CxoneAuth(os.getenv("CXONE_CLIENT_ID"), os.getenv("CXONE_CLIENT_SECRET"), os.getenv("CXONE_BASE_URL"))
data = await poll_recording_status(auth, recording_id)
if not validate_access(data, user_role):
raise HTTPException(status_code=403, detail="Insufficient permissions")
wav_path = f"./converted/{recording_id}.wav"
mp3_path = f"./converted/{recording_id}.mp3"
await stream_recording(auth, recording_id, wav_path)
convert_audio(wav_path, mp3_path)
os.remove(wav_path)
await sync_webhook(recording_id, data.get("duration", 0), mp3_path)
return {"status": "ready", "player_url": f"/play/{recording_id}"}
@app.get("/play/{recording_id}")
async def player(recording_id: str):
return HTMLResponse(f'<html><body><h2>Review</h2><audio controls><source src="/audio/{recording_id}.mp3" type="audio/mpeg"></audio></body></html>')
@app.get("/audio/{recording_id}.mp3")
async def serve(recording_id: str):
path = f"./converted/{recording_id}.mp3"
if not os.path.exists(path):
raise HTTPException(status_code=404, detail="Not found")
return FileResponse(path, media_type="audio/mpeg")
Run the application with uvicorn main:app --reload. Trigger ingestion via POST /ingest/{recording_id}. The endpoint handles polling, streaming, conversion, webhook sync, and serves the player at /play/{recording_id}.
Common Errors & Debugging
Error: 401 Unauthorized
- Cause: Expired access token or invalid client credentials.
- Fix: Verify
CXONE_BASE_URLpoints to your environment login domain. Ensure the OAuth client hasrecording:readandrecording:downloadscopes assigned in the CXone admin console. - Code adjustment: The
CxoneAuthclass automatically refreshes tokens before expiry. If 401 persists, clear cached credentials and regenerate the client secret.
Error: 403 Forbidden
- Cause: User role lacks permission to access restricted recordings or the OAuth client lacks scope.
- Fix: Check the
privacyfield in the recording metadata. Restricted recordings require a supervisor role. Addrecording:downloadto your OAuth scope if missing.
Error: 429 Too Many Requests
- Cause: Exceeding CXone API rate limits during polling or concurrent downloads.
- Fix: Increase jitter range in the polling loop. Implement a token bucket rate limiter for downstream calls. The current jittered exponential backoff mitigates most burst scenarios.
Error: FFmpeg conversion failed
- Cause: Missing
ffmpegbinary or unsupported input codec. - Fix: Install FFmpeg via your package manager (
apt install ffmpegorbrew install ffmpeg). Verify the binary is in$PATH. CXone returns PCM WAV files, which FFmpeg handles natively.
Error: Content-Range header missing
- Cause: CXone endpoint fallback to full download or proxy stripping headers.
- Fix: Ensure
Accept-Encoding: identityis set to prevent intermediate proxies from compressing the response. If the header remains absent, remove the Range header and stream the full payload, though this increases memory usage.