CXone Reporting API (v2) — How to Query Agent State History for the Last 24 Hours

CXone Reporting API (v2) — How to Query Agent State History for the Last 24 Hours

What You Will Build

  • A script that queries the NICE CXone Reporting API to retrieve the state history (e.g., Ready, Not Ready, Wrap-up) for a specific agent over the previous 24 hours.
  • This implementation uses the GET /api/v2/reporting/agents/statehistory endpoint from the CXone API.
  • The programming language covered is Python 3.10+ using the requests library for HTTP interactions.

Prerequisites

To execute this code, you must have the following resources and configurations:

  • NICE CXone Tenant Access: You need a user account with API access enabled.
  • API Credentials: You must have an API Key (api_key) and API Secret (api_secret) generated from the CXone Administration console. Alternatively, you can use OAuth 2.0 Client Credentials, but API Key/Secret is simpler for server-to-server scripts.
  • Required Scope: The user or API key must have the Reporting scope enabled. Specifically, you need read access to reporting data.
  • Python Environment: Python 3.10 or higher installed.
  • External Dependencies: The requests library. Install it via pip:
pip install requests

Authentication Setup

NICE CXone supports two primary authentication methods: OAuth 2.0 and API Key/Secret. For reporting scripts that run on a schedule or server-side, API Key/Secret is often preferred because it avoids the overhead of token refresh cycles for simple read operations. However, the Reporting API v2 strictly requires a valid Authorization header.

Below is the setup for generating a bearer token using the OAuth 2.0 Client Credentials flow, which is the most robust method for production integrations. If you are using API Keys, you would typically pass them in the header as Authorization: Basic <base64(api_key:api_secret)> or include them in the request body depending on the specific endpoint version, but CXone v2 Reporting generally expects a Bearer token derived from the OAuth flow or an API Key that maps to a token.

Note: Many CXone tenants allow direct API Key usage in the header for v1, but v2 reporting endpoints often enforce OAuth. The code below uses the standard OAuth token endpoint.

import requests
import base64
import time
from typing import Optional

class CXoneAuth:
    """
    Handles OAuth 2.0 Token acquisition for NICE CXone.
    """
    def __init__(self, api_key: str, api_secret: str, tenant_url: str):
        self.api_key = api_key
        self.api_secret = api_secret
        # Ensure tenant_url ends with a slash for concatenation safety
        self.tenant_url = tenant_url.rstrip('/') + '/'
        self.token_endpoint = f"{self.tenant_url}api/v2/oauth/token"
        self.access_token: Optional[str] = None
        self.token_expiry: float = 0

    def get_token(self) -> str:
        """
        Retrieves an OAuth access token.
        Returns the token string. Raises an exception if authentication fails.
        """
        # Check if we have a valid cached token
        if self.access_token and time.time() < self.token_expiry:
            return self.access_token

        # Prepare credentials
        credentials = f"{self.api_key}:{self.api_secret}"
        encoded_credentials = base64.b64encode(credentials.encode('utf-8')).decode('utf-8')

        headers = {
            "Authorization": f"Basic {encoded_credentials}",
            "Content-Type": "application/x-www-form-urlencoded"
        }

        data = {
            "grant_type": "client_credentials",
            "scope": "reporting"
        }

        try:
            response = requests.post(
                self.token_endpoint,
                headers=headers,
                data=data,
                timeout=10
            )
            response.raise_for_status()
        except requests.exceptions.HTTPError as e:
            if response.status_code == 401:
                raise Exception("Authentication failed: Invalid API Key or Secret.") from e
            elif response.status_code == 403:
                raise Exception("Authentication failed: API Key does not have 'reporting' scope.") from e
            else:
                raise Exception(f"Authentication request failed with status {response.status_code}: {response.text}") from e
        except requests.exceptions.RequestException as e:
            raise Exception(f"Network error during authentication: {e}") from e

        token_data = response.json()
        self.access_token = token_data.get("access_token")
        # Tokens usually expire in 3600 seconds (1 hour). We subtract 60s for safety.
        self.token_expiry = time.time() + (token_data.get("expires_in", 3600) - 60)

        return self.access_token

Implementation

Step 1: Constructing the Query Parameters

The CXone Reporting API uses ISO 8601 date formats for time ranges. To query the last 24 hours, we must calculate the start and end timestamps dynamically. The from and to parameters are inclusive.

We also need the Agent ID. In CXone, this is typically the numeric ID associated with the user, not the email or name. If you do not have the ID, you must first query the User Management API (GET /api/v2/users) to map the name/email to the ID. For this tutorial, we assume the Agent ID is known.

from datetime import datetime, timedelta, timezone

def get_last_24_hours_range() -> tuple[str, str]:
    """
    Calculates the ISO 8601 start and end timestamps for the last 24 hours.
    CXone API expects UTC time.
    """
    now = datetime.now(timezone.utc)
    start_time = now - timedelta(hours=24)
    
    # Format as ISO 8601 with timezone offset
    # Example: 2023-10-27T14:30:00+00:00
    start_iso = start_time.isoformat()
    end_iso = now.isoformat()
    
    return start_iso, end_iso

# Example usage
start_ts, end_ts = get_last_24_hours_range()
print(f"Querying from: {start_ts}")
print(f"Querying to:   {end_ts}")

Step 2: Core Logic — Executing the State History Query

The endpoint GET /api/v2/reporting/agents/statehistory returns a list of state transitions. It supports pagination via the pageSize and pageNumber parameters, or cursor-based pagination depending on the specific tenant configuration. We will implement a loop to handle pagination to ensure we capture all states for the 24-hour period.

Required OAuth Scope: reporting

Endpoint: GET /api/v2/reporting/agents/statehistory

Parameters:

  • agentId (Required): The numeric ID of the agent.
  • from (Required): Start timestamp (ISO 8601).
  • to (Required): End timestamp (ISO 8601).
  • pageSize (Optional): Number of records per page. Default is often 200. Max is usually 1000.
  • pageNumber (Optional): Page number (1-based).
import json

def fetch_agent_state_history(
    auth: CXoneAuth, 
    tenant_url: str, 
    agent_id: int, 
    start_time: str, 
    end_time: str, 
    page_size: int = 500
) -> list[dict]:
    """
    Fetches all agent state history records for the given time range.
    Handles pagination automatically.
    """
    base_url = f"{tenant_url.rstrip('/')}/api/v2/reporting/agents/statehistory"
    all_records = []
    page_number = 1
    has_more_pages = True

    while has_more_pages:
        params = {
            "agentId": agent_id,
            "from": start_time,
            "to": end_time,
            "pageSize": page_size,
            "pageNumber": page_number
        }

        headers = {
            "Authorization": f"Bearer {auth.get_token()}",
            "Accept": "application/json"
        }

        try:
            response = requests.get(
                base_url,
                headers=headers,
                params=params,
                timeout=30
            )
            
            # Handle Rate Limiting (429)
            if response.status_code == 429:
                retry_after = int(response.headers.get("Retry-After", 5))
                print(f"Rate limited. Waiting {retry_after} seconds...")
                time.sleep(retry_after)
                continue # Retry the same page

            response.raise_for_status()
            
        except requests.exceptions.HTTPError as e:
            if response.status_code == 401:
                # Token might have expired, try refreshing
                auth.access_token = None 
                continue 
            elif response.status_code == 404:
                raise Exception(f"Agent ID {agent_id} not found or no data available.") from e
            else:
                raise Exception(f"API Error: {response.status_code} - {response.text}") from e
        except requests.exceptions.RequestException as e:
            raise Exception(f"Network error: {e}") from e

        data = response.json()
        
        # CXone v2 Reporting usually returns a list directly or an object with 'items'
        # The specific structure for statehistory is often a list of objects.
        # Let's normalize the response.
        records = data if isinstance(data, list) else data.get("items", [])
        
        if not records:
            has_more_pages = False
            break
        
        all_records.extend(records)
        
        # Check if there are more pages
        # CXone often returns totalResults or a link for next page. 
        # If the number of records returned is less than page_size, we are at the end.
        if len(records) < page_size:
            has_more_pages = False
        else:
            page_number += 1

    return all_records

Step 3: Processing Results

The raw JSON response from CXone contains technical fields like stateId, code, description, and duration. To make this data useful, we should map the state IDs to human-readable labels and calculate the total time spent in each state.

Common State IDs in CXone:

  • 1: Ready (Available)
  • 2: Not Ready (Break, Meeting, etc.)
  • 3: Wrap-up
  • 4: In Call (Active Interaction)

Note: State IDs can vary by tenant configuration. It is best practice to fetch the stateCode or description field if available.

def analyze_state_history(records: list[dict]) -> dict:
    """
    Processes the raw state history records to provide a summary.
    """
    summary = {
        "total_records": len(records),
        "state_durations": {}, # State ID -> Total seconds
        "transitions": []
    }

    for record in records:
        state_id = record.get("stateId")
        duration = record.get("duration", 0) # Duration is in seconds
        start_time = record.get("startTime")
        end_time = record.get("endTime")
        description = record.get("description", "Unknown")

        if state_id:
            if state_id not in summary["state_durations"]:
                summary["state_durations"][state_id] = 0
            summary["state_durations"][state_id] += duration

        summary["transitions"].append({
            "state_id": state_id,
            "description": description,
            "duration_sec": duration,
            "start": start_time,
            "end": end_time
        })

    return summary

def print_summary(summary: dict):
    """
    Prints a formatted summary of the agent's activity.
    """
    print(f"\n--- Agent State Summary ---")
    print(f"Total State Changes: {summary['total_records']}")
    print("\nTime Spent by State:")
    
    # Sort by duration descending
    sorted_states = sorted(summary['state_durations'].items(), key=lambda x: x[1], reverse=True)
    
    for state_id, total_seconds in sorted_states:
        minutes = total_seconds / 60
        hours = total_seconds / 3600
        print(f"  State ID {state_id}: {hours:.2f} hours ({minutes:.1f} minutes)")
    
    print("\nDetailed Transitions (Last 5):")
    for t in summary['transitions'][-5:]:
        print(f"  [{t['start']}] -> State {t['state_id']} ({t['description']}) for {t['duration_sec']}s")

Complete Working Example

Below is the complete, copy-pasteable script. Replace the API_KEY, API_SECRET, TENANT_URL, and AGENT_ID variables with your actual values.

#!/usr/bin/env python3
"""
CXone Agent State History Reporter
Queries the last 24 hours of state history for a specific agent.
"""

import requests
import base64
import time
import json
from datetime import datetime, timedelta, timezone
from typing import Optional, List, Dict

# --- Configuration ---
API_KEY = "YOUR_API_KEY_HERE"
API_SECRET = "YOUR_API_SECRET_HERE"
TENANT_URL = "https://your-tenant.niceincontact.com" # Replace with your tenant URL
AGENT_ID = 123456 # Replace with the numeric Agent ID

# --- Authentication Class ---
class CXoneAuth:
    def __init__(self, api_key: str, api_secret: str, tenant_url: str):
        self.api_key = api_key
        self.api_secret = api_secret
        self.tenant_url = tenant_url.rstrip('/') + '/'
        self.token_endpoint = f"{self.tenant_url}api/v2/oauth/token"
        self.access_token: Optional[str] = None
        self.token_expiry: float = 0

    def get_token(self) -> str:
        if self.access_token and time.time() < self.token_expiry:
            return self.access_token

        credentials = f"{self.api_key}:{self.api_secret}"
        encoded_credentials = base64.b64encode(credentials.encode('utf-8')).decode('utf-8')

        headers = {
            "Authorization": f"Basic {encoded_credentials}",
            "Content-Type": "application/x-www-form-urlencoded"
        }

        data = {
            "grant_type": "client_credentials",
            "scope": "reporting"
        }

        try:
            response = requests.post(
                self.token_endpoint,
                headers=headers,
                data=data,
                timeout=10
            )
            response.raise_for_status()
        except requests.exceptions.HTTPError as e:
            if response.status_code == 401:
                raise Exception("Authentication failed: Invalid API Key or Secret.") from e
            elif response.status_code == 403:
                raise Exception("Authentication failed: API Key does not have 'reporting' scope.") from e
            else:
                raise Exception(f"Authentication request failed with status {response.status_code}: {response.text}") from e
        except requests.exceptions.RequestException as e:
            raise Exception(f"Network error during authentication: {e}") from e

        token_data = response.json()
        self.access_token = token_data.get("access_token")
        self.token_expiry = time.time() + (token_data.get("expires_in", 3600) - 60)

        return self.access_token

# --- Helper Functions ---

def get_last_24_hours_range() -> tuple[str, str]:
    now = datetime.now(timezone.utc)
    start_time = now - timedelta(hours=24)
    return start_time.isoformat(), now.isoformat()

def fetch_agent_state_history(
    auth: CXoneAuth, 
    tenant_url: str, 
    agent_id: int, 
    start_time: str, 
    end_time: str, 
    page_size: int = 500
) -> List[Dict]:
    base_url = f"{tenant_url.rstrip('/')}/api/v2/reporting/agents/statehistory"
    all_records = []
    page_number = 1
    has_more_pages = True

    while has_more_pages:
        params = {
            "agentId": agent_id,
            "from": start_time,
            "to": end_time,
            "pageSize": page_size,
            "pageNumber": page_number
        }

        headers = {
            "Authorization": f"Bearer {auth.get_token()}",
            "Accept": "application/json"
        }

        try:
            response = requests.get(
                base_url,
                headers=headers,
                params=params,
                timeout=30
            )
            
            if response.status_code == 429:
                retry_after = int(response.headers.get("Retry-After", 5))
                print(f"Rate limited. Waiting {retry_after} seconds...")
                time.sleep(retry_after)
                continue 

            response.raise_for_status()
            
        except requests.exceptions.HTTPError as e:
            if response.status_code == 401:
                auth.access_token = None 
                continue 
            elif response.status_code == 404:
                raise Exception(f"Agent ID {agent_id} not found or no data available.") from e
            else:
                raise Exception(f"API Error: {response.status_code} - {response.text}") from e
        except requests.exceptions.RequestException as e:
            raise Exception(f"Network error: {e}") from e

        data = response.json()
        records = data if isinstance(data, list) else data.get("items", [])
        
        if not records:
            has_more_pages = False
            break
        
        all_records.extend(records)
        
        if len(records) < page_size:
            has_more_pages = False
        else:
            page_number += 1

    return all_records

def analyze_state_history(records: List[Dict]) -> Dict:
    summary = {
        "total_records": len(records),
        "state_durations": {},
        "transitions": []
    }

    for record in records:
        state_id = record.get("stateId")
        duration = record.get("duration", 0)
        start_time = record.get("startTime")
        end_time = record.get("endTime")
        description = record.get("description", "Unknown")

        if state_id:
            if state_id not in summary["state_durations"]:
                summary["state_durations"][state_id] = 0
            summary["state_durations"][state_id] += duration

        summary["transitions"].append({
            "state_id": state_id,
            "description": description,
            "duration_sec": duration,
            "start": start_time,
            "end": end_time
        })

    return summary

def print_summary(summary: Dict):
    print(f"\n--- Agent State Summary ---")
    print(f"Total State Changes: {summary['total_records']}")
    print("\nTime Spent by State:")
    
    sorted_states = sorted(summary['state_durations'].items(), key=lambda x: x[1], reverse=True)
    
    for state_id, total_seconds in sorted_states:
        minutes = total_seconds / 60
        hours = total_seconds / 3600
        print(f"  State ID {state_id}: {hours:.2f} hours ({minutes:.1f} minutes)")
    
    print("\nDetailed Transitions (Last 5):")
    for t in summary['transitions'][-5:]:
        print(f"  [{t['start']}] -> State {t['state_id']} ({t['description']}) for {t['duration_sec']}s")

# --- Main Execution ---

if __name__ == "__main__":
    if API_KEY == "YOUR_API_KEY_HERE":
        print("Error: Please update the API_KEY, API_SECRET, TENANT_URL, and AGENT_ID variables in the script.")
        exit(1)

    try:
        # 1. Initialize Auth
        auth = CXoneAuth(API_KEY, API_SECRET, TENANT_URL)
        
        # 2. Get Time Range
        start_time, end_time = get_last_24_hours_range()
        print(f"Fetching state history for Agent {AGENT_ID} from {start_time} to {end_time}")

        # 3. Fetch Data
        records = fetch_agent_state_history(auth, TENANT_URL, AGENT_ID, start_time, end_time)
        
        # 4. Analyze and Print
        summary = analyze_state_history(records)
        print_summary(summary)

    except Exception as e:
        print(f"An error occurred: {e}")
        import traceback
        traceback.print_exc()

Common Errors & Debugging

Error: 401 Unauthorized

Cause: The API Key/Secret is invalid, or the OAuth token has expired and was not refreshed.
Fix:

  1. Verify the API Key and Secret in the CXone Admin Console under Integrations > API Keys.
  2. Ensure the CXoneAuth class is correctly encoding the credentials.
  3. If using a long-running process, ensure the token_expiry logic is working. The code above resets the token if 401 is received.

Error: 403 Forbidden

Cause: The API Key does not have the reporting scope.
Fix:

  1. Go to Integrations > API Keys in CXone Admin.
  2. Edit the API Key.
  3. Ensure the Reporting scope is checked.
  4. Save and regenerate the secret if necessary (some tenants require regeneration when scopes change).

Error: 429 Too Many Requests

Cause: You have exceeded the rate limit for the Reporting API. CXone imposes strict rate limits on reporting endpoints to protect data warehouse performance.
Fix:

  1. The code includes automatic retry logic with Retry-After header parsing.
  2. Reduce the page_size if you are making many small requests.
  3. Increase the time between polling intervals if this script is scheduled.

Error: Empty Result List

Cause: The Agent ID is incorrect, or the agent was not logged in during the last 24 hours.
Fix:

  1. Verify the AGENT_ID is the numeric ID, not the email. Use GET /api/v2/users to search for the user by email to get the correct ID.
  2. Check if the agent was actually logged in. If the agent never logged in, no state history exists.

Official References