Programmatically Transfer Active Calls Between Queues Using the Genesys Cloud Conversations API
What You Will Build
- One sentence: The code identifies an active voice conversation and updates its routing metadata to transfer it to a different queue.
- One sentence: This tutorial uses the Genesys Cloud CX Conversations API (
/api/v2/conversations/voice/{conversationId}) via the Python SDK. - One sentence: The programming language covered is Python 3.9+.
Prerequisites
- OAuth client type: Service Account or User-based OAuth with appropriate permissions.
- Required scopes:
conversations:view(to list and inspect conversations)conversations:modify(to update the conversation routing data)routing:queues:view(to validate queue IDs)
- SDK version:
genesys-cloud-purecloud-platform-clientv3.15.0 or later. - Language/runtime requirements: Python 3.9 or higher.
- External dependencies:
pip install genesys-cloud-purecloud-platform-client pip install python-dotenv
Authentication Setup
Genesys Cloud uses OAuth 2.0 for API authentication. For programmatic transfers, a Service Account is typically preferred to decouple the logic from a specific user session.
Create a .env file in your project root with the following credentials:
# .env
GENESYS_REGION=us-east-1
GENESYS_CLIENT_ID=your_client_id_here
GENESYS_CLIENT_SECRET=your_client_secret_here
Initialize the Genesys Cloud Python SDK with environment-based authentication:
import os
from dotenv import load_dotenv
from purecloud_platform_client.configuration import Configuration
from purecloud_platform_client.api_client import ApiClient
from purecloud_platform_client.api.conversations_api import ConversationsApi
from purecloud_platform_client.api.routing_api import RoutingApi
from purecloud_platform_client.rest import ApiException
# Load environment variables
load_dotenv()
def init_api_client():
"""
Initializes the Genesys Cloud API client with OAuth2 authentication.
"""
config = Configuration(
host=f"https://api.{os.getenv('GENESYS_REGION')}.mypurecloud.com",
client_id=os.getenv('GENESYS_CLIENT_ID'),
client_secret=os.getenv('GENESYS_CLIENT_SECRET')
)
# The SDK handles token refresh automatically
api_client = ApiClient(configuration=config)
return api_client
# Initialize APIs
api_client = init_api_client()
conversations_api = ConversationsApi(api_client=api_client)
routing_api = RoutingApi(api_client=api_client)
Implementation
Step 1: Identify the Active Conversation and Target Queue
Before transferring, you must identify the unique conversationId of the call to be transferred and the id of the target queue.
Note: You cannot transfer a call to a queue that does not exist or is not enabled. Always validate the queue ID first.
def get_target_queue_id(queue_name: str) -> str:
"""
Retrieves the Queue ID by name.
Required Scope: routing:queues:view
"""
try:
# Search queues by name
response = routing_api.post_routing_queues_search(
body={
"query": queue_name,
"size": 1,
"page": 1
}
)
if response.entities and len(response.entities) > 0:
return response.entities[0].id
else:
raise ValueError(f"Queue '{queue_name}' not found.")
except ApiException as e:
print(f"Error fetching queue: {e.body}")
raise
def find_active_conversation(agent_id: str) -> str:
"""
Finds the most recent active voice conversation for a specific agent.
Required Scope: conversations:view
"""
try:
# Query active voice conversations
response = conversations_api.get_conversations_voice(
expand=["participants"],
conversation_state="active"
)
if not response.entities:
raise ValueError("No active voice conversations found.")
# Filter for the specific agent
for conv in response.entities:
for participant in conv.participants:
if participant.id == agent_id and participant.status == "active":
return conv.id
raise ValueError(f"No active conversation found for agent {agent_id}.")
except ApiException as e:
print(f"Error fetching conversations: {e.body}")
raise
Step 2: Construct the Transfer Payload
The core of the transfer lies in the routingData object within the conversation update payload.
To transfer a call to another queue, you must:
- Set
routingData.typeto"queue". - Set
routingData.toto the target Queue ID. - Optionally set
routingData.wrappedtotrueif you want the agent to wrap up the current interaction before the transfer completes (though typically, for a blind transfer, you just change the queue assignment).
Critical Distinction:
- Blind Transfer: The agent changes the queue assignment, and the system immediately attempts to route the call to the new queue. The agent is typically disconnected or remains on hold depending on configuration.
- Consultative Transfer: Requires a more complex multi-step process involving a new conversation leg. This tutorial focuses on the Blind Transfer via PATCH, which is the most common programmatic requirement for IVR-to-Queue or Agent-to-Queue redirection.
def build_transfer_payload(target_queue_id: str, priority: int = 0) -> dict:
"""
Constructs the JSON payload for PATCH /api/v2/conversations/voice/{id}.
"""
return {
"routingData": {
"type": "queue",
"to": target_queue_id,
"priority": priority,
"skillRequirements": [] # Optional: Force specific skill requirements
}
}
Step 3: Execute the Transfer via PATCH
Use the patch_conversations_voice_conversation method. This performs a partial update on the conversation resource.
Required Scope: conversations:modify
def transfer_call_to_queue(conversation_id: str, payload: dict) -> bool:
"""
Executes the transfer by PATCHing the conversation routing data.
Required Scope: conversations:modify
"""
try:
# The SDK method for partial update
conversations_api.patch_conversations_voice_conversation(
conversation_id=conversation_id,
body=payload
)
print(f"Successfully initiated transfer for conversation {conversation_id}")
return True
except ApiException as e:
# Handle specific HTTP errors
if e.status == 404:
print(f"Conversation {conversation_id} not found or already ended.")
elif e.status == 409:
print(f"Conflict: Conversation {conversation_id} cannot be transferred in its current state.")
elif e.status == 429:
print("Rate limit exceeded. Implement retry logic.")
else:
print(f"API Error {e.status}: {e.body}")
return False
Step 4: Handling Edge Cases and Validation
Before executing the PATCH, you must ensure the conversation is in a translatable state. You cannot transfer a call that is:
- Already wrapped.
- Disconnected.
- In a transfer state (already being routed).
Add a validation step:
def validate_transfer_eligibility(conversation_id: str) -> bool:
"""
Checks if the conversation is in a state that allows routing changes.
"""
try:
conv_details = conversations_api.get_conversations_voice_conversation(
conversation_id=conversation_id,
expand=["participants"]
)
# Check conversation state
if conv_details.state != "active":
print(f"Conversation is in state '{conv_details.state}'. Transfer not allowed.")
return False
# Check if already routed to a queue
if conv_details.routing_data and conv_details.routing_data.type == "queue":
print("Conversation is already associated with a queue.")
# You can still change the queue, but this is a warning
return True
return True
except ApiException as e:
print(f"Validation error: {e.body}")
return False
Complete Working Example
This script combines all steps into a reusable class. It finds an agent’s active call, validates it, and transfers it to a specified queue.
import os
import time
from dotenv import load_dotenv
from purecloud_platform_client.configuration import Configuration
from purecloud_platform_client.api_client import ApiClient
from purecloud_platform_client.api.conversations_api import ConversationsApi
from purecloud_platform_client.api.routing_api import RoutingApi
from purecloud_platform_client.rest import ApiException
load_dotenv()
class GenesysCallTransferManager:
def __init__(self):
config = Configuration(
host=f"https://api.{os.getenv('GENESYS_REGION', 'us-east-1')}.mypurecloud.com",
client_id=os.getenv('GENESYS_CLIENT_ID'),
client_secret=os.getenv('GENESYS_CLIENT_SECRET')
)
self.api_client = ApiClient(configuration=config)
self.conversations_api = ConversationsApi(api_client=self.api_client)
self.routing_api = RoutingApi(api_client=self.api_client)
def get_queue_id_by_name(self, queue_name: str) -> str:
"""Fetches Queue ID from Name."""
try:
response = self.routing_api.post_routing_queues_search(
body={"query": queue_name, "size": 1, "page": 1}
)
if response.entities:
return response.entities[0].id
raise ValueError(f"Queue '{queue_name}' not found.")
except ApiException as e:
raise Exception(f"Failed to find queue: {e.body}")
def get_active_conversation_for_agent(self, agent_id: str) -> str:
"""Finds the latest active voice conversation for an agent."""
try:
response = self.conversations_api.get_conversations_voice(
expand=["participants"],
conversation_state="active"
)
for conv in response.entities:
for participant in conv.participants:
if participant.id == agent_id and participant.status == "active":
return conv.id
raise ValueError(f"No active conversation found for agent {agent_id}.")
except ApiException as e:
raise Exception(f"Failed to retrieve conversations: {e.body}")
def transfer_call(self, agent_id: str, target_queue_name: str, priority: int = 0) -> dict:
"""
Main function to transfer a call.
Returns a status dict.
"""
result = {
"success": False,
"message": "",
"conversation_id": None,
"target_queue_id": None
}
try:
# 1. Resolve Queue ID
target_queue_id = self.get_queue_id_by_name(target_queue_name)
result["target_queue_id"] = target_queue_id
# 2. Find Conversation
conversation_id = self.get_active_conversation_for_agent(agent_id)
result["conversation_id"] = conversation_id
# 3. Validate Eligibility
if not self._is_translatable(conversation_id):
result["message"] = "Conversation not in a translatable state."
return result
# 4. Build Payload
payload = {
"routingData": {
"type": "queue",
"to": target_queue_id,
"priority": priority
}
}
# 5. Execute Transfer
self.conversations_api.patch_conversations_voice_conversation(
conversation_id=conversation_id,
body=payload
)
result["success"] = True
result["message"] = f"Call transferred to queue {target_queue_name}."
except Exception as e:
result["message"] = str(e)
return result
def _is_translatable(self, conversation_id: str) -> bool:
"""Internal check for conversation state."""
try:
conv = self.conversations_api.get_conversations_voice_conversation(
conversation_id=conversation_id
)
return conv.state == "active"
except ApiException:
return False
# Usage Example
if __name__ == "__main__":
# Replace with actual Agent ID and Queue Name
AGENT_ID = "12345678-abcd-efgh-ijkl-1234567890ab"
TARGET_QUEUE = "Technical Support"
manager = GenesysCallTransferManager()
# Retry logic for 429s
max_retries = 3
for attempt in range(max_retries):
result = manager.transfer_call(AGENT_ID, TARGET_QUEUE)
if result["success"]:
print("Transfer Successful:", result["message"])
break
elif "Rate limit" in result["message"]:
print(f"Rate limited. Retrying in {2 ** attempt} seconds...")
time.sleep(2 ** attempt)
else:
print("Transfer Failed:", result["message"])
break
Common Errors & Debugging
Error: 400 Bad Request - Invalid Routing Data
Cause: The routingData.to field contains an invalid ID, or the type is set to "queue" but the ID is not a valid Queue ID.
Fix: Verify the Queue ID using the RoutingApi.get_routing_queues_queue endpoint. Ensure the queue is enabled.
# Debugging Code
try:
queue_details = routing_api.get_routing_queues_queue(queue_id=target_queue_id)
print(f"Queue Name: {queue_details.name}, Enabled: {queue_details.enabled}")
except ApiException as e:
print(f"Invalid Queue ID: {e.body}")
Error: 409 Conflict - Conversation State
Cause: The conversation is not in an active state, or it is already being routed (e.g., waiting for wrap-up).
Fix: Check the state field of the conversation. If it is wrapping, you must wait for it to become active or closed (depending on transfer type). For blind transfers, the call must be active and not currently in a transfer leg.
Error: 403 Forbidden - Scope Missing
Cause: The OAuth token lacks conversations:modify.
Fix: Re-authenticate with a token that includes the conversations:modify scope. If using a Service Account, ensure the Service Account has the “Modify conversations” permission in the Admin Console.
Error: 429 Too Many Requests
Cause: Exceeding the API rate limit (typically 100-200 requests per minute depending on the plan).
Fix: Implement exponential backoff. The complete example above includes a basic retry loop. For production, use a library like tenacity.
from tenacity import retry, wait_exponential, stop_after_attempt
@retry(wait=wait_exponential(multiplier=1, min=4, max=10), stop=stop_after_attempt(3))
def robust_transfer(conversation_id: str, payload: dict):
conversations_api.patch_conversations_voice_conversation(
conversation_id=conversation_id,
body=payload
)