Build a Custom Chat UI with Genesys Cloud Guest WebSocket API

Build a Custom Chat UI with Genesys Cloud Guest WebSocket API

What You Will Build

  • A fully functional chat interface that connects directly to Genesys Cloud using the WebSocket-based Guest API, bypassing the standard Web Messaging widget.
  • Implementation of the three-phase handshake: REST-based login, WebSocket upgrade, and message exchange.
  • A working example in JavaScript (Vanilla ES6) that handles connection lifecycle, message rendering, and error states.

Prerequisites

  • Platform: Genesys Cloud CX
  • API Version: v2 (Guest Messaging API)
  • Language: JavaScript (ES6+)
  • Dependencies: None (uses native WebSocket and fetch).
  • Required Scopes: None for the client-side implementation (the Guest API uses token-less authentication via a generated login token). However, your backend application must have the guest:login scope if you are generating login tokens server-side, or you will use the public endpoint directly.

Authentication Setup

The Guest API differs from standard Genesys Cloud APIs because it does not use OAuth2 Bearer tokens for the end-user session. Instead, it uses a two-step authentication process:

  1. REST Login: The client sends a POST to /api/v2/guest/message/logins to obtain a session token.
  2. WebSocket Upgrade: The client opens a WebSocket connection using that token to begin real-time communication.

This tutorial assumes you are connecting directly from a browser. In production, you should proxy the initial login request through your own backend to hide your Genesys Cloud siteName and region configuration from the client, but the WebSocket connection itself must originate from the client.

Step 1: The Login Request

To start a chat session, you must send a login request. This endpoint requires your Genesys Cloud organization’s siteName (e.g., mypurecloud.com) and region (e.g., us-east-1).

Endpoint: POST https://{siteName}.mypurecloud.com/api/v2/guest/message/logins

Request Body:

{
  "name": "John Doe",
  "email": "john.doe@example.com",
  "language": "en-US",
  "routing": {
    "queueId": "your-queue-id-here"
  }
}

JavaScript Implementation:

const CONFIG = {
  siteName: 'your-site-name', // e.g., 'acme'
  region: 'us-east-1',        // e.g., 'us-east-1', 'eu-west-1'
  queueId: '12345678-1234-1234-1234-123456789012' // Target Queue ID
};

async function initiateGuestLogin() {
  const loginUrl = `https://${CONFIG.siteName}.mypurecloud.com/api/v2/guest/message/logins`;
  
  const payload = {
    name: "Test User",
    email: "test@example.com",
    language: "en-US",
    routing: {
      queueId: CONFIG.queueId
    }
  };

  try {
    const response = await fetch(loginUrl, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(payload)
    });

    if (!response.ok) {
      const errorText = await response.text();
      throw new Error(`Login failed: ${response.status} - ${errorText}`);
    }

    const loginData = await response.json();
    
    // Critical fields for the next step
    return {
      sessionId: loginData.sessionId,
      token: loginData.token,
      websocketUrl: loginData.websocketUrl // Genesys provides the exact WS endpoint
    };

  } catch (error) {
    console.error("Guest Login Error:", error);
    throw error;
  }
}

Expected Response:
The response contains the sessionId and token required for the WebSocket handshake. It also includes websocketUrl, which is the exact endpoint to connect to. Do not construct this URL manually; use the one provided in the response.

Implementation

Step 2: Establishing the WebSocket Connection

Once you have the token and websocketUrl, you must open a WebSocket connection. Genesys Cloud expects specific query parameters or headers depending on the SDK, but for raw WebSocket connections, the URL provided in the login response is pre-configured with the necessary authentication parameters.

Important: The WebSocket connection is persistent. You must handle onopen, onmessage, onclose, and onerror.

class GuestChatClient {
  constructor(sessionData) {
    this.sessionId = sessionData.sessionId;
    this.token = sessionData.token;
    this.wsUrl = sessionData.websocketUrl;
    this.ws = null;
    this.isConnected = false;
    this.reconnectAttempts = 0;
    this.maxReconnectAttempts = 5;
  }

  connect() {
    if (this.ws && this.ws.readyState === WebSocket.OPEN) {
      console.log("Already connected");
      return;
    }

    console.log(`Connecting to WebSocket: ${this.wsUrl}`);
    
    // Note: The wsUrl from the login response usually includes the token as a query param
    // or requires it in the protocol. We use the URL directly as provided.
    this.ws = new WebSocket(this.wsUrl);

    this.ws.onopen = () => {
      console.log("WebSocket Connected");
      this.isConnected = true;
      this.reconnectAttempts = 0;
      this.notifyStatus("Connected");
    };

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

    this.ws.onclose = (event) => {
      console.log(`WebSocket Closed: Code ${event.code}, Reason: ${event.reason}`);
      this.isConnected = false;
      this.handleDisconnect(event);
    };

    this.ws.onerror = (error) => {
      console.error("WebSocket Error:", error);
      this.notifyStatus("Connection Error");
    };
  }

  handleMessage(rawData) {
    try {
      const message = JSON.parse(rawData);
      console.log("Received Message:", message);
      
      // Handle specific message types
      switch (message.type) {
        case 'conversation':
          this.handleConversationUpdate(message);
          break;
        case 'message':
          this.handleChatMessage(message);
          break;
        case 'error':
          this.handleApiError(message);
          break;
        default:
          console.log("Unknown message type:", message.type);
      }
    } catch (e) {
      console.error("Failed to parse WebSocket message:", e);
    }
  }

  handleChatMessage(msg) {
    // msg.payload contains the actual chat content
    if (msg.payload && msg.payload.content) {
      this.renderMessage({
        sender: msg.payload.from === this.sessionId ? 'me' : 'agent',
        text: msg.payload.content,
        timestamp: msg.payload.timestamp
      });
    }
  }

  handleConversationUpdate(msg) {
    // Handles events like 'agentJoined', 'queuePosition', etc.
    if (msg.payload && msg.payload.event) {
      const event = msg.payload.event;
      if (event === 'agentJoined') {
        this.notifyStatus("Agent Connected");
      } else if (event === 'queuePosition') {
        this.notifyStatus(`Position in queue: ${msg.payload.position}`);
      }
    }
  }

  handleApiError(msg) {
    console.error("Genesys API Error:", msg.payload);
    this.notifyStatus("Service Error");
  }

  handleDisconnect(event) {
    // Simple reconnection logic
    if (this.reconnectAttempts < this.maxReconnectAttempts) {
      this.reconnectAttempts++;
      const delay = Math.pow(2, this.reconnectAttempts) * 1000; // Exponential backoff
      console.log(`Reconnecting in ${delay}ms... (Attempt ${this.reconnectAttempts})`);
      setTimeout(() => this.connect(), delay);
    } else {
      this.notifyStatus("Max Reconnect Attempts Reached");
    }
  }

  sendMessage(text) {
    if (!this.isConnected) {
      console.error("Cannot send message: Not connected");
      return;
    }

    const messagePayload = {
      type: 'message',
      payload: {
        content: text,
        from: this.sessionId,
        timestamp: new Date().toISOString()
      }
    };

    this.ws.send(JSON.stringify(messagePayload));
  }

  disconnect() {
    if (this.ws) {
      this.ws.close(1000, "Client disconnecting");
    }
  }

  // UI Integration Hooks
  renderMessage(msg) {
    const chatContainer = document.getElementById('chat-messages');
    if (!chatContainer) return;

    const div = document.createElement('div');
    div.className = `message ${msg.sender}`;
    div.innerHTML = `<strong>${msg.sender === 'me' ? 'You' : 'Agent'}:</strong> ${msg.text}`;
    chatContainer.appendChild(div);
    chatContainer.scrollTop = chatContainer.scrollHeight;
  }

  notifyStatus(status) {
    const statusEl = document.getElementById('connection-status');
    if (statusEl) {
      statusEl.textContent = status;
    }
  }
}

Step 3: Processing Results and Lifecycle Management

The Guest API sends various event types over the WebSocket. You must filter these to update your UI correctly. The most common types are:

  1. message: A text chat message from the agent or system.
  2. conversation: Metadata updates (e.g., agent joined, transfer, hold).
  3. typing: Indicates the other party is typing (if supported by the queue configuration).

Error Handling Strategy:
WebSocket errors can be silent. Always implement a heartbeat or ping mechanism if the connection is idle for long periods. Genesys Cloud supports standard WebSocket ping/pong frames, but browser implementations vary. The code above includes a reconnection loop, which is the most robust way to handle network drops.

Sending Messages:
When sending a message, you must structure the JSON payload exactly as expected by the Genesys Cloud gateway. The type must be message, and the payload must include content, from, and timestamp.

Complete Working Example

This HTML file contains the complete logic. Save it as index.html and open it in a browser. You must replace CONFIG values with your actual Genesys Cloud details.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Custom Genesys Cloud Chat</title>
    <style>
        body { font-family: sans-serif; max-width: 600px; margin: 2rem auto; padding: 0 1rem; }
        #chat-container { border: 1px solid #ccc; height: 400px; overflow-y: scroll; padding: 1rem; margin-bottom: 1rem; background: #f9f9f9; }
        .message { margin: 0.5rem 0; padding: 0.5rem; border-radius: 4px; }
        .message.me { background: #e3f2fd; text-align: right; }
        .message.agent { background: #fff3e0; text-align: left; }
        #input-area { display: flex; gap: 0.5rem; }
        #message-input { flex: 1; padding: 0.5rem; }
        button { padding: 0.5rem 1rem; cursor: pointer; }
        #connection-status { font-weight: bold; color: #666; margin-bottom: 1rem; }
        .error { color: red; }
    </style>
</head>
<body>

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

    <script>
        const CONFIG = {
            siteName: 'your-site-name', // REPLACE THIS
            region: 'us-east-1',        // REPLACE THIS
            queueId: 'your-queue-id'    // REPLACE THIS
        };

        let chatClient = null;

        const startBtn = document.getElementById('start-btn');
        const sendBtn = document.getElementById('send-btn');
        const messageInput = document.getElementById('message-input');

        startBtn.addEventListener('click', async () => {
            try {
                startBtn.disabled = true;
                startBtn.textContent = "Connecting...";
                
                // Step 1: Login
                const sessionData = await initiateGuestLogin();
                
                // Step 2: Initialize Client
                chatClient = new GuestChatClient(sessionData);
                chatClient.connect();

                // Enable UI
                messageInput.disabled = false;
                sendBtn.disabled = false;
                startBtn.textContent = "Connected";
                
            } catch (err) {
                console.error(err);
                document.getElementById('connection-status').textContent = "Login Failed";
                document.getElementById('connection-status').classList.add('error');
                startBtn.disabled = false;
                startBtn.textContent = "Retry";
            }
        });

        sendBtn.addEventListener('click', () => {
            const text = messageInput.value.trim();
            if (text && chatClient) {
                chatClient.sendMessage(text);
                // Render local message immediately for better UX
                chatClient.renderMessage({
                    sender: 'me',
                    text: text,
                    timestamp: new Date().toISOString()
                });
                messageInput.value = '';
            }
        });

        messageInput.addEventListener('keypress', (e) => {
            if (e.key === 'Enter') sendBtn.click();
        });

        // --- Functions from Previous Sections ---

        async function initiateGuestLogin() {
            const loginUrl = `https://${CONFIG.siteName}.mypurecloud.com/api/v2/guest/message/logins`;
            
            const payload = {
                name: "Test User",
                email: "test@example.com",
                language: "en-US",
                routing: {
                    queueId: CONFIG.queueId
                }
            };

            const response = await fetch(loginUrl, {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify(payload)
            });

            if (!response.ok) {
                const errorText = await response.text();
                throw new Error(`Login failed: ${response.status} - ${errorText}`);
            }

            return await response.json();
        }

        class GuestChatClient {
            constructor(sessionData) {
                this.sessionId = sessionData.sessionId;
                this.token = sessionData.token;
                this.wsUrl = sessionData.websocketUrl;
                this.ws = null;
                this.isConnected = false;
                this.reconnectAttempts = 0;
                this.maxReconnectAttempts = 5;
            }

            connect() {
                if (this.ws && this.ws.readyState === WebSocket.OPEN) return;

                this.ws = new WebSocket(this.wsUrl);

                this.ws.onopen = () => {
                    this.isConnected = true;
                    this.reconnectAttempts = 0;
                    this.notifyStatus("Connected");
                };

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

                this.ws.onclose = (event) => {
                    this.isConnected = false;
                    this.handleDisconnect(event);
                };

                this.ws.onerror = (error) => {
                    this.notifyStatus("Connection Error");
                };
            }

            handleMessage(rawData) {
                try {
                    const message = JSON.parse(rawData);
                    if (message.type === 'message' && message.payload && message.payload.content) {
                        // Only render if not from me (to avoid duplicates if we render locally)
                        if (message.payload.from !== this.sessionId) {
                            this.renderMessage({
                                sender: 'agent',
                                text: message.payload.content,
                                timestamp: message.payload.timestamp
                            });
                        }
                    }
                } catch (e) {
                    console.error("Parse error", e);
                }
            }

            handleDisconnect(event) {
                if (this.reconnectAttempts < this.maxReconnectAttempts) {
                    this.reconnectAttempts++;
                    setTimeout(() => this.connect(), Math.pow(2, this.reconnectAttempts) * 1000);
                } else {
                    this.notifyStatus("Disconnected permanently");
                }
            }

            sendMessage(text) {
                if (!this.isConnected) return;
                const messagePayload = {
                    type: 'message',
                    payload: {
                        content: text,
                        from: this.sessionId,
                        timestamp: new Date().toISOString()
                    }
                };
                this.ws.send(JSON.stringify(messagePayload));
            }

            renderMessage(msg) {
                const chatContainer = document.getElementById('chat-messages');
                const div = document.createElement('div');
                div.className = `message ${msg.sender}`;
                div.innerHTML = `<strong>${msg.sender === 'me' ? 'You' : 'Agent'}:</strong> ${msg.text}`;
                chatContainer.appendChild(div);
                chatContainer.scrollTop = chatContainer.scrollHeight;
            }

            notifyStatus(status) {
                const statusEl = document.getElementById('connection-status');
                if (statusEl) statusEl.textContent = status;
            }
        }
    </script>
</body>
</html>

Common Errors & Debugging

Error: 401 Unauthorized on Login

Cause: The siteName or queueId is incorrect, or the queue is not configured to accept guest messages.

Fix:

  1. Verify siteName matches your Genesys Cloud URL (e.g., if your URL is acme.mypurecloud.com, siteName is acme).
  2. Verify queueId is a valid Queue ID in your organization.
  3. Ensure the Queue has “Web Messaging” enabled in the Genesys Cloud Admin console.

Error: WebSocket Connection Refused

Cause: The websocketUrl obtained from the login response is malformed or the token has expired before the WebSocket handshake.

Fix:

  1. Log the websocketUrl immediately after login. It should look like wss://websocket.mypurecloud.com/....
  2. Ensure you do not delay the connect() call. The login token expires quickly (usually within 30-60 seconds).
  3. Check browser console for CORS errors. The login endpoint supports CORS, but ensure your domain is allowed if you have strict CSP settings.

Error: Messages Not Appearing

Cause: The client is sending messages, but the server is not responding, or the client is filtering out messages incorrectly.

Fix:

  1. Check the onmessage handler. Ensure you are parsing event.data as JSON.
  2. Verify that the from field in the incoming message matches the sessionId of the agent, not the guest.
  3. Check if the agent has accepted the chat. The guest will see queuePosition events until an agent joins. Messages sent before an agent joins are queued by Genesys and delivered to the agent upon join.

Official References