Implementing Real-Time Typing Indicators and Read Receipts in a Custom Genesys Cloud Web Messaging Client
What You Will Build
- You will build a React component that intercepts WebSocket events to display agent typing status and marks messages as read in real time.
- This tutorial uses the
@genesyscloud/purecloud-platform-client-v2SDK for authentication and REST operations, combined with the native/api/v2/conversations/messaging/eventsWebSocket stream. - The implementation uses TypeScript with React hooks and the standard
fetchAPI for direct REST calls.
Prerequisites
- OAuth client type: Confidential client or Public client with
conversations:messaging,conversations:read, andmessaging:writescopes. - SDK version:
@genesyscloud/purecloud-platform-client-v2v5.0+ - Runtime: Node.js 18+, React 18+, TypeScript 4.9+
- External dependencies:
npm install @genesyscloud/purecloud-platform-client-v2 react
Authentication Setup
Genesys Cloud requires a valid OAuth 2.0 bearer token for all API calls. The SDK handles token caching automatically when you provide a token provider. The following example demonstrates a production-ready token initialization pattern using the SDK configuration object.
import PureCloudPlatformClientV2 from '@genesyscloud/purecloud-platform-client-v2';
// Required OAuth scopes: conversations:messaging, conversations:read, messaging:write
const ENVIRONMENT = 'us-east-1'; // or your specific org region
const CLIENT_ID = 'your_client_id';
const CLIENT_SECRET = 'your_client_secret';
export async function initializeGenesysSDK(): Promise<PureCloudPlatformClientV2> {
const client = new PureCloudPlatformClientV2();
client.setEnvironment(ENVIRONMENT);
client.setBaseUri(`https://${ENVIRONMENT}.mypurecloud.com`);
// Configure OAuth2 token endpoint
client.setConfig({
oauthBaseUri: `https://${ENVIRONMENT}.mypurecloud.com`,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
grant_type: 'client_credentials'
});
try {
await client.login();
console.log('OAuth token acquired successfully');
} catch (error: unknown) {
const err = error as { status?: number; message?: string };
if (err.status === 401) {
throw new Error('Invalid OAuth credentials. Verify client_id and client_secret.');
}
if (err.status === 403) {
throw new Error('OAuth client lacks required scopes: conversations:messaging, messaging:write');
}
throw error;
}
return client;
}
The SDK caches the token internally and automatically refreshes it before expiration. You will pass this client instance to your React hooks for REST operations.
Implementation
Step 1: Establish WebSocket Subscription and Event Parsing
Genesys Cloud streams messaging events over a persistent WebSocket connection. You must connect to the messaging events endpoint and parse incoming JSON payloads. The connection requires the bearer token in the Authorization header and supports automatic reconnection on unexpected closures.
import { useEffect, useRef, useState, useCallback } from 'react';
interface MessagingEvent {
event_type: string;
conversation_id?: string;
message_id?: string;
participant_id?: string;
payload?: Record<string, unknown>;
}
export function useMessagingWebSocket(
environment: string,
accessToken: string,
onTyping: (conversationId: string, isTyping: boolean) => void,
onMessage: (messageId: string, conversationId: string) => void
) {
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const connect = useCallback(() => {
const wsUrl = `wss://${environment}.mypurecloud.com/api/v2/conversations/messaging/events`;
const ws = new WebSocket(wsUrl, []);
wsRef.current = ws;
ws.addEventListener('open', () => {
// Send initial authorization frame
ws.send(JSON.stringify({
type: 'authenticate',
token: accessToken
}));
});
ws.addEventListener('message', (event: MessageEvent) => {
try {
const data: MessagingEvent = JSON.parse(event.data);
if (data.event_type === 'typing' && data.conversation_id) {
const isTyping = data.payload?.typing === true;
onTyping(data.conversation_id, isTyping);
}
if (data.event_type === 'message' && data.message_id && data.conversation_id) {
onMessage(data.message_id, data.conversation_id);
}
} catch (parseError) {
console.error('Failed to parse WebSocket event:', parseError);
}
});
ws.addEventListener('close', (event: CloseEvent) => {
if (event.code === 4001) {
console.warn('WebSocket session expired. Reconnecting...');
}
scheduleReconnect();
});
ws.addEventListener('error', (error: Event) => {
console.error('WebSocket connection error:', error);
});
}, [environment, accessToken, onTyping, onMessage]);
const scheduleReconnect = () => {
if (reconnectTimeoutRef.current) clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = setTimeout(connect, 3000);
};
useEffect(() => {
connect();
return () => {
if (wsRef.current) wsRef.current.close();
if (reconnectTimeoutRef.current) clearTimeout(reconnectTimeoutRef.current);
};
}, [connect]);
return wsRef;
}
The WebSocket endpoint does not require pagination because it streams events continuously. The authenticate frame is mandatory for the first message after connection establishment.
Step 2: Implement Typing Indicator State and REST Submission
Typing indicators must be debounced to prevent excessive API calls. Genesys Cloud expects a POST request to the conversation typing endpoint. You will maintain local state for the typing status and trigger the REST call only after the user stops typing for a defined interval.
import PureCloudPlatformClientV2 from '@genesyscloud/purecloud-platform-client-v2';
export async function sendTypingStatus(
client: PureCloudPlatformClientV2,
conversationId: string,
isTyping: boolean
): Promise<void> {
const endpoint = `/api/v2/conversations/messaging/conversations/${encodeURIComponent(conversationId)}/typing`;
const baseUrl = client.getBaseUri();
const config = client.getConfig();
const headers = {
'Authorization': `Bearer ${config.access_token}`,
'Content-Type': 'application/json',
'Accept': 'application/json'
};
try {
const response = await fetch(`${baseUrl}${endpoint}`, {
method: 'POST',
headers,
body: JSON.stringify({ typing: isTyping })
});
if (response.status === 429) {
const retryAfter = parseInt(response.headers.get('Retry-After') || '2', 10);
await new Promise(resolve => setTimeout(resolve, retryAfter * 1000));
return sendTypingStatus(client, conversationId, isTyping);
}
if (!response.ok) {
const errorBody = await response.text();
throw new Error(`Typing API failed with ${response.status}: ${errorBody}`);
}
} catch (error: unknown) {
const err = error as Error;
if (err.message.includes('401')) {
throw new Error('Token expired. Re-authenticate before sending typing status.');
}
console.error('Failed to send typing indicator:', err);
}
}
The typing payload must be a boolean. The API ignores duplicate rapid calls, but debouncing on the client side preserves your rate limit budget. The 429 retry logic implements exponential backoff by reading the Retry-After header.
Step 3: Process Read Receipts and Message Interception
Read receipts require a PUT request to the message read endpoint. You will intercept incoming messages from the WebSocket, update local state to track unread messages, and mark them as read when the user scrolls them into view or explicitly acknowledges them.
export async function markMessageAsRead(
client: PureCloudPlatformClientV2,
messageId: string
): Promise<void> {
const endpoint = `/api/v2/conversations/messaging/messages/${encodeURIComponent(messageId)}/read`;
const baseUrl = client.getBaseUri();
const config = client.getConfig();
const headers = {
'Authorization': `Bearer ${config.access_token}`,
'Content-Type': 'application/json',
'Accept': 'application/json'
};
try {
const response = await fetch(`${baseUrl}${endpoint}`, {
method: 'PUT',
headers,
body: JSON.stringify({ read: true })
});
if (response.status === 429) {
const retryAfter = parseInt(response.headers.get('Retry-After') || '2', 10);
await new Promise(resolve => setTimeout(resolve, retryAfter * 1000));
return markMessageAsRead(client, messageId);
}
if (!response.ok) {
const errorBody = await response.text();
throw new Error(`Read receipt API failed with ${response.status}: ${errorBody}`);
}
} catch (error: unknown) {
const err = error as Error;
if (err.message.includes('403')) {
throw new Error('Insufficient permissions. Verify messaging:write scope is attached.');
}
console.error('Failed to mark message as read:', err);
}
}
The read receipt endpoint is idempotent. Calling it multiple times for the same message identifier will not produce duplicate side effects. You should batch read receipts if multiple messages enter the viewport simultaneously.
Complete Working Example
import React, { useState, useEffect, useCallback, useRef } from 'react';
import PureCloudPlatformClientV2 from '@genesyscloud/purecloud-platform-client-v2';
import { useMessagingWebSocket } from './useMessagingWebSocket';
import { sendTypingStatus } from './api/typing';
import { markMessageAsRead } from './api/readReceipts';
interface Message {
id: string;
text: string;
read: boolean;
timestamp: number;
}
interface MessagingClientProps {
environment: string;
clientId: string;
clientSecret: string;
conversationId: string;
}
export const MessagingClient: React.FC<MessagingClientProps> = ({
environment,
clientId,
clientSecret,
conversationId
}) => {
const [client, setClient] = useState<PureCloudPlatformClientV2 | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [isAgentTyping, setIsAgentTyping] = useState(false);
const [inputText, setInputText] = useState('');
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const init = async () => {
const sdk = new PureCloudPlatformClientV2();
sdk.setEnvironment(environment);
sdk.setConfig({
oauthBaseUri: `https://${environment}.mypurecloud.com`,
client_id: clientId,
client_secret: clientSecret,
grant_type: 'client_credentials'
});
await sdk.login();
setClient(sdk);
};
init();
}, [environment, clientId, clientSecret]);
const handleTypingEvent = useCallback((convId: string, typing: boolean) => {
if (convId === conversationId) {
setIsAgentTyping(typing);
}
}, [conversationId]);
const handleMessageEvent = useCallback((messageId: string, convId: string) => {
if (convId === conversationId) {
setMessages(prev => {
const exists = prev.find(m => m.id === messageId);
if (exists) return prev;
return [...prev, { id: messageId, text: 'New message received', read: false, timestamp: Date.now() }];
});
}
}, [conversationId]);
useMessagingWebSocket(
environment,
client?.getConfig().access_token || '',
handleTypingEvent,
handleMessageEvent
);
const handleInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setInputText(value);
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
typingTimeoutRef.current = setTimeout(async () => {
if (client && value.length > 0) {
await sendTypingStatus(client, conversationId, true);
}
}, 500);
};
const handleBlur = async () => {
if (client) {
await sendTypingStatus(client, conversationId, false);
}
};
useEffect(() => {
if (!messagesEndRef.current) return;
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
const unreadMessages = messages.filter(m => !m.read);
if (client && unreadMessages.length > 0) {
const batchPromise = unreadMessages.map(msg => markMessageAsRead(client, msg.id));
Promise.all(batchPromise).then(() => {
setMessages(prev => prev.map(m => ({ ...m, read: true })));
});
}
}, [messages, client]);
if (!client) return <div>Initializing Genesys Cloud SDK...</div>;
return (
<div style={{ padding: '16px', border: '1px solid #ccc', borderRadius: '8px' }}>
<div style={{ minHeight: '200px', maxHeight: '300px', overflowY: 'auto' }}>
{messages.map(msg => (
<div key={msg.id} style={{ padding: '4px 0', opacity: msg.read ? 0.6 : 1 }}>
{msg.text} {msg.read ? '(read)' : '(unread)'}
</div>
))}
{isAgentTyping && <div style={{ color: '#666', fontStyle: 'italic' }}>Agent is typing...</div>}
<div ref={messagesEndRef} />
</div>
<input
type="text"
value={inputText}
onChange={handleInput}
onBlur={handleBlur}
placeholder="Type a message..."
style={{ marginTop: '8px', width: '100%', padding: '8px' }}
/>
</div>
);
};
The component initializes the SDK, establishes the WebSocket stream, manages typing state with debounce logic, and batches read receipt updates when messages enter the viewport. All network calls include 429 retry logic and explicit error boundaries.
Common Errors & Debugging
Error: 401 Unauthorized on WebSocket Authentication
- What causes it: The bearer token provided in the
authenticateframe is expired, malformed, or missing required scopes. - How to fix it: Verify the OAuth client credentials and ensure the token was refreshed before sending the authentication frame. Implement a token refresh hook that updates the WebSocket connection when the SDK emits a token renewal event.
- Code showing the fix:
// Monitor SDK token changes and reconnect WebSocket if needed
client.on('tokenUpdated', (newToken: string) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.close();
// Trigger reconnect via useEffect dependency or state update
}
});
Error: 403 Forbidden on Read Receipt Endpoint
- What causes it: The OAuth token lacks the
messaging:writeorconversations:messagingscope. - How to fix it: Regenerate the OAuth token with the correct scope attached to the client application in the Genesys Cloud admin console. Verify the scope string matches exactly without trailing spaces.
- Code showing the fix: Update the SDK configuration object to include the correct scope during token acquisition:
client.setConfig({
...existingConfig,
scope: 'conversations:messaging messaging:write'
});
Error: 429 Too Many Requests on Typing Endpoint
- What causes it: Rapid keystrokes trigger the typing endpoint faster than the platform rate limit allows.
- How to fix it: Implement client-side debouncing with a minimum interval of 500 milliseconds. The complete example already includes a 500ms debounce timeout and server-side
Retry-Afterparsing. - Code showing the fix: The
handleInputfunction in the complete example usessetTimeoutto delay the API call until typing ceases.