De-Provision NICE CXone Users via SCIM API with Python
What You Will Build
This tutorial builds a production-ready Python module that asynchronously de-provisions NICE CXone users by constructing deletion payloads, terminating active sessions, reclaiming licenses, and synchronizing lifecycle events with external HR systems. The implementation uses the NICE CXone SCIM 2.0 API, REST provisioning endpoints, and asynchronous job orchestration to handle dependency cleanup and audit logging. Python 3.9+ with httpx and pydantic provides the runtime foundation.
Prerequisites
- NICE CXone OAuth 2.0 Client Credentials grant with scopes:
provisioning:write,users:delete,sessions:manage,license:read - Python 3.9 or higher
- External dependencies:
httpx,pydantic,python-dotenv,orjson - Tenant domain format:
{tenant}.nicecxone.com - Valid external user identifier mapped in the CXone provisioning engine
Authentication Setup
NICE CXone uses standard OAuth 2.0 for API authentication. The client must request a bearer token from the authentication endpoint before issuing SCIM or REST calls. Token caching prevents unnecessary authentication requests and reduces rate-limit exposure.
import httpx
import asyncio
import time
from typing import Optional
class CXoneAuthManager:
def __init__(self, client_id: str, client_secret: str, auth_url: str):
self.client_id = client_id
self.client_secret = client_secret
self.auth_url = auth_url
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(
self.auth_url,
data={
"grant_type": "client_credentials",
"client_id": self.client_id,
"client_secret": self.client_secret,
"scope": "provisioning:write users:delete sessions:manage license:read"
}
)
response.raise_for_status()
payload = response.json()
self.token = payload["access_token"]
self.token_expiry = time.time() + payload["expires_in"] - 30
return self.token
The get_token method caches the token and applies a thirty-second safety margin before expiration. The scope string explicitly requests provisioning, user deletion, session management, and license read permissions. Every subsequent API call will attach this token to the Authorization header.
Implementation
Step 1: HTTP Client Configuration with Retry Logic
Rate limiting is common during bulk de-provisioning operations. The HTTP client must implement exponential backoff for 429 Too Many Requests responses and validate TLS certificates against the NICE CXone certificate authority.
import httpx
from httpx import RetryTransport, Limits
def create_cxone_client(base_url: str, token: str) -> httpx.AsyncClient:
transport = RetryTransport(
max_retries=3,
retry_on_status_codes={429},
backoff_factor=0.5,
limits=Limits(max_connections=10, max_keepalive_connections=5)
)
return httpx.AsyncClient(
base_url=base_url,
transport=transport,
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/scim+json",
"Accept": "application/json"
},
timeout=httpx.Timeout(30.0, connect=10.0)
)
The RetryTransport automatically retries failed requests when the server returns a 429 status. The client sets application/scim+json as the default content type for SCIM operations and falls back to application/json for REST endpoints. Connection limits prevent socket exhaustion during parallel job orchestration.
Step 2: Construct Deletion Payload and Validate Constraints
SCIM 2.0 specifies that DELETE requests do not contain a request body. NICE CXone extends this behavior by accepting custom headers and query parameters for session termination and data retention directives. The payload construction phase validates these flags against license recovery policies and checks for dependent resources before proceeding.
from pydantic import BaseModel, Field
from typing import List, Dict, Any
class DeprovisionRequest(BaseModel):
external_id: str = Field(..., min_length=1, max_length=128)
terminate_active_sessions: bool = True
retain_data: bool = False
license_policy: str = Field(..., pattern="^(RECLAIM|DOWNGRADE|PRESERVE)$")
hr_webhook_url: str = Field(..., pattern=r"^https?://")
def build_headers(self) -> Dict[str, str]:
return {
"X-NICE-TerminateSessions": str(self.terminate_active_sessions).lower(),
"X-NICE-RetainData": str(self.retain_data).lower(),
"X-NICE-LicenseAction": self.license_policy
}
async def validate_dependencies(client: httpx.AsyncClient, user_id: str) -> List[str]:
"""Check for queues, skills, and recordings assigned to the user."""
constraints = []
endpoints = [
f"/api/v2/users/{user_id}/queues",
f"/api/v2/users/{user_id}/skills",
f"/api/v2/recordings?userIds={user_id}"
]
for endpoint in endpoints:
response = await client.get(endpoint, headers={"Content-Type": "application/json"})
if response.status_code == 200:
data = response.json()
items = data.get("entities", data.get("items", []))
if items:
constraints.append(f"Resource dependency found: {endpoint} ({len(items)} items)")
return constraints
The DeprovisionRequest model enforces strict schema validation. The build_headers method transforms the boolean and enum flags into custom headers that the CXone provisioning engine recognizes. The validate_dependencies function queries assignment endpoints to prevent orphaned configurations. If dependencies exist, the orchestration layer must resolve them before deletion.
Step 3: Execute SCIM Deletion and Async Job Orchestration
The core deletion flow triggers the SCIM endpoint, initiates asynchronous license reclamation, and coordinates session termination. The orchestration layer runs these tasks concurrently while tracking execution latency.
import asyncio
import time
import orjson
async def execute_deprovision(client: httpx.AsyncClient, request: DeprovisionRequest) -> Dict[str, Any]:
start_time = time.time()
audit_log = {
"external_id": request.external_id,
"actions": [],
"status": "initiated",
"errors": [],
"latency_ms": 0
}
# Step 3.1: SCIM Deletion
scim_headers = {"Content-Type": "application/scim+json", **request.build_headers()}
scim_response = await client.delete(
f"/scim/v2/Users/{request.external_id}",
headers=scim_headers
)
if scim_response.status_code not in (200, 202, 204):
audit_log["status"] = "failed"
audit_log["errors"].append(f"SCIM DELETE returned {scim_response.status_code}: {scim_response.text}")
return audit_log
audit_log["actions"].append("scim_user_deleted")
# Step 3.2: Dependency Cleanup & License Reclamation
cleanup_task = asyncio.create_task(_reclaim_license(client, request))
session_task = asyncio.create_task(_terminate_sessions(client, request))
await asyncio.gather(cleanup_task, session_task, return_exceptions=True)
audit_log["latency_ms"] = int((time.time() - start_time) * 1000)
audit_log["status"] = "completed"
return audit_log
async def _reclaim_license(client: httpx.AsyncClient, request: DeprovisionRequest) -> None:
if request.license_policy != "RECLAIM":
return
await client.post(
"/api/v2/license/reclaim",
json={"external_id": request.external_id, "reason": "deprovisioning"},
headers={"Content-Type": "application/json"}
)
async def _terminate_sessions(client: httpx.AsyncClient, request: DeprovisionRequest) -> None:
if not request.terminate_active_sessions:
return
await client.post(
"/api/v2/sessions/bulk-revoke",
json={"user_external_ids": [request.external_id], "force": True},
headers={"Content-Type": "application/json"}
)
The execute_deprovision function orchestrates the lifecycle. It issues the SCIM DELETE request with custom headers, then spawns concurrent tasks for license reclamation and session termination. The asyncio.gather call ensures both operations run in parallel while capturing exceptions. Latency tracking measures wall-clock time from initiation to completion.
Step 4: Session Revocation and WebSocket Disconnection Signals
Token blacklisting alone does not disconnect active WebSocket streams used by the CXone Agent Desktop. The platform requires an explicit broadcast signal to drop persistent connections immediately.
async def broadcast_disconnect_signal(client: httpx.AsyncClient, external_id: str) -> None:
"""Push a disconnect command to the CXone pub/sub gateway."""
await client.post(
"/api/v2/pubsub/commands",
json={
"command": "FORCE_DISCONNECT",
"target_type": "USER",
"target_id": external_id,
"metadata": {"reason": "deprovisioning_triggered", "timestamp": int(time.time())}
},
headers={"Content-Type": "application/json"}
)
The pub/sub endpoint forwards the FORCE_DISCONNECT command to all active WebSocket channels associated with the user. Agent Desktop clients receive the signal and terminate their socket connections, preventing stale session states during the de-provisioning window.
Step 5: HRIS Webhook Synchronization and Audit Logging
External HR systems require synchronous confirmation of workforce lifecycle changes. The orchestrator posts a standardized payload to the configured webhook and persists an immutable audit record for compliance verification.
async def notify_hris(client: httpx.AsyncClient, request: DeprovisionRequest, audit: Dict[str, Any]) -> None:
payload = {
"event_type": "USER_DEPROVISIONED",
"external_id": request.external_id,
"timestamp": int(time.time()),
"status": audit["status"],
"latency_ms": audit["latency_ms"],
"license_reclaimed": request.license_policy == "RECLAIM",
"sessions_terminated": request.terminate_active_sessions
}
async with httpx.AsyncClient() as hr_client:
hr_response = await hr_client.post(
request.hr_webhook_url,
content=orjson.dumps(payload),
headers={"Content-Type": "application/json", "X-CXone-Signature": "production"}
)
if hr_response.status_code >= 300:
audit["errors"].append(f"HRIS webhook failed: {hr_response.status_code}")
def write_audit_log(audit: Dict[str, Any]) -> None:
with open("deprovisioning_audit.jsonl", "a") as f:
f.write(orjson.dumps(audit).decode("utf-8") + "\n")
The webhook notification includes latency metrics and license recovery status. The audit log appends to a JSON Lines file, ensuring each de-provisioning event remains queryable for compliance audits. The orjson library provides fast serialization for high-throughput environments.
Complete Working Example
The following script combines all components into a single executable module. Replace the placeholder credentials and tenant domain before execution.
import asyncio
import os
from dotenv import load_dotenv
load_dotenv()
async def main():
tenant = os.getenv("CXONE_TENANT", "example")
client_id = os.getenv("CXONE_CLIENT_ID")
client_secret = os.getenv("CXONE_CLIENT_SECRET")
target_external_id = os.getenv("TARGET_USER_EXTERNAL_ID")
hr_webhook = os.getenv("HR_WEBHOOK_URL", "https://webhook.site/test")
if not all([tenant, client_id, client_secret, target_external_id]):
raise ValueError("Missing required environment variables")
base_url = f"https://{tenant}.nicecxone.com"
auth_manager = CXoneAuthManager(
client_id=client_id,
client_secret=client_secret,
auth_url="https://auth.nicecxone.com/oauth2/token"
)
token = await auth_manager.get_token()
client = create_cxone_client(base_url, token)
request = DeprovisionRequest(
external_id=target_external_id,
terminate_active_sessions=True,
retain_data=False,
license_policy="RECLAIM",
hr_webhook_url=hr_webhook
)
# Validate dependencies first
constraints = await validate_dependencies(client, target_external_id)
if constraints:
print(f"Blocking deletion due to dependencies: {constraints}")
return
try:
audit = await execute_deprovision(client, request)
await notify_hris(client, request, audit)
await broadcast_disconnect_signal(client, target_external_id)
write_audit_log(audit)
print(f"Deprovisioning complete. Latency: {audit['latency_ms']}ms")
except httpx.HTTPStatusError as e:
print(f"HTTP Error {e.response.status_code}: {e.response.text}")
except Exception as e:
print(f"Orchestration failed: {str(e)}")
finally:
await client.aclose()
if __name__ == "__main__":
asyncio.run(main())
The script loads credentials from environment variables, authenticates, validates dependencies, executes the de-provisioning pipeline, synchronizes with the HR system, broadcasts disconnect signals, and writes an audit record. The asyncio.run entry point ensures compatibility with modern Python runtimes.
Common Errors & Debugging
Error: 401 Unauthorized
- Cause: Expired OAuth token, invalid client credentials, or missing
Authorizationheader. - Fix: Verify the
client_idandclient_secretmatch the CXone OAuth application. Ensure theget_tokenmethod executes before any API calls. Check that the token cache is not returning a stale value. - Code Fix: Add explicit token refresh logic and log the expiration timestamp.
Error: 403 Forbidden
- Cause: Insufficient OAuth scopes or tenant-level provisioning restrictions.
- Fix: Confirm the OAuth application includes
provisioning:writeandusers:delete. Verify the API client belongs to a service account with provisioning privileges. - Code Fix: Expand the scope string in the token request and re-authenticate.
Error: 404 Not Found
- Cause: Invalid external ID or user already de-provisioned.
- Fix: Query
GET /scim/v2/Users?externalId={value}to verify existence before deletion. Handle404gracefully as an idempotent success state. - Code Fix: Wrap the SCIM delete in a try-except block and treat
404asstatus: completed.
Error: 422 Unprocessable Entity
- Cause: Schema validation failure or unresolved resource dependencies.
- Fix: Review the
validate_dependenciesoutput. Assign queues and skills to a pool group before deletion. Ensure custom headers match the expected boolean format. - Code Fix: Parse the response body for field-level errors and log them to the audit record.
Error: 429 Too Many Requests
- Cause: Bulk de-provisioning exceeds tenant rate limits.
- Fix: The
RetryTransportconfiguration handles automatic backoff. Reduce concurrent requests by adjustingmax_connectionsor implementing a semaphore. - Code Fix: Monitor retry counts and implement a circuit breaker if failures persist.