Sending Typing Indicators and Read Receipts via the Genesys Cloud Web Messaging Guest API
What You Will Build
- A client-side JavaScript module that sends typing status updates and read receipts to a Genesys Cloud Web Messaging conversation.
- This uses the Genesys Cloud Web Messaging Guest API (
/api/v2/conversations/messaging/participants) and the WebSocket event stream. - The programming language covered is JavaScript (ES6+) running in a browser or Node.js environment.
Prerequisites
- OAuth Client: A Genesys Cloud OAuth2 client with the
webmessaging:conversation:writescope. For guest-initiated flows, you typically use the token provided by the webchat widget or exchange a guest token via the/api/v2/oauth/tokenendpoint using client credentials if building a custom bot interface. - SDK Version:
genesys-cloud-webmessaging-guestSDK v1.0.0+ or direct REST API calls. - Runtime: Modern browser (Chrome, Firefox, Safari, Edge) or Node.js 16+.
- Dependencies: No external npm packages required if using
fetch. If using the SDK, install vianpm install @genesys/web-messaging-guest-sdk.
Authentication Setup
Genesys Cloud Web Messaging operates on a dual-token model. The guestId and guestToken are issued when a conversation is initiated. All subsequent API calls require these values in the header or URL parameters.
If you are building a custom client outside the standard widget, you must first obtain a valid guestId and guestToken. This is typically done by initiating a conversation via the /api/v2/conversations/messaging endpoint.
Required Headers for all Guest API calls:
X-Genesys-Request-Id: A unique ID for the request (UUID v4).Authorization:Bearer <guestToken>(Note: In the Guest API, the “token” is often passed directly as a query param or header depending on the specific endpoint version. The modern Guest API usesAuthorization: Bearer <guestToken>).
// Example of generating a Request ID
function generateRequestId() {
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);
});
}
Implementation
Step 1: Initialize the Connection and Store Credentials
Before sending any status updates, you must have an active conversation and a valid participant ID. The guestId acts as the participant ID in this context.
In a production environment, you should store the guestId and guestToken securely. Do not log them to the console in production builds.
class WebMessenger {
constructor(orgUrl, guestId, guestToken) {
this.orgUrl = orgUrl.replace(/\/$/, ''); // Remove trailing slash
this.guestId = guestId;
this.guestToken = guestToken;
this.apiBase = `${this.orgUrl}/api/v2/conversations/messaging`;
this.headers = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.guestToken}`,
'X-Genesys-Request-Id': generateRequestId()
};
}
/**
* Helper to make API calls with error handling
*/
async apiCall(endpoint, method = 'GET', body = null) {
const url = `${this.apiBase}${endpoint}`;
const options = {
method,
headers: this.headers,
body: body ? JSON.stringify(body) : null
};
try {
const response = await fetch(url, options);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(`API Error ${response.status}: ${errorData.message || response.statusText}`);
}
// Some endpoints return 204 No Content
if (response.status === 204) return null;
return await response.json();
} catch (error) {
console.error('Web Messaging API Error:', error);
throw error;
}
}
}
Step 2: Send Typing Indicators
Genesys Cloud supports typing indicators through the Participant API. You do not send a message; you update the participant’s state. The endpoint is POST /api/v2/conversations/messaging/participants/{participantId}/typing.
Important: You must send this request every time the user starts typing. There is no persistent “typing” state that lasts forever; it is an event. To stop the typing indicator, you typically do not send a “stop” event explicitly in all SDKs, but rather rely on the UI to clear the indicator after a timeout (usually 3-5 seconds) if no new message is received. However, you can explicitly stop typing by sending a request with isTyping: false in some implementations, but the standard Guest API endpoint expects a TypingInfo object.
Endpoint: POST /api/v2/conversations/messaging/participants/{participantId}/typing
Scope: webmessaging:conversation:write (Implicit in Guest Token)
The request body must contain the conversationId.
/**
* Sends a typing indicator to the conversation.
* @param {string} conversationId - The ID of the active conversation.
* @param {boolean} isTyping - Whether the user is currently typing.
*/
async sendTypingIndicator(conversationId, isTyping = true) {
const endpoint = `/participants/${this.guestId}/typing`;
const requestBody = {
conversationId: conversationId,
isTyping: isTyping
};
try {
const result = await this.apiCall(endpoint, 'POST', requestBody);
console.log('Typing indicator sent successfully');
return result;
} catch (error) {
// Handle 409 Conflict: Conversation not found
if (error.message.includes('409')) {
console.warn('Conversation not found or inactive.');
}
throw error;
}
}
Note on Rate Limiting: Do not send typing indicators on every keystroke. Implement a debounce function in your UI layer. Sending a typing indicator every 500ms-1s is sufficient.
// Debounce utility for typing events
debounce(func, wait) {
let timeout;
return function(...args) {
const context = this;
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(context, args), wait);
};
}
// Usage in UI Event Listener
const debouncedTyping = this.debounce(() => {
this.webMessenger.sendTypingIndicator(this.currentConversationId, true);
}, 1000); // Send once per second if typing continues
// Attach to input element
inputElement.addEventListener('input', debouncedTyping);
Step 3: Send Read Receipts
Read receipts confirm that the user has viewed the messages. This updates the lastReadTimestamp for the participant.
Endpoint: POST /api/v2/conversations/messaging/participants/{participantId}/read
Scope: webmessaging:conversation:write
The body requires the conversationId and optionally the messageId of the last message read. If you omit messageId, it marks all messages in the conversation as read up to the current time.
/**
* Marks messages as read.
* @param {string} conversationId - The ID of the active conversation.
* @param {string} [lastMessageId] - The ID of the last message read. Optional.
*/
async markAsRead(conversationId, lastMessageId = null) {
const endpoint = `/participants/${this.guestId}/read`;
const requestBody = {
conversationId: conversationId,
};
if (lastMessageId) {
requestBody.messageId = lastMessageId;
}
try {
const result = await this.apiCall(endpoint, 'POST', requestBody);
console.log('Read receipt sent successfully');
return result;
} catch (error) {
console.error('Failed to send read receipt:', error);
throw error;
}
}
Best Practice: Trigger this function when the conversation window comes into view (Intersection Observer) or when the user scrolls to the bottom of the message list.
// Example using Intersection Observer to detect when chat is visible
setupReadReceiptObserver() {
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting && this.currentConversationId) {
// Send read receipt when chat window is visible
this.webMessenger.markAsRead(this.currentConversationId);
}
});
}, { threshold: 0.5 }); // Trigger when 50% visible
observer.observe(document.getElementById('chat-window'));
}
Step 4: Handling WebSocket Events (Optional but Recommended)
While the REST API sends the status, you may want to confirm receipt or handle server-side acknowledgments. Genesys Cloud pushes events via WebSocket. You do not need to listen for a specific “typing ack” event, but you should handle the conversation:typing event if you are building a multi-device client.
For the Guest API, the primary feedback loop is visual. If the REST call succeeds (200/204), the server has accepted the state change.
Complete Working Example
This example combines authentication, typing debounce, and read receipts into a single class.
/**
* Genesys Cloud Web Messaging Client
* Handles typing indicators and read receipts via the Guest API.
*/
class GenesysWebMessenger {
constructor(orgUrl, guestId, guestToken) {
this.orgUrl = orgUrl.replace(/\/$/, '');
this.guestId = guestId;
this.guestToken = guestToken;
this.apiBase = `${this.orgUrl}/api/v2/conversations/messaging`;
this.currentConversationId = null;
// Default headers
this.headers = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.guestToken}`,
'X-Genesys-Request-Id': this.generateRequestId()
};
}
generateRequestId() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
async apiCall(endpoint, method = 'GET', body = null) {
const url = `${this.apiBase}${endpoint}`;
// Refresh Request ID for each call
const headers = { ...this.headers, 'X-Genesys-Request-Id': this.generateRequestId() };
const options = {
method,
headers,
body: body ? JSON.stringify(body) : null
};
try {
const response = await fetch(url, options);
if (!response.ok) {
const errorBody = await response.text();
throw new Error(`HTTP ${response.status}: ${errorBody}`);
}
if (response.status === 204) return { success: true };
return await response.json();
} catch (error) {
console.error(`API Call Failed [${endpoint}]:`, error);
throw error;
}
}
/**
* Sets the active conversation ID.
* Call this when a new conversation is started or restored.
*/
setConversationId(conversationId) {
this.currentConversationId = conversationId;
}
/**
* Sends a typing indicator.
* Includes built-in debounce logic to prevent API spam.
*/
async startTyping() {
if (!this.currentConversationId) {
throw new Error('No active conversation ID set.');
}
const endpoint = `/participants/${this.guestId}/typing`;
const body = {
conversationId: this.currentConversationId,
isTyping: true
};
try {
await this.apiCall(endpoint, 'POST', body);
} catch (e) {
// Silent fail for typing indicators is acceptable in UI
// to avoid blocking user experience, but log for debugging.
console.warn('Failed to send typing indicator:', e);
}
}
/**
* Stops the typing indicator (optional, depends on UI timeout logic).
*/
async stopTyping() {
if (!this.currentConversationId) return;
const endpoint = `/participants/${this.guestId}/typing`;
const body = {
conversationId: this.currentConversationId,
isTyping: false
};
try {
await this.apiCall(endpoint, 'POST', body);
} catch (e) {
console.warn('Failed to stop typing indicator:', e);
}
}
/**
* Marks all messages in the conversation as read.
*/
async markAsRead() {
if (!this.currentConversationId) {
throw new Error('No active conversation ID set.');
}
const endpoint = `/participants/${this.guestId}/read`;
const body = {
conversationId: this.currentConversationId
};
try {
await this.apiCall(endpoint, 'POST', body);
} catch (e) {
console.error('Failed to send read receipt:', e);
throw e;
}
}
/**
* Factory method to create a debounced typing handler.
* @returns {Function} Debounced function to call startTyping
*/
createTypingHandler() {
let typingTimer;
const DEBOUNCE_DELAY = 1000; // 1 second
return () => {
clearTimeout(typingTimer);
// Immediately send the first typing indicator
this.startTyping();
// If they keep typing, update the indicator periodically
typingTimer = setTimeout(() => {
// Optional: Send another ping if conversation is still active
// this.startTyping();
}, DEBOUNCE_DELAY);
};
}
}
// --- Usage Example ---
// 1. Initialize (Assuming you have guestId and guestToken from previous step)
const guestId = "guest-12345-abcde";
const guestToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...";
const orgUrl = "https://mycompany.genesiscloud.com";
const messenger = new GenesysWebMessenger(orgUrl, guestId, guestToken);
// 2. Set Conversation ID (Retrieved from WebSocket or initial API call)
messenger.setConversationId("conv-xyz-789");
// 3. Attach Typing Handler to Input Field
const chatInput = document.getElementById('message-input');
const typingHandler = messenger.createTypingHandler();
chatInput.addEventListener('input', typingHandler);
// 4. Attach Read Receipt Observer
const chatWindow = document.getElementById('chat-window');
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
messenger.markAsRead();
}
}, { threshold: 0.5 });
observer.observe(chatWindow);
Common Errors & Debugging
Error: 401 Unauthorized
- Cause: The
guestTokenis expired, invalid, or missing. - Fix: Verify the token was obtained recently. Guest tokens expire after a set duration (typically 1 hour). If your application persists across sessions, you must re-authenticate the guest.
- Code Check: Ensure the
Authorizationheader is formatted asBearer <token>without quotes around the token.
Error: 403 Forbidden
- Cause: The guest token does not have the required scope (
webmessaging:conversation:write). - Fix: Check the OAuth client configuration in Genesys Cloud Admin. Ensure the client used to generate the token has the correct scopes. Note that guest tokens are usually scoped automatically based on the conversation context, but if you are using a service account, verify scopes.
Error: 404 Not Found
- Cause: The
conversationIdorparticipantId(guestId) is incorrect. - Fix: Verify the IDs match exactly. Case sensitivity matters. Ensure the conversation is not deleted or archived.
Error: 429 Too Many Requests
- Cause: Sending typing indicators too frequently.
- Fix: Implement robust debouncing. Never send a typing indicator more than once per second. If you receive a 429, wait exponentially before retrying.
// Retry Logic for 429
async apiCallWithRetry(endpoint, method, body, retries = 3) {
for (let i = 0; i < retries; i++) {
try {
return await this.apiCall(endpoint, method, body);
} catch (error) {
if (error.message.includes('429') && i < retries - 1) {
const delay = Math.pow(2, i) * 1000; // Exponential backoff
console.warn(`Rate limited. Waiting ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
} else {
throw error;
}
}
}
}
Error: 400 Bad Request
- Cause: Malformed JSON or missing required fields in the request body.
- Fix: Ensure the
conversationIdis present and is a string. Validate JSON syntax.