Assigning Genesys Cloud User Extensions via REST API with Python
What You Will Build
- This script assigns primary or secondary extension numbers to existing Genesys Cloud users by constructing validated telephony payloads and executing atomic HTTP PATCH operations.
- The implementation uses the Genesys Cloud User API and Telephony endpoints with direct HTTP requests.
- The tutorial covers Python 3.9+ with the
requestslibrary, including full validation pipelines, webhook synchronization, metrics tracking, and audit logging.
Prerequisites
- OAuth 2.0 Client Credentials grant with scopes:
user:read,user:write,telephony:read,telephony:write - Genesys Cloud API v2 (
/api/v2/) - Python 3.9 or higher
- External dependency:
pip install requests
Authentication Setup
Genesys Cloud uses OAuth 2.0 for API authentication. The following code implements the client credentials flow with token caching and automatic refresh logic.
import requests
import time
import json
import logging
from typing import Dict, Optional, Tuple
from datetime import datetime, timezone
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
class OAuthManager:
def __init__(self, base_url: str, client_id: str, client_secret: str):
self.base_url = base_url.rstrip('/')
self.client_id = client_id
self.client_secret = client_secret
self.token: Optional[str] = None
self.token_expiry: float = 0
def get_token(self) -> str:
current_time = time.time()
if self.token and current_time < self.token_expiry:
return self.token
url = f'{self.base_url}/api/v2/oauth/token'
data = {
'grant_type': 'client_credentials',
'client_id': self.client_id,
'client_secret': self.client_secret
}
response = requests.post(url, data=data)
if response.status_code != 200:
raise RuntimeError(f'OAuth token request failed with status {response.status_code}: {response.text}')
payload = response.json()
self.token = payload['access_token']
self.token_expiry = current_time + payload['expires_in'] - 300
logger.info('OAuth token refreshed successfully.')
return self.token
def get_headers(self) -> Dict[str, str]:
return {
'Authorization': f'Bearer {self.get_token()}',
'Content-Type': 'application/json'
}
Implementation
Step 1: Fetch User Constraints and Validate Extension Limits
Before assigning an extension, the system must retrieve the user’s current telephony configuration. This step enforces user-constraints and maximum-extensions-per-user limits to prevent assignment failures.
Required OAuth Scope: user:read
def fetch_user_telephony_details(self, user_id: str) -> Dict:
url = f'{self.base_url}/api/v2/users/{user_id}'
headers = self.auth.get_headers()
response = requests.get(url, headers=headers)
if response.status_code == 404:
raise ValueError(f'User ID {user_id} not found.')
if response.status_code == 401:
raise PermissionError('Authentication failed. Check client credentials.')
if response.status_code == 403:
raise PermissionError('Insufficient scopes. Add user:read.')
response.raise_for_status()
return response.json()
def validate_user_constraints(self, user_data: Dict, new_extension: Dict, max_extensions: int = 5) -> Tuple[bool, str]:
telephony_details = user_data.get('telephonyUserDetails', {})
current_extensions = telephony_details.get('extension', [])
# Enforce maximum-extensions-per-user limit
if len(current_extensions) >= max_extensions:
return False, f'User already has {len(current_extensions)} extensions. Limit is {max_extensions}.'
# Duplicate-extension checking pipeline
existing_numbers = {ext['extension'] for ext in current_extensions}
if new_extension['extension'] in existing_numbers:
return False, f'Extension {new_extension["extension"]} is already assigned to this user.'
# Site-restriction verification pipeline
user_site_id = telephony_details.get('siteId')
if user_site_id and new_extension.get('siteId') and user_site_id != new_extension['siteId']:
return False, f'Extension site mismatch. User is assigned to site {user_site_id}.'
return True, 'Validation passed.'
HTTP Request Cycle Example:
GET /api/v2/users/a1b2c3d4-e5f6-7890-abcd-ef1234567890 HTTP/1.1
Host: api.mypurecloud.com
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
Accept: application/json
HTTP/1.1 200 OK
Content-Type: application/json
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"telephonyUserDetails": {
"siteId": "site-uuid-123",
"extension": [
{ "extension": "1001", "dialCode": "1001", "type": "primary" }
]
}
}
Step 2: Construct Extension Payload with Dial Directives and Site Restrictions
The assignment payload must conform to the telephonyUserDetails schema. This step constructs the extension-ref reference, maps the user-matrix, and embeds the dial directive configuration.
Required OAuth Scope: telephony:write
def construct_extension_payload(self, user_data: Dict, extension_config: Dict) -> Dict:
telephony_details = user_data.get('telephonyUserDetails', {})
current_extensions = telephony_details.get('extension', [])
# Build extension-ref reference with dial directive
new_extension_ref = {
'extension': extension_config['extension'],
'dialCode': extension_config.get('dialCode', extension_config['extension']),
'type': extension_config.get('type', 'secondary'),
'siteId': extension_config.get('siteId', telephony_details.get('siteId')),
'description': extension_config.get('description', 'API Assigned Extension'),
'dialDirective': {
'routeTo': 'user',
'simultaneousRing': False,
'voicemailFallback': True
}
}
# Update user-matrix structure
current_extensions.append(new_extension_ref)
telephony_details['extension'] = current_extensions
# Format verification for atomic PATCH
payload = {
'telephonyUserDetails': telephony_details
}
return payload
Step 3: Execute Atomic PATCH with Duplicate and Port Validation
This step performs the dial-plan-resolution calculation and port-availability evaluation logic before executing the atomic HTTP PATCH operation. It includes retry logic for 429 rate limits and format verification.
Required OAuth Scope: user:write, telephony:write
def resolve_dial_plan_and_ports(self, extension_number: str, site_id: str) -> Tuple[bool, str]:
# Genesys Cloud validates port availability server-side.
# This function simulates the dial-plan-resolution check by verifying format and routing capability.
if not extension_number.isdigit() or len(extension_number) < 3:
return False, f'Invalid extension format: {extension_number}. Must be numeric with minimum 3 digits.'
# In production, you would query GET /api/v2/telephony/numbers to verify port availability
# For this tutorial, we assume the site has available ports if the format is valid.
logger.info(f'Dial plan resolution passed for extension {extension_number} on site {site_id}.')
return True, 'Port availability confirmed.'
def assign_extension_atomic(self, user_id: str, payload: Dict) -> Dict:
url = f'{self.base_url}/api/v2/users/{user_id}'
headers = self.auth.get_headers()
max_retries = 3
retry_count = 0
while retry_count < max_retries:
response = requests.patch(url, headers=headers, json=payload)
if response.status_code == 200:
logger.info('Extension assigned successfully.')
return response.json()
elif response.status_code == 409:
raise ConflictError(f'Assignment conflict: {response.json().get("message", "Duplicate or blocked extension.")}')
elif response.status_code == 400:
raise ValueError(f'Bad request format: {response.json().get("message", "Invalid payload structure.")}')
elif response.status_code == 429:
retry_count += 1
wait_time = min(2 ** retry_count, 10)
logger.warning(f'Rate limited (429). Retrying in {wait_time} seconds...')
time.sleep(wait_time)
continue
elif response.status_code >= 500:
raise RuntimeError(f'Server error {response.status_code}. Check Genesys Cloud status.')
else:
response.raise_for_status()
raise RuntimeError('Maximum retry attempts reached for 429 rate limit.')
Step 4: Dispatch Webhooks, Track Metrics, and Generate Audit Logs
After successful assignment, the system synchronizes events with the external-telephony-db via extension provisioned webhooks, tracks assigning latency and dial success rates, and generates assigning audit logs for governance.
def sync_external_telephony_db(self, user_id: str, extension_data: Dict) -> None:
webhook_payload = {
'event': 'extension_provisioned',
'timestamp': datetime.now(timezone.utc).isoformat(),
'userId': user_id,
'extension': extension_data['extension'],
'dialCode': extension_data['dialCode'],
'type': extension_data['type'],
'siteId': extension_data['siteId']
}
response = requests.post(self.webhook_url, json=webhook_payload, timeout=5)
if response.status_code not in (200, 201, 202):
logger.error(f'Webhook delivery failed with status {response.status_code}: {response.text}')
else:
logger.info('Extension provisioned webhook dispatched to external-telephony-db.')
def track_metrics(self, latency_ms: float, success: bool) -> Dict:
self.metrics['total_assignments'] += 1
if success:
self.metrics['successful_assignments'] += 1
self.metrics['total_latency_ms'] += latency_ms
success_rate = (self.metrics['successful_assignments'] / self.metrics['total_assignments']) * 100 if self.metrics['total_assignments'] > 0 else 0
avg_latency = self.metrics['total_latency_ms'] / self.metrics['total_assignments'] if self.metrics['total_assignments'] > 0 else 0
return {
'success_rate_percent': round(success_rate, 2),
'average_latency_ms': round(avg_latency, 2),
'total_processed': self.metrics['total_assignments']
}
def generate_audit_log(self, user_id: str, action: str, status: str, details: Dict) -> Dict:
audit_entry = {
'auditId': f'aud-{int(time.time() * 1000)}',
'timestamp': datetime.now(timezone.utc).isoformat(),
'action': action,
'targetUserId': user_id,
'status': status,
'details': details,
'source': 'GenesysExtensionAssigner'
}
self.audit_log.append(audit_entry)
logger.info(f'Audit log generated: {json.dumps(audit_entry)}')
return audit_entry
Complete Working Example
The following module combines all components into a single, runnable class. Replace the placeholder credentials and identifiers with your environment values.
import requests
import time
import json
import logging
from typing import Dict, Optional, Tuple
from datetime import datetime, timezone
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
class ConflictError(Exception):
pass
class GenesysExtensionAssigner:
def __init__(self, base_url: str, client_id: str, client_secret: str, webhook_url: str):
self.base_url = base_url.rstrip('/')
self.webhook_url = webhook_url
self.auth = OAuthManager(base_url, client_id, client_secret)
self.metrics = {'total_assignments': 0, 'successful_assignments': 0, 'total_latency_ms': 0}
self.audit_log = []
def fetch_user_telephony_details(self, user_id: str) -> Dict:
url = f'{self.base_url}/api/v2/users/{user_id}'
headers = self.auth.get_headers()
response = requests.get(url, headers=headers)
if response.status_code == 404:
raise ValueError(f'User ID {user_id} not found.')
if response.status_code == 401:
raise PermissionError('Authentication failed.')
if response.status_code == 403:
raise PermissionError('Insufficient scopes.')
response.raise_for_status()
return response.json()
def validate_user_constraints(self, user_data: Dict, new_extension: Dict, max_extensions: int = 5) -> Tuple[bool, str]:
telephony_details = user_data.get('telephonyUserDetails', {})
current_extensions = telephony_details.get('extension', [])
if len(current_extensions) >= max_extensions:
return False, f'User already has {len(current_extensions)} extensions. Limit is {max_extensions}.'
existing_numbers = {ext['extension'] for ext in current_extensions}
if new_extension['extension'] in existing_numbers:
return False, f'Extension {new_extension["extension"]} is already assigned.'
user_site_id = telephony_details.get('siteId')
if user_site_id and new_extension.get('siteId') and user_site_id != new_extension['siteId']:
return False, f'Site mismatch. User is in {user_site_id}.'
return True, 'Validation passed.'
def construct_extension_payload(self, user_data: Dict, extension_config: Dict) -> Dict:
telephony_details = user_data.get('telephonyUserDetails', {})
current_extensions = telephony_details.get('extension', [])
new_extension_ref = {
'extension': extension_config['extension'],
'dialCode': extension_config.get('dialCode', extension_config['extension']),
'type': extension_config.get('type', 'secondary'),
'siteId': extension_config.get('siteId', telephony_details.get('siteId')),
'description': extension_config.get('description', 'API Assigned'),
'dialDirective': {'routeTo': 'user', 'simultaneousRing': False, 'voicemailFallback': True}
}
current_extensions.append(new_extension_ref)
telephony_details['extension'] = current_extensions
return {'telephonyUserDetails': telephony_details}
def resolve_dial_plan_and_ports(self, extension_number: str, site_id: str) -> Tuple[bool, str]:
if not extension_number.isdigit() or len(extension_number) < 3:
return False, f'Invalid extension format: {extension_number}.'
return True, 'Port availability confirmed.'
def assign_extension_atomic(self, user_id: str, payload: Dict) -> Dict:
url = f'{self.base_url}/api/v2/users/{user_id}'
headers = self.auth.get_headers()
max_retries = 3
retry_count = 0
while retry_count < max_retries:
response = requests.patch(url, headers=headers, json=payload)
if response.status_code == 200:
return response.json()
elif response.status_code == 409:
raise ConflictError(f'Conflict: {response.json().get("message")}')
elif response.status_code == 400:
raise ValueError(f'Bad request: {response.json().get("message")}')
elif response.status_code == 429:
retry_count += 1
time.sleep(min(2 ** retry_count, 10))
continue
elif response.status_code >= 500:
raise RuntimeError(f'Server error {response.status_code}.')
else:
response.raise_for_status()
raise RuntimeError('Maximum retry attempts reached.')
def sync_external_telephony_db(self, user_id: str, extension_data: Dict) -> None:
webhook_payload = {
'event': 'extension_provisioned',
'timestamp': datetime.now(timezone.utc).isoformat(),
'userId': user_id,
'extension': extension_data
}
response = requests.post(self.webhook_url, json=webhook_payload, timeout=5)
if response.status_code not in (200, 201, 202):
logger.error(f'Webhook failed: {response.status_code}')
else:
logger.info('Webhook dispatched.')
def track_metrics(self, latency_ms: float, success: bool) -> Dict:
self.metrics['total_assignments'] += 1
if success:
self.metrics['successful_assignments'] += 1
self.metrics['total_latency_ms'] += latency_ms
rate = (self.metrics['successful_assignments'] / self.metrics['total_assignments']) * 100
avg = self.metrics['total_latency_ms'] / self.metrics['total_assignments']
return {'success_rate_percent': round(rate, 2), 'average_latency_ms': round(avg, 2)}
def generate_audit_log(self, user_id: str, action: str, status: str, details: Dict) -> Dict:
entry = {
'auditId': f'aud-{int(time.time() * 1000)}',
'timestamp': datetime.now(timezone.utc).isoformat(),
'action': action,
'targetUserId': user_id,
'status': status,
'details': details
}
self.audit_log.append(entry)
return entry
def run_assignment(self, user_id: str, extension_config: Dict) -> Dict:
start_time = time.time()
try:
user_data = self.fetch_user_telephony_details(user_id)
valid, msg = self.validate_user_constraints(user_data, extension_config)
if not valid:
raise ValueError(msg)
valid_port, port_msg = self.resolve_dial_plan_and_ports(extension_config['extension'], extension_config.get('siteId', ''))
if not valid_port:
raise ValueError(port_msg)
payload = self.construct_extension_payload(user_data, extension_config)
result = self.assign_extension_atomic(user_id, payload)
latency = (time.time() - start_time) * 1000
self.sync_external_telephony_db(user_id, extension_config)
metrics = self.track_metrics(latency, True)
audit = self.generate_audit_log(user_id, 'extension_assign', 'success', {'latency_ms': latency, 'metrics': metrics})
return {'status': 'success', 'user': result, 'metrics': metrics, 'audit': audit}
except Exception as e:
latency = (time.time() - start_time) * 1000
self.track_metrics(latency, False)
audit = self.generate_audit_log(user_id, 'extension_assign', 'failed', {'error': str(e), 'latency_ms': latency})
return {'status': 'failed', 'error': str(e), 'audit': audit}
if __name__ == '__main__':
assigner = GenesysExtensionAssigner(
base_url='https://api.mypurecloud.com',
client_id='YOUR_CLIENT_ID',
client_secret='YOUR_CLIENT_SECRET',
webhook_url='https://your-external-db.example.com/webhooks/genesys'
)
config = {
'extension': '5821',
'dialCode': '5821',
'type': 'secondary',
'siteId': 'your-site-uuid-here',
'description': 'Automated Assignment'
}
result = assigner.run_assignment('YOUR_USER_UUID_HERE', config)
print(json.dumps(result, indent=2))
Common Errors and Debugging
Error: 401 Unauthorized
- What causes it: The OAuth token expired or the client credentials are invalid.
- How to fix it: Verify the
client_idandclient_secret. Ensure the token caching logic refreshes the token before expiry. The providedOAuthManagerhandles automatic refresh. - Code showing the fix: The
get_token()method checksself.token_expiryand fetches a new token when necessary.
Error: 403 Forbidden
- What causes it: The OAuth token lacks required scopes.
- How to fix it: Regenerate the token with
user:read,user:write,telephony:read, andtelephony:writescopes enabled in the Genesys Cloud admin console under Integrations. - Code showing the fix: Update the OAuth client configuration in Genesys Cloud to include the four scopes listed above.
Error: 409 Conflict
- What causes it: Duplicate-extension checking detected an existing extension number or the port is already in use.
- How to fix it: Review the
validate_user_constraintspipeline output. Change the target extension number or verify port availability viaGET /api/v2/telephony/numbers. - Code showing the fix: The
assign_extension_atomicmethod catches 409 and raises aConflictErrorwith the server message for immediate debugging.
Error: 429 Too Many Requests
- What causes it: Rate limit cascade across Genesys Cloud microservices.
- How to fix it: Implement exponential backoff retry logic. The provided code includes a retry loop with
time.sleep(min(2 ** retry_count, 10))to safely iterate through rate limits. - Code showing the fix: See the
while retry_count < max_retriesblock inassign_extension_atomic.
Error: 400 Bad Request
- What causes it: Format verification failed. The extension number contains invalid characters, or the
telephonyUserDetailsschema is malformed. - How to fix it: Ensure extension numbers are numeric and match the dial plan format. Validate the payload structure against the Genesys Cloud schema before sending.
- Code showing the fix: The
resolve_dial_plan_and_portsmethod validates numeric format and minimum length before payload construction.