Programmatically Close Web Messaging Sessions via Genesys Cloud API

Programmatically Close Web Messaging Sessions via Genesys Cloud API

What You Will Build

  • You will build a backend service that identifies an active Web Messaging conversation by participant ID and programmatically terminates the session.
  • This tutorial uses the Genesys Cloud Platform API v2, specifically the Conversations and Analytics endpoints.
  • The implementation is provided in Python using the genesys-cloud-purecloud-sdk and requests library for raw API fallbacks.

Prerequisites

  • OAuth Client Type: Service Account (Client Credentials Grant).
  • Required Scopes:
    • webchat:conversation:read (to query conversation details)
    • webchat:conversation:write (to update conversation status or end it)
    • analytics:conversationdetails:read (optional, for verifying session state)
  • SDK Version: genesys-cloud-purecloud-sdk >= 2.0.0
  • Runtime: Python 3.9+
  • Dependencies:
    pip install genesys-cloud-purecloud-sdk requests python-dotenv
    

Authentication Setup

Genesys Cloud requires OAuth 2.0 authentication. For backend services, the Client Credentials Grant is the standard flow. You must cache the access token and handle expiration to avoid unnecessary re-authentication overhead.

import os
import time
import requests
from typing import Optional

class GenesysAuth:
    def __init__(self, client_id: str, client_secret: str, environment: str = "mypurecloud.com"):
        self.client_id = client_id
        self.client_secret = client_secret
        self.environment = environment
        self.token_url = f"https://{environment}/oauth/token"
        self.access_token: Optional[str] = None
        self.token_expiry: float = 0

    def get_access_token(self) -> str:
        """
        Retrieves an access token. Returns cached token if valid.
        Implements basic retry logic for transient network errors.
        """
        # Check if token is still valid (subtract 60s buffer for clock skew)
        if self.access_token and time.time() < (self.token_expiry - 60):
            return self.access_token

        payload = {
            "grant_type": "client_credentials",
            "client_id": self.client_id,
            "client_secret": self.client_secret
        }

        try:
            response = requests.post(self.token_url, data=payload, timeout=10)
            response.raise_for_status()
            token_data = response.json()
            
            self.access_token = token_data["access_token"]
            self.token_expiry = time.time() + token_data["expires_in"]
            
            return self.access_token
        except requests.exceptions.HTTPError as e:
            # Handle 401 Unauthorized (Invalid Credentials)
            if response.status_code == 401:
                raise ValueError("Invalid Client ID or Secret") from e
            raise

# Initialize Auth
# In production, load these from environment variables or a secure vault
auth = GenesysAuth(
    client_id=os.getenv("GENESYS_CLIENT_ID"),
    client_secret=os.getenv("GENESYS_CLIENT_SECRET")
)

Implementation

Step 1: Identify the Active Conversation

Web Messaging sessions are represented as conversations in Genesys Cloud. To close a specific session, you must first locate the active conversation associated with the user. You typically identify the user by their externalId (passed from the widget during initialization) or by the participantId if you are tracking it server-side.

The GET /api/v2/conversations/webchat endpoint is not directly searchable by external ID in a single call. Instead, you query the GET /api/v2/conversations/webchat list and filter, or use the GET /api/v2/conversations endpoint with specific filters. However, the most reliable method for programmatic closure is to find the conversation ID linked to the participant.

We will use the GET /api/v2/conversations/webchat endpoint to list recent conversations. Note that this endpoint returns a paginated list.

import requests
from typing import List, Dict, Any

class WebChatManager:
    def __init__(self, auth: GenesysAuth):
        self.auth = auth
        self.base_url = f"https://{auth.environment}/api/v2"

    def find_active_conversation_by_external_id(self, external_id: str) -> Optional[str]:
        """
        Searches recent Web Chat conversations for one associated with the given external_id.
        Returns the conversation ID if found, else None.
        """
        headers = {
            "Authorization": f"Bearer {self.auth.get_access_token()}",
            "Content-Type": "application/json"
        }

        # We query the last few minutes to avoid scanning entire history
        # The 'since' parameter expects ISO 8601 UTC
        import datetime
        since = (datetime.datetime.utcnow() - datetime.timedelta(minutes=5)).isoformat() + "Z"
        
        params = {
            "since": since,
            "pageSize": 100
        }

        try:
            response = requests.get(
                f"{self.base_url}/conversations/webchat",
                headers=headers,
                params=params,
                timeout=15
            )
            response.raise_for_status()
            
            conversations = response.json().get("entities", [])
            
            for conv in conversations:
                # Check participants for the matching externalId
                for participant in conv.get("participants", []):
                    if participant.get("externalId") == external_id:
                        return conv["id"]
            
            return None

        except requests.exceptions.HTTPError as e:
            if response.status_code == 401:
                # Token expired, refresh and retry once
                self.auth.access_token = None # Force refresh
                return self.find_active_conversation_by_external_id(external_id)
            raise

# Usage
conv_id = WebChatManager(auth).find_active_conversation_by_external_id("user_12345")
if not conv_id:
    print("No active conversation found for this user.")
else:
    print(f"Found active conversation: {conv_id}")

Important Note on Scope: This call requires webchat:conversation:read. If you receive a 403 Forbidden, verify your service account has this scope.

Step 2: Terminate the Conversation

There are two primary ways to “close” a Web Messaging session:

  1. End the Conversation: This marks the conversation as closed in the platform. The agent cannot send messages, and the widget typically shows a “Chat Ended” state. This is the standard administrative closure.
  2. Disconnect Participant: This forcibly removes the participant from the conversation. This is more aggressive and simulates a network drop or client disconnect.

For a clean backend-initiated closure, we use the POST /api/v2/conversations/{conversationId}/end endpoint.

import json

class WebChatManager:
    # ... previous code ...

    def close_conversation(self, conversation_id: str, reason: str = "Backend initiated closure") -> Dict[str, Any]:
        """
        Ends a Web Chat conversation programmatically.
        
        Args:
            conversation_id: The ID of the conversation to close.
            reason: Optional reason string for audit logs.
            
        Returns:
            The response JSON from the API.
        """
        headers = {
            "Authorization": f"Bearer {self.auth.get_access_token()}",
            "Content-Type": "application/json"
        }

        payload = {
            "reason": reason
        }

        url = f"{self.base_url}/conversations/{conversation_id}/end"

        try:
            response = requests.post(
                url,
                headers=headers,
                json=payload,
                timeout=15
            )
            
            # 204 No Content is expected for successful end
            if response.status_code == 204:
                return {"status": "success", "message": "Conversation ended successfully"}
            
            # Handle specific error codes
            if response.status_code == 404:
                return {"status": "error", "message": "Conversation not found or already ended"}
            if response.status_code == 409:
                return {"status": "error", "message": "Conversation is already in a terminal state"}
            
            response.raise_for_status()
            return response.json()

        except requests.exceptions.HTTPError as e:
            # Log the error for debugging
            print(f"HTTP Error: {response.status_code} - {response.text}")
            raise
        except requests.exceptions.ConnectionError:
            print("Failed to connect to Genesys Cloud API.")
            raise

# Usage
if conv_id:
    result = WebChatManager(auth).close_conversation(conv_id)
    print(f"Closure Result: {result}")

Edge Case: 429 Too Many Requests
If you are closing many sessions in a batch, you will hit rate limits. Genesys Cloud returns a 429 status code with a Retry-After header. You must implement exponential backoff.

import time
import random

def post_with_retry(session, url, headers, json_payload, max_retries=3):
    """
    Wrapper for POST requests with exponential backoff for 429 errors.
    """
    for attempt in range(max_retries):
        response = session.post(url, headers=headers, json=json_payload)
        
        if response.status_code == 429:
            retry_after = int(response.headers.get("Retry-After", 5))
            # Add jitter to prevent thundering herd
            time.sleep(retry_after + random.uniform(0, 1))
            continue
        
        return response
    
    raise Exception(f"Max retries exceeded for {url}")

Step 3: Verify Session Closure

After initiating the closure, it is good practice to verify the state. The conversation status should transition from active to closed. You can query the conversation details to confirm.

    def verify_conversation_status(self, conversation_id: str) -> str:
        """
        Retrieves the current status of a conversation.
        """
        headers = {
            "Authorization": f"Bearer {self.auth.get_access_token()}",
            "Content-Type": "application/json"
        }

        url = f"{self.base_url}/conversations/{conversation_id}"

        try:
            response = requests.get(url, headers=headers, timeout=10)
            response.raise_for_status()
            data = response.json()
            return data.get("state", "unknown")
        except requests.exceptions.HTTPError:
            return "error"

# Usage
if conv_id:
    status = WebChatManager(auth).verify_conversation_status(conv_id)
    print(f"Final Conversation Status: {status}")
    assert status == "closed", "Conversation was not successfully closed."

Complete Working Example

This is a fully functional Python script. Replace the placeholder credentials with your service account details.

import os
import time
import requests
import datetime
import random
from typing import Optional, Dict, Any

# --- Configuration ---
# Load from environment variables in production
CLIENT_ID = os.getenv("GENESYS_CLIENT_ID", "your_client_id_here")
CLIENT_SECRET = os.getenv("GENESYS_CLIENT_SECRET", "your_client_secret_here")
ENVIRONMENT = os.getenv("GENESYS_ENVIRONMENT", "mypurecloud.com")
EXTERNAL_ID = os.getenv("TARGET_EXTERNAL_ID", "test_user_123")

# --- Authentication Module ---
class GenesysAuth:
    def __init__(self, client_id: str, client_secret: str, environment: str):
        self.client_id = client_id
        self.client_secret = client_secret
        self.environment = environment
        self.token_url = f"https://{environment}/oauth/token"
        self.access_token: Optional[str] = None
        self.token_expiry: float = 0

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

        payload = {
            "grant_type": "client_credentials",
            "client_id": self.client_id,
            "client_secret": self.client_secret
        }

        try:
            response = requests.post(self.token_url, data=payload, timeout=10)
            response.raise_for_status()
            token_data = response.json()
            self.access_token = token_data["access_token"]
            self.token_expiry = time.time() + token_data["expires_in"]
            return self.access_token
        except requests.exceptions.HTTPError as e:
            raise ValueError(f"Authentication failed: {e}") from e

# --- Web Chat Management Module ---
class WebChatManager:
    def __init__(self, auth: GenesysAuth):
        self.auth = auth
        self.base_url = f"https://{auth.environment}/api/v2"
        self.session = requests.Session()

    def _get_headers(self) -> Dict[str, str]:
        return {
            "Authorization": f"Bearer {self.auth.get_access_token()}",
            "Content-Type": "application/json"
        }

    def find_active_conversation_by_external_id(self, external_id: str) -> Optional[str]:
        """
        Finds the most recent active conversation for a given external ID.
        """
        since = (datetime.datetime.utcnow() - datetime.timedelta(minutes=10)).isoformat() + "Z"
        params = {"since": since, "pageSize": 100}
        
        try:
            response = self.session.get(
                f"{self.base_url}/conversations/webchat",
                headers=self._get_headers(),
                params=params,
                timeout=15
            )
            response.raise_for_status()
            
            conversations = response.json().get("entities", [])
            
            # Sort by timestamp descending to get the most recent
            conversations.sort(key=lambda x: x.get("timestamp", ""), reverse=True)
            
            for conv in conversations:
                # Only consider active conversations
                if conv.get("state") != "active":
                    continue
                    
                for participant in conv.get("participants", []):
                    if participant.get("externalId") == external_id:
                        return conv["id"]
            
            return None

        except requests.exceptions.HTTPError as e:
            print(f"Error finding conversation: {e}")
            return None

    def close_conversation(self, conversation_id: str) -> bool:
        """
        Ends the conversation. Implements retry logic for 429s.
        """
        url = f"{self.base_url}/conversations/{conversation_id}/end"
        payload = {"reason": "Programmatically closed by backend service"}
        
        max_retries = 3
        for attempt in range(max_retries):
            response = self.session.post(
                url,
                headers=self._get_headers(),
                json=payload,
                timeout=15
            )

            if response.status_code == 429:
                retry_after = int(response.headers.get("Retry-After", 2))
                print(f"Rate limited. Retrying in {retry_after} seconds...")
                time.sleep(retry_after + random.uniform(0, 1))
                continue
            
            if response.status_code == 204:
                return True
            
            if response.status_code == 404:
                print("Conversation not found or already ended.")
                return False
            
            if response.status_code == 409:
                print("Conversation already in terminal state.")
                return False

            response.raise_for_status()
        
        print("Failed to close conversation after retries.")
        return False

# --- Main Execution ---
def main():
    print(f"Starting Web Chat Closure Process for External ID: {EXTERNAL_ID}")
    
    try:
        # 1. Authenticate
        auth = GenesysAuth(CLIENT_ID, CLIENT_SECRET, ENVIRONMENT)
        manager = WebChatManager(auth)
        
        # 2. Find Conversation
        print("Searching for active conversation...")
        conv_id = manager.find_active_conversation_by_external_id(EXTERNAL_ID)
        
        if not conv_id:
            print("No active conversation found. Exiting.")
            return

        print(f"Found Conversation ID: {conv_id}")
        
        # 3. Close Conversation
        print("Initiating closure...")
        success = manager.close_conversation(conv_id)
        
        if success:
            print("Conversation closed successfully.")
        else:
            print("Failed to close conversation.")
            
    except Exception as e:
        print(f"Critical Error: {e}")
        import traceback
        traceback.print_exc()

if __name__ == "__main__":
    main()

Common Errors & Debugging

Error: 403 Forbidden

Cause: The service account lacks the required OAuth scopes.
Fix: Ensure the client has webchat:conversation:read and webchat:conversation:write. Go to the Genesys Cloud Admin Console → Platform → OAuth Clients → Edit your client → Scopes. Add the missing scopes and save. You must re-authenticate to get a new token with the updated scopes.

Error: 401 Unauthorized

Cause: Invalid Client ID/Secret, or token expiration.
Fix: Verify credentials. If using the GenesysAuth class, ensure token_expiry logic is correct. If the error persists, check if the client is disabled in the Admin Console.

Error: Conversation Not Found (404)

Cause: The conversation ID is invalid, or the conversation has already ended.
Fix: Check the find_active_conversation_by_external_id logic. Ensure the externalId matches exactly what is passed from the Web Chat widget. If the user has already disconnected, the conversation may already be closed, resulting in a 404 or 409 on the end endpoint.

Error: 429 Too Many Requests

Cause: Exceeding API rate limits.
Fix: Implement the retry logic shown in close_conversation. Monitor the Retry-After header. If you are processing high volumes, consider using the Bulk API endpoints if available, or space out your requests.

Official References