Implementing Real-Time Typing Indicators and Read Receipts in a Custom Genesys Cloud Web Messaging Client

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-v2 SDK for authentication and REST operations, combined with the native /api/v2/conversations/messaging/events WebSocket stream.
  • The implementation uses TypeScript with React hooks and the standard fetch API for direct REST calls.

Prerequisites

  • OAuth client type: Confidential client or Public client with conversations:messaging, conversations:read, and messaging:write scopes.
  • SDK version: @genesyscloud/purecloud-platform-client-v2 v5.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 authenticate frame 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:write or conversations:messaging scope.
  • 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-After parsing.
  • Code showing the fix: The handleInput function in the complete example uses setTimeout to delay the API call until typing ceases.

Official References