Bulk-Update Agent Skill Proficiencies via NICE CXone REST API
What You Will Build
- A script that retrieves a list of agents and updates their skill proficiency levels in bulk using the NICE CXone Administration API.
- This tutorial utilizes the NICE CXone
adminREST API endpoints for users and skills. - The primary programming language covered is Python 3.9+ using the
requestslibrary.
Prerequisites
- OAuth Client Type: Service Account (Client Credentials Grant) is recommended for backend automation scripts.
- Required Scopes:
admin:user:read(to list agents)admin:user:write(to update agent profiles)admin:skill:read(to fetch skill IDs if not known)admin:site:read(often required for context in multi-site deployments)
- SDK Version: No official SDK is required for this specific task; raw REST calls via
requestsprovide the most control over bulk operations and error handling. - Language/Runtime: Python 3.9 or higher.
- External Dependencies:
requests: For HTTP communication.python-dotenv: For secure environment variable management.tenacity: For robust retry logic on rate limits.
Install dependencies:
pip install requests python-dotenv tenacity
Authentication Setup
NICE CXone uses OAuth 2.0 for authentication. For server-to-server integration, the Client Credentials flow is the standard. You must obtain an access token before making any API calls. The token expires after a short duration (typically 1 hour), so your application should handle token refresh or re-authentication.
The following Python class handles authentication and provides a method to get a valid token.
import os
import requests
from dotenv import load_dotenv
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
load_dotenv()
class CXoneAuth:
def __init__(self, domain: str, client_id: str, client_secret: str):
self.domain = domain
self.client_id = client_id
self.client_secret = client_secret
self.token_url = f"https://{domain}/api/auth/oauth2/token"
self.access_token = None
self.token_expiry = 0
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=2, max=10),
retry=retry_if_exception_type(requests.exceptions.RequestException)
)
def get_token(self) -> str:
"""
Retrieves an OAuth2 access token using client credentials.
Caches the token if it is not expired.
"""
import time
# Simple cache check. In production, use a proper cache with expiration.
if self.access_token and time.time() < self.token_expiry:
return self.access_token
headers = {
"Content-Type": "application/x-www-form-urlencoded"
}
data = {
"grant_type": "client_credentials",
"client_id": self.client_id,
"client_secret": self.client_secret
}
try:
response = requests.post(self.token_url, headers=headers, data=data)
response.raise_for_status()
token_data = response.json()
self.access_token = token_data["access_token"]
# Set expiry slightly before actual expiry to avoid edge cases
self.token_expiry = time.time() + token_data.get("expires_in", 3600) - 60
return self.access_token
except requests.exceptions.HTTPError as e:
print(f"Authentication failed: {e.response.status_code} - {e.response.text}")
raise
# Usage initialization
auth = CXoneAuth(
domain=os.getenv("CXONE_DOMAIN"),
client_id=os.getenv("CXONE_CLIENT_ID"),
client_secret=os.getenv("CXONE_CLIENT_SECRET")
)
Implementation
Step 1: Retrieve Target Skills and Agents
Before updating proficiencies, you must identify the skillId for the skills you intend to update and the userId for the agents. CXone IDs are UUIDs. Hardcoding IDs is fragile; querying them dynamically is safer.
First, we fetch the skills. We assume you know the skill name (e.g., “Spanish - Fluent”).
import requests
def get_skill_id_by_name(auth: CXoneAuth, skill_name: str) -> str:
"""
Fetches the UUID of a skill by its name.
Endpoint: GET /api/v2/admin/skills
Scope: admin:skill:read
"""
url = f"https://{auth.domain}/api/v2/admin/skills"
token = auth.get_token()
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
try:
response = requests.get(url, headers=headers)
response.raise_for_status()
skills = response.json().get("skills", [])
for skill in skills:
if skill.get("name").lower() == skill_name.lower():
return skill.get("skillId")
return None
except requests.exceptions.HTTPError as e:
print(f"Error fetching skills: {e.response.status_code}")
raise
# Example usage
spanish_skill_id = get_skill_id_by_name(auth, "Spanish - Fluent")
if not spanish_skill_id:
raise ValueError("Skill 'Spanish - Fluent' not found.")
Next, we retrieve the list of agents. In CXone, agents are represented as users with a specific userType (usually agent). We will filter for active agents.
def get_active_agents(auth: CXoneAuth, limit: int = 100) -> list:
"""
Fetches a list of active agents.
Endpoint: GET /api/v2/admin/users
Scope: admin:user:read
"""
url = f"https://{auth.domain}/api/v2/admin/users"
token = auth.get_token()
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
params = {
"userType": "agent",
"limit": limit
}
try:
response = requests.get(url, headers=headers, params=params)
response.raise_for_status()
users = response.json().get("users", [])
return users
except requests.exceptions.HTTPError as e:
print(f"Error fetching agents: {e.response.status_code}")
raise
agents = get_active_agents(auth, limit=50)
print(f"Retrieved {len(agents)} agents.")
Step 2: Construct the Update Payload
The CXone API does not have a single “bulk update proficiencies” endpoint. You must update each user individually via PUT /api/v2/admin/users/{userId}. However, to minimize API calls and avoid overwriting other user data, you must:
- Fetch the current user profile (to get the current
versionnumber and existing skills). - Modify the
skillsarray in the profile. - Send the updated profile back with the correct
versionnumber to prevent race conditions.
The version field is critical. CXone uses optimistic locking. If you submit a PUT request with an old version, the API will reject it with a 409 Conflict.
def update_agent_proficiency(auth: CXoneAuth, user_id: str, skill_id: str, proficiency_level: int, skill_name: str) -> bool:
"""
Updates a single agent's proficiency for a specific skill.
Endpoint: PUT /api/v2/admin/users/{userId}
Scope: admin:user:write
Args:
user_id: The UUID of the agent.
skill_id: The UUID of the skill.
proficiency_level: 1-5 (1=Low, 5=High).
skill_name: Name of the skill for logging purposes.
Returns:
True if successful, False otherwise.
"""
token = auth.get_token()
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
# Step 2a: Get current user profile to preserve version and other data
get_url = f"https://{auth.domain}/api/v2/admin/users/{user_id}"
try:
get_response = requests.get(get_url, headers=headers)
get_response.raise_for_status()
user_data = get_response.json()
except requests.exceptions.HTTPError as e:
print(f"Failed to fetch user {user_id}: {e.response.status_code}")
return False
# Step 2b: Modify the skills array
# The user object contains a 'skills' array. Each item has 'skillId' and 'proficiency'
skills = user_data.get("skills", [])
skill_updated = False
for skill in skills:
if skill.get("skillId") == skill_id:
skill["proficiency"] = proficiency_level
skill_updated = True
break
if not skill_updated:
# If the skill is not in the list, add it
new_skill = {
"skillId": skill_id,
"proficiency": proficiency_level
}
skills.append(new_skill)
user_data["skills"] = skills
# Step 2c: Prepare the PUT payload
# Important: We must keep the 'version' field from the GET response
payload = {
"version": user_data.get("version"),
"userId": user_data.get("userId"),
"userName": user_data.get("userName"),
"firstName": user_data.get("firstName"),
"lastName": user_data.get("lastName"),
"email": user_data.get("email"),
"userType": user_data.get("userType"),
"skills": user_data.get("skills"),
# Include other necessary fields if your tenant requires them
# e.g., "groups", "roles", etc. Omitting them might default to empty depending on API behavior.
# It is safer to only send fields you are changing + version + userId.
# However, CXone PUT /users often requires the full object or at least the core identity fields.
}
# Step 2d: Send the update
try:
put_response = requests.put(get_url, headers=headers, json=payload)
if put_response.status_code == 200:
print(f"Successfully updated {user_data.get('userName')} for {skill_name}")
return True
elif put_response.status_code == 409:
print(f"Conflict updating {user_id}. Version mismatch. User data may have changed.")
return False
else:
print(f"Failed to update {user_id}: {put_response.status_code} - {put_response.text}")
return False
except requests.exceptions.RequestException as e:
print(f"Network error updating {user_id}: {e}")
return False
Step 3: Process Results and Handle Rate Limits
When updating many agents, you will hit rate limits. The CXone API returns 429 Too Many Requests with a Retry-After header. We will use the tenacity library to handle retries automatically.
We will also implement a small delay between requests to be polite to the API, even if we are not rate-limited.
import time
from tenacity import retry, stop_after_attempt, wait_random_exponential, retry_if_exception_type
@retry(
stop=stop_after_attempt(5),
wait=wait_random_exponential(multiplier=1, max=60),
retry=retry_if_exception_type(requests.exceptions.HTTPError),
reraise=True
)
def safe_update_agent(auth: CXoneAuth, user_id: str, skill_id: str, proficiency: int, skill_name: str) -> bool:
"""
Wrapper for update_agent_proficiency that handles retries on 429 errors.
"""
# Check for 429 manually before calling the main function to allow custom retry logic if needed
# However, tenacity handles the retry loop. We need to ensure the inner function raises the exception.
# Re-implementing the core logic inside the retry decorator scope for simplicity
token = auth.get_token()
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
get_url = f"https://{auth.domain}/api/v2/admin/users/{user_id}"
# Fetch current data
get_response = requests.get(get_url, headers=headers)
if get_response.status_code == 429:
retry_after = int(get_response.headers.get('Retry-After', 5))
raise requests.exceptions.HTTPError(f"Rate limited. Retry after {retry_after}s", response=get_response)
get_response.raise_for_status()
user_data = get_response.json()
# Modify skills
skills = user_data.get("skills", [])
skill_updated = False
for skill in skills:
if skill.get("skillId") == skill_id:
skill["proficiency"] = proficiency
skill_updated = True
break
if not skill_updated:
skills.append({"skillId": skill_id, "proficiency": proficiency})
user_data["skills"] = skills
payload = {
"version": user_data.get("version"),
"userId": user_data.get("userId"),
"userName": user_data.get("userName"),
"firstName": user_data.get("firstName"),
"lastName": user_data.get("lastName"),
"email": user_data.get("email"),
"userType": user_data.get("userType"),
"skills": user_data.get("skills"),
}
# Update
put_response = requests.put(get_url, headers=headers, json=payload)
if put_response.status_code == 429:
retry_after = int(put_response.headers.get('Retry-After', 5))
raise requests.exceptions.HTTPError(f"Rate limited on PUT. Retry after {retry_after}s", response=put_response)
if put_response.status_code == 409:
# Version conflict. In a bulk job, we might want to log this and skip, or retry the GET.
# For this tutorial, we treat 409 as a non-retryable error for this specific item.
print(f"Conflict (409) for user {user_id}. Skipping.")
return False
put_response.raise_for_status()
print(f"Updated: {user_data.get('userName')} ({user_id})")
return True
# Bulk Execution Loop
def bulk_update_agents(auth: CXoneAuth, agents: list, skill_id: str, proficiency: int, skill_name: str):
"""
Iterates through agents and updates proficiencies.
"""
success_count = 0
fail_count = 0
for agent in agents:
user_id = agent.get("userId")
user_name = agent.get("userName")
try:
# Add a small delay between requests to stay within rate limits proactively
time.sleep(0.5)
if safe_update_agent(auth, user_id, skill_id, proficiency, skill_name):
success_count += 1
else:
fail_count += 1
except Exception as e:
print(f"Unexpected error updating {user_name}: {e}")
fail_count += 1
print(f"--- Bulk Update Complete ---")
print(f"Success: {success_count}")
print(f"Failed: {fail_count}")
# Run the bulk update
if spanish_skill_id:
bulk_update_agents(auth, agents, spanish_skill_id, proficiency=5, skill_name="Spanish - Fluent")
Complete Working Example
Below is the consolidated, runnable script. Save this as bulk_update_skills.py.
import os
import time
import requests
from dotenv import load_dotenv
from tenacity import retry, stop_after_attempt, wait_random_exponential, retry_if_exception_type
load_dotenv()
class CXoneAuth:
def __init__(self, domain: str, client_id: str, client_secret: str):
self.domain = domain
self.client_id = client_id
self.client_secret = client_secret
self.token_url = f"https://{domain}/api/auth/oauth2/token"
self.access_token = None
self.token_expiry = 0
@retry(
stop=stop_after_attempt(3),
wait=wait_random_exponential(multiplier=1, min=2, max=10),
retry=retry_if_exception_type(requests.exceptions.RequestException)
)
def get_token(self) -> str:
import time
if self.access_token and time.time() < self.token_expiry:
return self.access_token
headers = {"Content-Type": "application/x-www-form-urlencoded"}
data = {
"grant_type": "client_credentials",
"client_id": self.client_id,
"client_secret": self.client_secret
}
response = requests.post(self.token_url, headers=headers, data=data)
response.raise_for_status()
token_data = response.json()
self.access_token = token_data["access_token"]
self.token_expiry = time.time() + token_data.get("expires_in", 3600) - 60
return self.access_token
def get_skill_id_by_name(auth: CXoneAuth, skill_name: str) -> str:
url = f"https://{auth.domain}/api/v2/admin/skills"
token = auth.get_token()
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
response = requests.get(url, headers=headers)
response.raise_for_status()
skills = response.json().get("skills", [])
for skill in skills:
if skill.get("name").lower() == skill_name.lower():
return skill.get("skillId")
return None
def get_active_agents(auth: CXoneAuth, limit: int = 100) -> list:
url = f"https://{auth.domain}/api/v2/admin/users"
token = auth.get_token()
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
params = {"userType": "agent", "limit": limit}
response = requests.get(url, headers=headers, params=params)
response.raise_for_status()
return response.json().get("users", [])
@retry(
stop=stop_after_attempt(5),
wait=wait_random_exponential(multiplier=1, max=60),
retry=retry_if_exception_type(requests.exceptions.HTTPError),
reraise=True
)
def safe_update_agent(auth: CXoneAuth, user_id: str, skill_id: str, proficiency: int, skill_name: str) -> bool:
token = auth.get_token()
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
get_url = f"https://{auth.domain}/api/v2/admin/users/{user_id}"
# GET current user
get_response = requests.get(get_url, headers=headers)
if get_response.status_code == 429:
raise requests.exceptions.HTTPError(f"Rate limited. Retry after {get_response.headers.get('Retry-After', 5)}s", response=get_response)
get_response.raise_for_status()
user_data = get_response.json()
# Modify skills
skills = user_data.get("skills", [])
skill_updated = False
for skill in skills:
if skill.get("skillId") == skill_id:
skill["proficiency"] = proficiency
skill_updated = True
break
if not skill_updated:
skills.append({"skillId": skill_id, "proficiency": proficiency})
user_data["skills"] = skills
# Payload construction
payload = {
"version": user_data.get("version"),
"userId": user_data.get("userId"),
"userName": user_data.get("userName"),
"firstName": user_data.get("firstName"),
"lastName": user_data.get("lastName"),
"email": user_data.get("email"),
"userType": user_data.get("userType"),
"skills": user_data.get("skills"),
}
# PUT update
put_response = requests.put(get_url, headers=headers, json=payload)
if put_response.status_code == 429:
raise requests.exceptions.HTTPError(f"Rate limited on PUT. Retry after {put_response.headers.get('Retry-After', 5)}s", response=put_response)
if put_response.status_code == 409:
print(f"Conflict (409) for user {user_id}. Skipping.")
return False
put_response.raise_for_status()
print(f"Updated: {user_data.get('userName')} ({user_id})")
return True
def main():
auth = CXoneAuth(
domain=os.getenv("CXONE_DOMAIN"),
client_id=os.getenv("CXONE_CLIENT_ID"),
client_secret=os.getenv("CXONE_CLIENT_SECRET")
)
skill_name = "Spanish - Fluent"
target_proficiency = 5
print(f"Fetching skill ID for: {skill_name}")
skill_id = get_skill_id_by_name(auth, skill_name)
if not skill_id:
print(f"Error: Skill '{skill_name}' not found.")
return
print(f"Fetching active agents...")
agents = get_active_agents(auth, limit=100)
print(f"Found {len(agents)} agents.")
print(f"Starting bulk update of {len(agents)} agents to proficiency {target_proficiency}...")
success_count = 0
fail_count = 0
for agent in agents:
user_id = agent.get("userId")
try:
time.sleep(0.5) # Polite delay
if safe_update_agent(auth, user_id, skill_id, target_proficiency, skill_name):
success_count += 1
else:
fail_count += 1
except Exception as e:
print(f"Failed to process agent {agent.get('userName')}: {e}")
fail_count += 1
print(f"--- Complete ---")
print(f"Success: {success_count}, Failed: {fail_count}")
if __name__ == "__main__":
main()
Common Errors & Debugging
Error: 409 Conflict
- What causes it: The
versionfield in yourPUTpayload does not match the currentversionof the user record in CXone. This happens if another process (or admin) updated the user between yourGETandPUTcalls. - How to fix it: The provided code treats this as a skip. For critical updates, implement a retry loop that performs a fresh
GETbefore the nextPUTattempt. Ensure you are passing theversionfield exactly as returned by theGETrequest.
Error: 429 Too Many Requests
- What causes it: You have exceeded the rate limit for the
admin/userendpoints. CXone enforces strict rate limits to protect platform stability. - How to fix it: The code uses
tenacityto retry with exponential backoff. Additionally, thetime.sleep(0.5)in the main loop reduces the request rate proactively. If you still hit 429s, increase the sleep duration or reduce thelimitinget_active_agentsto process smaller batches.
Error: 403 Forbidden
- What causes it: The OAuth token does not have the required scopes (
admin:user:writeoradmin:user:read). - How to fix it: Verify your Service Account configuration in the CXone Admin UI. Ensure the Client ID and Secret match the Service Account that has been granted the necessary permissions.
Error: 400 Bad Request
- What causes it: The JSON payload is malformed or missing required fields. Specifically, omitting the
versionfield or sending an invalidproficiencylevel (must be 1-5) will cause this. - How to fix it: Ensure the
proficiencyinteger is between 1 and 5. Verify that theuserIdandskillIdare valid UUIDs.