Build a Custom Chat UI with the Genesys Cloud Guest WebSocket API

Build a Custom Chat UI with the Genesys Cloud Guest WebSocket API

What You Will Build

  • You will build a functional chat interface that connects directly to Genesys Cloud via WebSockets, bypassing the standard embedded widget.
  • This implementation uses the Genesys Cloud Guest API (WebSocket) for real-time messaging and the REST API for session initialization.
  • The tutorial covers JavaScript (frontend) and Python (backend proxy for OAuth) to handle authentication securely.

Prerequisites

  • Genesys Cloud Account: An organization with Genesys Cloud Messaging enabled.
  • OAuth Application: A “Confidential Client” application created in the Genesys Cloud Admin Portal.
  • Required Scopes:
    • webchat:guest (for WebSocket connection)
    • webchat:session:write (to create the chat session)
    • webchat:session:read (to verify session status)
  • Runtime: Node.js 18+ for the frontend logic demonstration, Python 3.9+ for the authentication backend.
  • Dependencies:
    • Frontend: No external libraries required; uses native WebSocket and fetch.
    • Backend: requests for Python.

Authentication Setup

The Guest API requires a valid OAuth access token to establish the initial WebSocket handshake. Because the token contains sensitive credentials, you must never expose your OAuth Client Secret in frontend JavaScript. You will build a small backend endpoint to exchange your client credentials for a token.

Step 1: Create the Token Endpoint (Python)

Create a file named auth_server.py. This script exposes a simple HTTP endpoint that returns a short-lived access token.

import requests
from flask import Flask, jsonify

app = Flask(__name__)

# Replace these with your actual Genesys Cloud OAuth credentials
CLIENT_ID = "your-client-id"
CLIENT_SECRET = "your-client-secret"
BASE_URL = "https://api.mypurecloud.com"  # Use your specific region

def get_access_token():
    """
    Retrieves an OAuth access token using the Client Credentials flow.
    Scope: webchat:guest webchat:session:write
    """
    url = f"{BASE_URL}/oauth/token"
    headers = {
        "Content-Type": "application/x-www-form-urlencoded"
    }
    data = {
        "grant_type": "client_credentials",
        "client_id": CLIENT_ID,
        "client_secret": CLIENT_SECRET,
        "scope": "webchat:guest webchat:session:write"
    }

    try:
        response = requests.post(url, headers=headers, data=data)
        response.raise_for_status()
        return response.json()
    except requests.exceptions.HTTPError as e:
        print(f"OAuth Error: {e.response.text}")
        return None

@app.route('/api/token', methods=['GET'])
def token_endpoint():
    """
    Endpoint for the frontend to request a valid Genesys Cloud token.
    In production, add rate limiting and caching here.
    """
    token_data = get_access_token()
    if token_data:
        return jsonify({
            "access_token": token_data["access_token"],
            "expires_in": token_data["expires_in"]
        })
    return jsonify({"error": "Failed to retrieve token"}), 500

if __name__ == '__main__':
    app.run(port=5000)

Run this server locally: python auth_server.py. It will listen on http://localhost:5000.

Implementation

Step 1: Initialize the Chat Session via REST

Before opening a WebSocket connection, you must create a chat session using the REST API. This returns a sessionId and a guestId, which are required for the WebSocket handshake.

Create a file named chat-ui.html. This file contains the HTML structure and the JavaScript logic.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Custom Genesys Chat UI</title>
    <style>
        body { font-family: sans-serif; padding: 20px; }
        #chat-container { width: 400px; border: 1px solid #ccc; height: 500px; display: flex; flex-direction: column; }
        #messages { flex: 1; overflow-y: auto; padding: 10px; border-bottom: 1px solid #eee; }
        .message { margin-bottom: 10px; padding: 8px; border-radius: 5px; }
        .agent { background-color: #e0f7fa; align-self: flex-start; }
        .guest { background-color: #fff3e0; align-self: flex-end; text-align: right; }
        #input-area { display: flex; padding: 10px; }
        #message-input { flex: 1; padding: 8px; margin-right: 10px; }
        #send-btn { padding: 8px 16px; cursor: pointer; }
        #status { font-size: 0.8em; color: #666; margin-bottom: 10px; }
    </style>
</head>
<body>

    <h2>Custom Genesys Chat</h2>
    <div id="status">Disconnected</div>
    <div id="chat-container">
        <div id="messages"></div>
        <div id="input-area">
            <input type="text" id="message-input" placeholder="Type a message..." disabled>
            <button id="send-btn" disabled>Send</button>
        </div>
    </div>

<script>
    // Configuration
    const REGION = "mypurecloud.com"; // e.g., usw2.pure.cloud
    const SUBDOMAIN = "your-subdomain"; // Your Genesys Cloud org subdomain
    const WS_URL = `wss://api.${REGION}/api/v2/webchat/guest`;
    const REST_URL = `https://api.${REGION}/api/v2/webchat/sessions`;
    const TOKEN_ENDPOINT = "http://localhost:5000/api/token";

    let accessToken = null;
    let sessionId = null;
    let guestId = null;
    let ws = null;

    const statusEl = document.getElementById('status');
    const messagesEl = document.getElementById('messages');
    const inputEl = document.getElementById('message-input');
    const sendBtn = document.getElementById('send-btn');

    // 1. Start the Connection Flow
    async function startChat() {
        try {
            statusEl.textContent = "Authenticating...";
            
            // Fetch token from our backend
            const tokenResponse = await fetch(TOKEN_ENDPOINT);
            const tokenData = await tokenResponse.json();
            
            if (!tokenData.access_token) {
                throw new Error("Failed to get access token");
            }
            accessToken = tokenData.access_token;

            statusEl.textContent = "Creating Session...";
            
            // Create Session via REST API
            const sessionResponse = await fetch(REST_URL, {
                method: 'POST',
                headers: {
                    'Authorization': `Bearer ${accessToken}`,
                    'Content-Type': 'application/json'
                },
                body: JSON.stringify({
                    "capabilities": ["text"],
                    "routing": {
                        "skill": {
                            "name": "General Support" // Must match a skill in your org
                        }
                    }
                })
            });

            if (!sessionResponse.ok) {
                const errText = await sessionResponse.text();
                throw new Error(`Session creation failed: ${errText}`);
            }

            const sessionData = await sessionResponse.json();
            sessionId = sessionData.id;
            guestId = sessionData.guestId;

            statusEl.textContent = "Connecting to WebSocket...";
            
            // Establish WebSocket Connection
            connectWebSocket();

        } catch (error) {
            console.error(error);
            statusEl.textContent = `Error: ${error.message}`;
        }
    }

    // 2. WebSocket Connection Logic
    function connectWebSocket() {
        // The WebSocket URL must include the token and session details as query parameters
        // Note: Genesys Cloud Guest API expects specific query params for handshake
        const wsEndpoint = `${WS_URL}?access_token=${accessToken}&sessionId=${sessionId}&guestId=${guestId}`;

        ws = new WebSocket(wsEndpoint);

        ws.onopen = () => {
            statusEl.textContent = "Connected";
            inputEl.disabled = false;
            sendBtn.disabled = false;
            addMessage("system", "Connected to Genesys Cloud");
        };

        ws.onmessage = (event) => {
            handleMessage(JSON.parse(event.data));
        };

        ws.onerror = (error) => {
            statusEl.textContent = "WebSocket Error";
            console.error("WebSocket Error:", error);
        };

        ws.onclose = (event) => {
            statusEl.textContent = "Disconnected";
            inputEl.disabled = true;
            sendBtn.disabled = true;
            addMessage("system", `Connection closed: ${event.reason}`);
        };
    }

    // 3. Handle Incoming Messages
    function handleMessage(msg) {
        console.log("Received:", msg);

        if (msg.type === "message") {
            // This is a chat message from an agent or bot
            const sender = msg.from === guestId ? "guest" : "agent";
            addMessage(sender, msg.body);
        } 
        else if (msg.type === "typing") {
            // Handle typing indicators if desired
            console.log(`${msg.from} is typing...`);
        }
        else if (msg.type === "error") {
            addMessage("system", `Error: ${msg.body}`);
        }
    }

    // 4. Send Messages
    function sendMessage() {
        const text = inputEl.value.trim();
        if (!text || !ws || ws.readyState !== WebSocket.OPEN) return;

        // Construct the message payload
        const payload = {
            "type": "message",
            "body": text,
            "timestamp": new Date().toISOString()
        };

        ws.send(JSON.stringify(payload));
        addMessage("guest", text);
        inputEl.value = "";
    }

    // UI Helper
    function addMessage(sender, text) {
        const div = document.createElement('div');
        div.className = `message ${sender}`;
        div.textContent = text;
        messagesEl.appendChild(div);
        messagesEl.scrollTop = messagesEl.scrollHeight;
    }

    // Event Listeners
    sendBtn.addEventListener('click', sendMessage);
    inputEl.addEventListener('keypress', (e) => {
        if (e.key === 'Enter') sendMessage();
    });

    // Initialize on load
    startChat();

</script>
</body>
</html>

Step 2: Understanding the WebSocket Payload Structure

The Genesys Cloud Guest API uses a strict JSON structure for messages. When you send a message, the payload must include a unique id for each message to ensure delivery confirmation and ordering. The previous example simplified this for brevity, but production code should generate UUIDs.

Update the sendMessage function in the JavaScript above to include message IDs and handle acknowledgments:

// Helper to generate simple UUIDs
function generateUUID() {
    return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
        var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
        return v.toString(16);
    });
}

function sendMessage() {
    const text = inputEl.value.trim();
    if (!text || !ws || ws.readyState !== WebSocket.OPEN) return;

    const payload = {
        "id": generateUUID(), // Unique ID for this message
        "type": "message",
        "body": text,
        "timestamp": new Date().toISOString(),
        "from": guestId // Explicitly state the sender
    };

    ws.send(JSON.stringify(payload));
    
    // Optimistic UI update
    addMessage("guest", text);
    inputEl.value = "";
}

Step 3: Handling Reconnection and Rate Limits

WebSockets can drop due to network instability or server maintenance. Genesys Cloud may also return a 429 status during the initial REST session creation if you are hitting rate limits.

Handling 429 Rate Limits in Python

Update the get_access_token function in auth_server.py to include basic retry logic:

import time

def get_access_token_with_retry(retries=3, delay=1):
    url = f"{BASE_URL}/oauth/token"
    headers = {"Content-Type": "application/x-www-form-urlencoded"}
    data = {
        "grant_type": "client_credentials",
        "client_id": CLIENT_ID,
        "client_secret": CLIENT_SECRET,
        "scope": "webchat:guest webchat:session:write"
    }

    for attempt in range(retries):
        try:
            response = requests.post(url, headers=headers, data=data)
            
            if response.status_code == 429:
                # Rate limited. Wait and retry.
                wait_time = delay * (2 ** attempt)
                print(f"Rate limited. Retrying in {wait_time} seconds...")
                time.sleep(wait_time)
                continue
                
            response.raise_for_status()
            return response.json()
            
        except requests.exceptions.RequestException as e:
            print(f"Request error: {e}")
            time.sleep(delay)

    return None

Handling WebSocket Disconnection in JavaScript

Add a reconnection mechanism to the ws.onclose handler. If the connection drops unexpectedly, attempt to reconnect after a short delay.

let reconnectAttempts = 0;
const MAX_RECONNECT_ATTEMPTS = 5;
const RECONNECT_DELAY_MS = 3000;

ws.onclose = (event) => {
    statusEl.textContent = "Disconnected";
    inputEl.disabled = true;
    sendBtn.disabled = true;
    
    // If the close was not clean (code 1000), try to reconnect
    if (event.code !== 1000 && reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
        statusEl.textContent = `Reconnecting... (${reconnectAttempts + 1})`;
        setTimeout(() => {
            reconnectAttempts++;
            connectWebSocket();
        }, RECONNECT_DELAY_MS);
    } else {
        addMessage("system", "Connection lost. Please refresh.");
    }
};

// Reset reconnect attempts on successful open
ws.onopen = () => {
    reconnectAttempts = 0;
    statusEl.textContent = "Connected";
    inputEl.disabled = false;
    sendBtn.disabled = false;
};

Complete Working Example

Below is the consolidated chat-ui.html file. Ensure your auth_server.py is running on port 5000 before opening this file in a browser.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Genesys Cloud Custom Chat</title>
    <style>
        body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background: #f5f5f5; padding: 20px; display: flex; justify-content: center; }
        #chat-container { width: 400px; background: white; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); display: flex; flex-direction: column; height: 600px; }
        #header { padding: 15px; background: #0079bf; color: white; font-weight: bold; border-radius: 8px 8px 0 0; display: flex; justify-content: space-between; align-items: center; }
        #status { font-size: 0.8em; font-weight: normal; opacity: 0.9; }
        #messages { flex: 1; overflow-y: auto; padding: 15px; background: #fafafa; }
        .message { margin-bottom: 12px; padding: 10px 15px; border-radius: 18px; max-width: 80%; word-wrap: break-word; }
        .agent { background-color: #e0f7fa; align-self: flex-start; border-bottom-left-radius: 4px; }
        .guest { background-color: #0079bf; color: white; align-self: flex-end; border-bottom-right-radius: 4px; margin-left: auto; }
        .system { background: none; color: #888; font-size: 0.8em; text-align: center; max-width: 100%; }
        #input-area { padding: 15px; border-top: 1px solid #eee; display: flex; gap: 10px; }
        #message-input { flex: 1; padding: 10px; border: 1px solid #ddd; border-radius: 20px; outline: none; }
        #send-btn { padding: 10px 20px; background: #0079bf; color: white; border: none; border-radius: 20px; cursor: pointer; }
        #send-btn:disabled { background: #ccc; cursor: not-allowed; }
    </style>
</head>
<body>

<div id="chat-container">
    <div id="header">
        <span>Support Chat</span>
        <span id="status">Connecting...</span>
    </div>
    <div id="messages"></div>
    <div id="input-area">
        <input type="text" id="message-input" placeholder="Type a message..." disabled>
        <button id="send-btn" disabled>Send</button>
    </div>
</div>

<script>
    // CONFIGURATION
    const REGION = "mypurecloud.com"; 
    const SUBDOMAIN = "your-subdomain"; 
    const TOKEN_ENDPOINT = "http://localhost:5000/api/token";
    
    let accessToken = null;
    let sessionId = null;
    let guestId = null;
    let ws = null;
    let reconnectAttempts = 0;

    const statusEl = document.getElementById('status');
    const messagesEl = document.getElementById('messages');
    const inputEl = document.getElementById('message-input');
    const sendBtn = document.getElementById('send-btn');

    async function startChat() {
        try {
            statusEl.textContent = "Authenticating...";
            const tokenResponse = await fetch(TOKEN_ENDPOINT);
            const tokenData = await tokenResponse.json();
            
            if (!tokenData.access_token) throw new Error("Token fetch failed");
            accessToken = tokenData.access_token;

            statusEl.textContent = "Creating Session...";
            const sessionResponse = await fetch(`https://api.${REGION}/api/v2/webchat/sessions`, {
                method: 'POST',
                headers: {
                    'Authorization': `Bearer ${accessToken}`,
                    'Content-Type': 'application/json'
                },
                body: JSON.stringify({
                    "capabilities": ["text"],
                    "routing": { "skill": { "name": "General Support" } }
                })
            });

            if (!sessionResponse.ok) {
                const err = await sessionResponse.text();
                throw new Error(`Session Error: ${err}`);
            }

            const sessionData = await sessionResponse.json();
            sessionId = sessionData.id;
            guestId = sessionData.guestId;

            connectWebSocket();

        } catch (error) {
            console.error(error);
            statusEl.textContent = "Error";
            addMessage("system", `Init Error: ${error.message}`);
        }
    }

    function connectWebSocket() {
        if (ws && ws.readyState === WebSocket.OPEN) ws.close();

        const wsUrl = `wss://api.${REGION}/api/v2/webchat/guest?access_token=${accessToken}&sessionId=${sessionId}&guestId=${guestId}`;
        ws = new WebSocket(wsUrl);

        ws.onopen = () => {
            reconnectAttempts = 0;
            statusEl.textContent = "Online";
            inputEl.disabled = false;
            sendBtn.disabled = false;
        };

        ws.onmessage = (event) => {
            const msg = JSON.parse(event.data);
            if (msg.type === "message") {
                const sender = msg.from === guestId ? "guest" : "agent";
                addMessage(sender, msg.body);
            }
        };

        ws.onerror = (e) => console.error("WS Error", e);

        ws.onclose = (e) => {
            inputEl.disabled = true;
            sendBtn.disabled = true;
            if (e.code !== 1000 && reconnectAttempts < 5) {
                statusEl.textContent = "Reconnecting...";
                setTimeout(() => {
                    reconnectAttempts++;
                    connectWebSocket();
                }, 3000);
            } else {
                statusEl.textContent = "Offline";
                addMessage("system", "Connection lost.");
            }
        };
    }

    function sendMessage() {
        const text = inputEl.value.trim();
        if (!text || !ws || ws.readyState !== WebSocket.OPEN) return;

        const payload = {
            "id": crypto.randomUUID(),
            "type": "message",
            "body": text,
            "timestamp": new Date().toISOString(),
            "from": guestId
        };

        ws.send(JSON.stringify(payload));
        addMessage("guest", text);
        inputEl.value = "";
    }

    function addMessage(sender, text) {
        const div = document.createElement('div');
        div.className = `message ${sender}`;
        div.textContent = text;
        messagesEl.appendChild(div);
        messagesEl.scrollTop = messagesEl.scrollHeight;
    }

    sendBtn.addEventListener('click', sendMessage);
    inputEl.addEventListener('keypress', (e) => e.key === 'Enter' && sendMessage());

    startChat();
</script>
</body>
</html>

Common Errors & Debugging

Error: 401 Unauthorized on WebSocket Handshake

Cause: The access token provided in the WebSocket query parameter is expired or invalid.
Fix: Ensure your Python backend returns a fresh token. The Guest API token typically expires in 3600 seconds. If your session lasts longer, you must implement token refresh logic on the backend and reconnect the WebSocket with the new token.

Error: 403 Forbidden on Session Creation

Cause: The OAuth token lacks the webchat:session:write scope, or the specified Skill (“General Support” in the example) does not exist in your Genesys Cloud organization.
Fix:

  1. Check your OAuth App settings in the Admin Portal and ensure webchat:session:write is selected.
  2. Verify the skill name in the routing object matches an active skill in your Genesys Cloud org.

Error: WebSocket Connection Refused

Cause: The region URL is incorrect. Genesys Cloud regions vary (e.g., usw2.pure.cloud, au02.pure.cloud).
Fix: Confirm your organization’s region URL. You can find this in the Genesys Cloud Admin Portal under Setup > Organization. Replace mypurecloud.com in the code with your specific region.

Error: Message Not Received by Agent

Cause: The guestId in the outgoing message payload does not match the guestId from the session creation response.
Fix: Ensure you are using the guestId returned by the POST /api/v2/webchat/sessions call in every outgoing WebSocket message.

Official References