How to build a custom chat UI using the WebSocket-based Guest API instead of the default widget
What You Will Build
- A fully functional, custom chat interface that connects directly to Genesys Cloud CX without using the pre-built Web Messaging Widget.
- The application will handle WebSocket handshakes, message transmission, typing indicators, and session lifecycle management using the Genesys Cloud Guest API.
- The tutorial covers implementation in JavaScript (Node.js environment for backend logic or browser-side with appropriate CORS handling) and Python for the initial OAuth token acquisition.
Prerequisites
- OAuth Client Type: Public Client (Client Credentials flow is not supported for guest-initiated sessions; you must use the Guest Token flow or a Public Client with
openidscope). - Required Scopes:
chat:guest:write,chat:guest:read. For full session control,chat:session:writemay be needed depending on your deployment mode. - API Version: Genesys Cloud CX API v2.
- Language/Runtime: Node.js 18+ (for the WebSocket client) and Python 3.9+ (for token acquisition).
- External Dependencies:
- Node.js:
ws(WebSocket library),axios(HTTP requests). - Python:
requests.
- Node.js:
- Genesys Cloud Configuration: You must have a Web Messaging Channel configured in your Genesys Cloud organization with a valid Messaging Site ID and Messaging Channel ID.
Authentication Setup
The Guest API does not use standard Bearer tokens for the WebSocket connection. Instead, it uses a two-step process:
- Obtain a temporary guest token via the REST API.
- Use that token to establish the WebSocket connection.
Step 1: Obtaining the Guest Token
You must call the POST /api/v2/engagements/guests endpoint. This endpoint returns a guestToken and a sessionId.
Python Implementation for Token Acquisition
import requests
import json
def get_guest_token(org_id: str, client_id: str, channel_id: str) -> dict:
"""
Acquires a guest token for the Web Messaging Guest API.
Args:
org_id: Your Genesys Cloud Organization ID.
client_id: The Client ID of your Public OAuth Client.
channel_id: The ID of the Web Messaging Channel.
Returns:
A dictionary containing 'guestToken', 'sessionId', and 'expiresAt'.
"""
url = f"https://{org_id}.mypurecloud.com/api/v2/engagements/guests"
headers = {
"Content-Type": "application/json",
"Accept": "application/json"
}
payload = {
"channelId": channel_id,
"clientId": client_id,
"metadata": {
"customAttribute1": "customValue1" # Optional: Pass initial context
}
}
try:
response = requests.post(url, headers=headers, json=payload)
response.raise_for_status()
data = response.json()
return {
"guestToken": data.get("guestToken"),
"sessionId": data.get("sessionId"),
"expiresAt": data.get("expiresAt")
}
except requests.exceptions.HTTPError as http_err:
if response.status_code == 401:
print(f"Authentication failed. Check your Client ID and Organization ID.")
elif response.status_code == 403:
print(f"Permission denied. Ensure the OAuth Client has 'chat:guest:write' scope.")
else:
print(f"HTTP error occurred: {http_err}")
print(f"Response body: {response.text}")
except requests.exceptions.RequestException as err:
print(f"An error occurred: {err}")
return None
Required Scope: chat:guest:write
Endpoint: POST /api/v2/engagements/guests
Step 2: Establishing the WebSocket Connection
The WebSocket URL is dynamic. It uses the guestToken obtained above. The protocol is wss:// for secure connections.
JavaScript/Node.js Implementation for WebSocket Client
const WebSocket = require('ws');
class GuestChatClient {
constructor(orgId, guestToken, sessionId) {
this.orgId = orgId;
this.guestToken = guestToken;
this.sessionId = sessionId;
this.ws = null;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 5;
// Construct the WebSocket URL
// Note: The path is /api/v2/engagements/guests/{sessionId}/ws
this.wsUrl = `wss://${orgId}.mypurecloud.com/api/v2/engagements/guests/${sessionId}/ws?guestToken=${guestToken}`;
}
connect() {
console.log(`Connecting to WebSocket: ${this.wsUrl}`);
this.ws = new WebSocket(this.wsUrl);
this.ws.on('open', () => {
console.log('WebSocket connection established.');
this.reconnectAttempts = 0;
// Send initial handshake if required by specific channel config
// Most modern setups auto-negotiate on connection
});
this.ws.on('message', (data) => {
try {
const message = JSON.parse(data);
this.handleMessage(message);
} catch (e) {
console.error('Failed to parse WebSocket message:', e);
}
});
this.ws.on('close', (code, reason) => {
console.log(`WebSocket closed with code ${code}: ${reason}`);
this.handleReconnection();
});
this.ws.on('error', (error) => {
console.error('WebSocket error:', error);
});
}
handleMessage(message) {
console.log('Received Message:', JSON.stringify(message, null, 2));
// Handle specific message types from Genesys
switch (message.type) {
case 'message':
// This is a message from an agent or bot
this.onAgentMessage(message);
break;
case 'typing':
// Agent is typing
this.onAgentTyping(message);
break;
case 'sessionEnded':
// The chat session has ended
this.onSessionEnd(message);
break;
case 'error':
// API error
this.onApiError(message);
break;
default:
console.log('Unknown message type:', message.type);
}
}
onAgentMessage(msg) {
// Extract the actual text content
const content = msg.content || msg.body || '';
const sender = msg.from || 'Agent';
console.log(`[${sender}]: ${content}`);
// Trigger UI update here
}
onAgentTyping(msg) {
console.log('Agent is typing...');
// Trigger UI typing indicator here
}
onSessionEnd(msg) {
console.log('Session ended by:', msg.reason || 'System');
}
onApiError(msg) {
console.error('API Error:', msg.error || msg.message);
}
handleReconnection() {
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 {
console.error('Max reconnection attempts reached.');
}
}
sendTextMessage(text) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
const payload = {
type: 'message',
content: text,
// Optional: Add custom metadata
metadata: {
source: 'custom-ui'
}
};
this.ws.send(JSON.stringify(payload));
console.log('Sent message:', text);
} else {
console.error('WebSocket is not open. Cannot send message.');
}
}
sendTypingIndicator() {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
const payload = {
type: 'typing'
};
this.ws.send(JSON.stringify(payload));
}
}
endSession() {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
const payload = {
type: 'sessionEnded',
reason: 'Guest ended'
};
this.ws.send(JSON.stringify(payload));
this.ws.close();
}
}
}
module.exports = GuestChatClient;
Implementation
Step 1: Initializing the Session and Handling State
In a real-world application, you cannot rely on the WebSocket connection being permanent. Network drops, browser refreshes, and token expirations will occur. You must manage the state of the connection explicitly.
The GuestChatClient class above provides the foundation. You must wrap it in a controller that manages the lifecycle of the guestToken.
JavaScript Controller Example
const axios = require('axios');
const GuestChatClient = require('./GuestChatClient');
class ChatSessionManager {
constructor(orgId, clientId, channelId) {
this.orgId = orgId;
this.clientId = clientId;
this.channelId = channelId;
this.chatClient = null;
this.tokenData = null;
}
async startSession() {
try {
// Step 1: Get Token
console.log('Acquiring guest token...');
this.tokenData = await this.acquireToken();
if (!this.tokenData) {
throw new Error('Failed to acquire guest token');
}
console.log(`Token acquired. Session ID: ${this.tokenData.sessionId}`);
// Step 2: Initialize WebSocket Client
this.chatClient = new GuestChatClient(
this.orgId,
this.tokenData.guestToken,
this.tokenData.sessionId
);
// Step 3: Connect
this.chatClient.connect();
return this.tokenData.sessionId;
} catch (error) {
console.error('Failed to start chat session:', error);
throw error;
}
}
async acquireToken() {
const url = `https://${this.orgId}.mypurecloud.com/api/v2/engagements/guests`;
const payload = {
channelId: this.channelId,
clientId: this.clientId,
metadata: {
// You can pass user context here, e.g., email, name, custom attributes
// This data appears in the Genesys Cloud agent desktop
"displayName": "John Doe",
"email": "john.doe@example.com"
}
};
const response = await axios.post(url, payload, {
headers: {
'Content-Type': 'application/json'
}
});
return {
guestToken: response.data.guestToken,
sessionId: response.data.sessionId,
expiresAt: response.data.expiresAt
};
}
sendUserMessage(text) {
if (this.chatClient) {
this.chatClient.sendTextMessage(text);
} else {
console.error('Chat client not initialized');
}
}
endChat() {
if (this.chatClient) {
this.chatClient.endSession();
}
}
}
module.exports = ChatSessionManager;
Step 2: Handling Message Structure and Metadata
Genesys Cloud messages are not just plain text. They are structured JSON objects that can contain rich media, buttons, and cards. Your custom UI must parse these structures correctly.
Expected Message Structure from Agent/Bot
{
"type": "message",
"id": "msg_123456789",
"from": "agent",
"to": "guest",
"timestamp": "2023-10-27T10:00:00Z",
"content": "Hello, how can I help you today?",
"metadata": {
"sentiment": "positive"
}
}
Handling Rich Content (Cards)
If the bot sends a card, the content field might be a JSON string or an object containing card data. You must render this appropriately in your UI.
// Inside GuestChatClient.handleMessage()
onAgentMessage(msg) {
const content = msg.content;
// Check if content is a rich card
if (typeof content === 'string') {
try {
const parsed = JSON.parse(content);
if (parsed.type === 'card') {
this.renderCard(parsed);
return;
}
} catch (e) {
// Not JSON, treat as plain text
}
}
// Plain text fallback
console.log(`[${msg.from}]: ${content}`);
}
renderCard(cardData) {
console.log('Rendering Card:', cardData.title);
// Implement UI logic to render buttons, images, etc.
}
Step 3: Managing Typing Indicators and Heartbeats
To provide a responsive user experience, you must send typing indicators. Genesys Cloud expects a typing message type.
Sending Typing Indicators
// In your UI event handler
document.getElementById('chat-input').addEventListener('input', (e) => {
if (sessionManager.chatClient) {
// Debounce this call to avoid flooding the server
sessionManager.chatClient.sendTypingIndicator();
}
});
Heartbeat Mechanism
While the ws library handles ping/pong automatically, some Genesys Cloud configurations may require explicit keep-alive messages if the channel is configured for long idle times. However, the standard Guest API WebSocket is stateful and maintains connection via TCP keep-alives. If you notice silent disconnections, ensure your network infrastructure (load balancers, proxies) does not terminate idle WebSocket connections before the Genesys Cloud timeout.
Complete Working Example
Below is a complete, runnable Node.js script that demonstrates the full lifecycle: acquiring a token, connecting, sending a message, and handling the response.
custom-chat-demo.js
const axios = require('axios');
const WebSocket = require('ws');
// Configuration
const CONFIG = {
ORG_ID: 'your-org-id', // Replace with your Org ID
CLIENT_ID: 'your-public-client-id', // Replace with your Public Client ID
CHANNEL_ID: 'your-web-messaging-channel-id', // Replace with your Channel ID
MAX_RECONNECT_ATTEMPTS: 3
};
class SimpleGuestChat {
constructor(config) {
this.config = config;
this.ws = null;
this.sessionId = null;
this.guestToken = null;
}
async initialize() {
console.log('--- Starting Chat Session ---');
// 1. Acquire Token
const tokenData = await this.getGuestToken();
if (!tokenData) {
console.error('Failed to initialize session.');
process.exit(1);
}
this.sessionId = tokenData.sessionId;
this.guestToken = tokenData.guestToken;
console.log(`Session ID: ${this.sessionId}`);
// 2. Connect WebSocket
this.connectWebSocket();
}
async getGuestToken() {
const url = `https://${this.config.ORG_ID}.mypurecloud.com/api/v2/engagements/guests`;
const payload = {
channelId: this.config.CHANNEL_ID,
clientId: this.config.CLIENT_ID,
metadata: {
displayName: "Demo User",
customField1: "Demo Value"
}
};
try {
const response = await axios.post(url, payload, {
headers: { 'Content-Type': 'application/json' }
});
return {
guestToken: response.data.guestToken,
sessionId: response.data.sessionId
};
} catch (error) {
if (error.response) {
console.error(`Token Error: ${error.response.status} - ${error.response.data.message}`);
} else {
console.error('Token Error:', error.message);
}
return null;
}
}
connectWebSocket() {
const wsUrl = `wss://${this.config.ORG_ID}.mypurecloud.com/api/v2/engagements/guests/${this.sessionId}/ws?guestToken=${this.guestToken}`;
console.log('Connecting to WebSocket...');
this.ws = new WebSocket(wsUrl);
this.ws.on('open', () => {
console.log('WebSocket Connected.');
// Simulate user sending a message after 2 seconds
setTimeout(() => {
this.sendText("Hello, this is a test message from custom UI.");
}, 2000);
});
this.ws.on('message', (data) => {
const message = JSON.parse(data);
this.processMessage(message);
});
this.ws.on('close', (code, reason) => {
console.log(`WebSocket Closed: ${code} - ${reason}`);
this.attemptReconnect();
});
this.ws.on('error', (err) => {
console.error('WebSocket Error:', err.message);
});
}
processMessage(msg) {
console.log('--- Incoming Message ---');
console.log(JSON.stringify(msg, null, 2));
if (msg.type === 'message') {
console.log(`Agent/Bot: ${msg.content}`);
// Simulate ending the chat after receiving a response
if (msg.content.includes("help") || msg.content.includes("test")) {
setTimeout(() => {
this.endSession();
}, 1000);
}
} else if (msg.type === 'typing') {
console.log('Agent is typing...');
} else if (msg.type === 'sessionEnded') {
console.log('Session ended.');
process.exit(0);
}
}
sendText(text) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
const payload = {
type: 'message',
content: text
};
this.ws.send(JSON.stringify(payload));
console.log(`Sent: ${text}`);
} else {
console.error('Cannot send message: WebSocket not open');
}
}
endSession() {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
const payload = {
type: 'sessionEnded',
reason: 'Demo Complete'
};
this.ws.send(JSON.stringify(payload));
console.log('Ending session...');
}
}
attemptReconnect() {
if (this.reconnectAttempts < this.config.MAX_RECONNECT_ATTEMPTS) {
this.reconnectAttempts = (this.reconnectAttempts || 0) + 1;
console.log(`Reconnecting... (${this.reconnectAttempts}/${this.config.MAX_RECONNECT_ATTEMPTS})`);
setTimeout(() => this.connectWebSocket(), 2000);
} else {
console.error('Max reconnect attempts reached. Exiting.');
process.exit(1);
}
}
}
// Run the demo
const chat = new SimpleGuestChat(CONFIG);
chat.initialize().catch(err => {
console.error('Initialization failed:', err);
process.exit(1);
});
Common Errors & Debugging
Error: 401 Unauthorized on Token Request
Cause: The Client ID is invalid, or the OAuth Client is not configured as a Public Client. Private clients require a client_secret and cannot be used for guest-initiated flows in the browser or untrusted environments.
Fix:
- Go to Genesys Cloud Admin > Admin > Integrations > OAuth 2.0 Clients.
- Ensure the client type is set to Public.
- Ensure the client is Enabled.
- Verify the
client_idmatches exactly.
Error: 403 Forbidden on Token Request
Cause: The OAuth Client lacks the required scope chat:guest:write.
Fix:
- Edit the OAuth Client in Genesys Cloud.
- Add the scope
chat:guest:writeto the list of allowed scopes. - Save the client. Note: Changes to scopes may take a few minutes to propagate.
Error: WebSocket Connection Refused or Immediate Close
Cause:
- Invalid
guestToken. - Expired
guestToken. - Incorrect WebSocket URL format.
Fix: - Log the
guestTokenandsessionIdbefore connecting. - Ensure the URL format is
wss://{orgId}.mypurecloud.com/api/v2/engagements/guests/{sessionId}/ws?guestToken={guestToken}. - Check if the
guestTokenhas expired. Tokens typically expire in 15-30 minutes. If expired, request a new token viaPOST /api/v2/engagements/guests.
Error: 429 Too Many Requests
Cause: Sending messages or typing indicators too frequently. Genesys Cloud enforces rate limits on WebSocket messages.
Fix:
- Implement debouncing for typing indicators. Do not send a
typingmessage on every keystroke. Send it once when the user starts typing, and again after a pause of 2-3 seconds. - Implement exponential backoff for reconnections, as shown in the
GuestChatClientclass.
Error: Message Not Appearing in Agent Desktop
Cause: The message payload is malformed. The type field must be exactly message. The content field must be a string.
Fix:
- Verify the JSON structure sent via
ws.send(). - Ensure no extra fields break the parser.
- Check the Genesys Cloud logs for the specific session ID to see if the message was received but filtered.