Sending Typing Indicators and Read Receipts via the Genesys Cloud Web Messaging Guest API

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:write scope. For guest-initiated flows, you typically use the token provided by the webchat widget or exchange a guest token via the /api/v2/oauth/token endpoint using client credentials if building a custom bot interface.
  • SDK Version: genesys-cloud-webmessaging-guest SDK 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 via npm 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 uses Authorization: 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 guestToken is 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 Authorization header is formatted as Bearer <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 conversationId or participantId (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 conversationId is present and is a string. Validate JSON syntax.

Official References