Implementing Robust WebSocket Reconnection Logic for the Genesys Cloud Notification API in Node.js

Implementing Robust WebSocket Reconnection Logic for the Genesys Cloud Notification API in Node.js

What You Will Build

  • A Node.js application that subscribes to Genesys Cloud presence and conversation notifications and survives network interruptions or server-side disconnects.
  • The solution uses the Genesys Cloud REST API for authentication and the native Node.js ws library for WebSocket communication.
  • The implementation covers token refresh, exponential backoff, and automatic subscription re-registration upon reconnection.

Prerequisites

  • OAuth Client Type: Confidential Client (Client Credentials Flow).
  • Required Scopes: presence:read (for presence notifications), conversation:read (for conversation details), and integration:read (if subscribing to integration events).
  • SDK/API Version: Genesys Cloud API v2.
  • Language/Runtime: Node.js 18+ (LTS).
  • Dependencies:
    • @genesyscloud/purecloud-platform-client-v2: For OAuth token management.
    • ws: For WebSocket connectivity.
    • dotenv: For environment variable management.

Install dependencies via npm:

npm install @genesyscloud/purecloud-platform-client-v2 ws dotenv

Authentication Setup

The Genesys Cloud Notification API requires a valid OAuth bearer token to initialize the WebSocket connection. Unlike standard REST calls, the WebSocket handshake occurs over HTTPS, but the subscription payload includes the token. If the token expires while the socket is open, the server will close the connection. Therefore, your reconnection logic must also handle token refresh.

We will use the official Genesys Cloud SDK to handle the OAuth flow, as it manages token caching and refresh automatically. This decouples the complex credential management from the raw WebSocket logic.

import { PlatformClient } from '@genesyscloud/purecloud-platform-client-v2';
import dotenv from 'dotenv';

dotenv.config();

const platformClient = PlatformClient.create();

platformClient.setEnvironment(process.env.GENESYS_CLOUD_ENVIRONMENT || 'mypurecloud.com');
platformClient.authClient.setCredentials(
  process.env.GENESYS_CLOUD_CLIENT_ID,
  process.env.GENESYS_CLOUD_CLIENT_SECRET
);

/**
 * Retrieves a fresh access token.
 * The SDK handles caching and refreshing internally.
 * @returns {Promise<string>} The bearer token.
 */
async function getAccessToken() {
  try {
    const token = await platformClient.authClient.getAccessToken([
      'presence:read',
      'conversation:read'
    ]);
    return token;
  } catch (error) {
    console.error('Failed to retrieve access token:', error.message);
    throw error;
  }
}

Implementation

Step 1: Defining the Subscription Payload

Before connecting, you must define what data you want to receive. The Genesys Cloud Notification API uses a JSON payload sent immediately after the WebSocket handshake to register subscriptions. This payload contains a topics array.

Each topic specifies the resource type, the specific ID (or * for all), and the event types.

/**
 * Constructs the subscription payload for the Notification API.
 * 
 * @param {string} accessToken - The valid OAuth bearer token.
 * @returns {string} JSON stringified subscription message.
 */
function createSubscriptionPayload(accessToken) {
  const subscription = {
    topics: [
      {
        topic: 'presence',
        id: '*', // Subscribe to all presences in the org
        events: ['statusChange', 'awayReasonChange']
      },
      {
        topic: 'conversation',
        id: '*', // Subscribe to all conversations
        events: ['created', 'updated', 'closed']
      }
    ],
    // Optional: Include metadata for debugging
    metadata: {
      client: 'node-ws-reconnect-demo',
      timestamp: new Date().toISOString()
    }
  };
  
  // The API requires the subscription message to be sent as a text frame.
  return JSON.stringify(subscription);
}

Step 2: Implementing the WebSocket Client with Reconnection Logic

The core of this tutorial is the NotificationClient class. This class encapsulates the WebSocket lifecycle. It does not rely on the SDK for the WebSocket connection itself, as the SDK primarily focuses on REST. Instead, it uses the ws library for lightweight, high-performance socket management.

Key features of this implementation:

  1. Exponential Backoff: Prevents overwhelming the server during outages.
  2. Jitter: Adds randomness to backoff intervals to prevent thundering herd issues if multiple clients reconnect simultaneously.
  3. Token Refresh on Reconnect: Ensures the new connection uses a valid token.
  4. Subscription Re-registration: The Genesys Cloud Notification API does not persist subscriptions across connections. You must send the subscription payload again after every reconnect.
import WebSocket from 'ws';
import { getAccessToken } from './auth.js'; // Assuming auth is in a separate module
import { createSubscriptionPayload } from './subscription.js';

const MAX_RECONNECT_ATTEMPTS = 10;
const INITIAL_RECONNECT_DELAY = 1000; // 1 second
const MAX_RECONNECT_DELAY = 60000;    // 1 minute

export class NotificationClient {
  constructor() {
    this.ws = null;
    this.reconnectAttempts = 0;
    this.isIntentionalClose = false;
    this.subscriptionsSent = false;
  }

  /**
   * Initiates the connection and starts the reconnection loop.
   */
  async connect() {
    this.isIntentionalClose = false;
    await this._connectAndSubscribe();
  }

  /**
   * Handles the connection, subscription, and error handling loop.
   */
  async _connectAndSubscribe() {
    try {
      // 1. Get a fresh token
      const token = await getAccessToken();
      
      // 2. Construct the WebSocket URL
      // The Notification API endpoint is /api/v2/notifications
      const wsUrl = `wss://api.mypurecloud.com/api/v2/notifications?access_token=${token}`;
      
      console.log(`Connecting to ${wsUrl}...`);
      this.ws = new WebSocket(wsUrl);

      // 3. Handle Open Event
      this.ws.on('open', () => {
        console.log('WebSocket connection established.');
        this.reconnectAttempts = 0; // Reset attempts on success
        this.subscriptionsSent = false;
        
        // Send subscription payload
        this._sendSubscriptions(token);
      });

      // 4. Handle Incoming Messages
      this.ws.on('message', (data) => {
        this._handleMessage(data);
      });

      // 5. Handle Errors
      this.ws.on('error', (error) => {
        console.error('WebSocket error:', error.message);
        // Do not reconnect here; let the 'close' event handle it
        // to avoid duplicate reconnection logic.
      });

      // 6. Handle Close Event (The Trigger for Reconnection)
      this.ws.on('close', (code, reason) => {
        console.log(`WebSocket closed: Code ${code}, Reason: ${reason.toString()}`);
        this._handleClose(code, reason);
      });

    } catch (error) {
      console.error('Failed to connect:', error.message);
      this._scheduleReconnect();
    }
  }

  /**
   * Sends the subscription payload to the server.
   * @param {string} token 
   */
  _sendSubscriptions(token) {
    if (this.subscriptionsSent) return;

    const payload = createSubscriptionPayload(token);
    
    // Add a small delay to ensure the server is ready to accept frames
    setTimeout(() => {
      if (this.ws && this.ws.readyState === WebSocket.OPEN) {
        this.ws.send(payload);
        console.log('Subscriptions sent.');
        this.subscriptionsSent = true;
      }
    }, 100);
  }

  /**
   * Processes incoming JSON notifications.
   * @param {Buffer} data 
   */
  _handleMessage(data) {
    try {
      const notification = JSON.parse(data.toString());
      
      // Log the topic for debugging
      console.log(`Received notification on topic: ${notification.topic}`);
      
      // Process the notification based on topic
      if (notification.topic === 'presence') {
        this._handlePresenceNotification(notification);
      } else if (notification.topic === 'conversation') {
        this._handleConversationNotification(notification);
      }
    } catch (error) {
      console.error('Error parsing notification:', error);
    }
  }

  _handlePresenceNotification(notification) {
    console.log(`Presence Update: ${notification.data.id} is now ${notification.data.status}`);
  }

  _handleConversationNotification(notification) {
    console.log(`Conversation Event: ${notification.eventType} for ID: ${notification.data.id}`);
  }

  /**
   * Determines whether to reconnect based on the close code and reason.
   * @param {number} code 
   * @param {Buffer} reason 
   */
  _handleClose(code, reason) {
    if (this.isIntentionalClose) return;

    // 1000 indicates a normal closure. 
    // However, in this demo, we treat all closures as reconnect triggers 
    // to demonstrate resilience. In production, you might filter 1000 if 
    // you triggered the close yourself.
    
    console.log(`Connection lost. Reconnection attempt ${this.reconnectAttempts + 1}`);
    this._scheduleReconnect();
  }

  /**
   * Calculates the delay using exponential backoff with jitter.
   */
  _scheduleReconnect() {
    if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
      console.error('Max reconnection attempts reached. Giving up.');
      return;
    }

    this.reconnectAttempts++;
    
    // Exponential backoff: 1s, 2s, 4s, 8s...
    let delay = INITIAL_RECONNECT_DELAY * Math.pow(2, this.reconnectAttempts - 1);
    
    // Cap at max delay
    delay = Math.min(delay, MAX_RECONNECT_DELAY);
    
    // Add jitter (randomness between 0 and 1 second) to prevent thundering herd
    const jitter = Math.random() * 1000;
    delay += jitter;

    console.log(`Reconnecting in ${Math.round(delay / 1000)} seconds...`);
    
    setTimeout(() => {
      this._connectAndSubscribe();
    }, delay);
  }

  /**
   * Gracefully shuts down the client.
   */
  disconnect() {
    this.isIntentionalClose = true;
    if (this.ws) {
      this.ws.close(1000, 'Client shutting down');
    }
  }
}

Step 3: Processing Results and Edge Cases

The _handleMessage method parses the JSON payload. The Genesys Cloud Notification API sends messages that conform to a specific envelope structure.

A typical presence notification looks like this:

{
  "topic": "presence",
  "eventType": "statusChange",
  "data": {
    "id": "12345-67890",
    "status": "available",
    "awayReason": null
  },
  "timestamp": "2023-10-27T10:00:00Z"
}

A typical conversation notification looks like this:

{
  "topic": "conversation",
  "eventType": "updated",
  "data": {
    "id": "conv-123",
    "type": "voice",
    "state": "connected"
  },
  "timestamp": "2023-10-27T10:05:00Z"
}

Edge Case: Token Expiration During Session

The Genesys Cloud Notification API does not push token expiration events. Instead, the server will close the WebSocket connection with a specific close code (often 4001 or 4400 indicating authentication failure) when the token expires.

The _handleClose method in the code above catches this. Because _connectAndSubscribe is called again, getAccessToken() is invoked, which retrieves a fresh token from the SDK. The new WebSocket connection is then established with the valid token, and subscriptions are re-sent. This ensures zero data loss due to authentication issues, provided the underlying network is stable.

Edge Case: Network Flapping

If the network drops and reconnects rapidly, the exponential backoff logic prevents the client from hammering the API with connection attempts. The jitter ensures that if you have multiple instances of this client running, they do not all reconnect at the exact same millisecond, which could strain the load balancers.

Complete Working Example

Below is the complete, runnable index.js file. It combines the authentication, subscription logic, and the WebSocket client.

import { PlatformClient } from '@genesyscloud/purecloud-platform-client-v2';
import WebSocket from 'ws';
import dotenv from 'dotenv';

dotenv.config();

// --- Configuration ---
const GENESYS_ENV = process.env.GENESYS_CLOUD_ENVIRONMENT || 'mypurecloud.com';
const CLIENT_ID = process.env.GENESYS_CLOUD_CLIENT_ID;
const CLIENT_SECRET = process.env.GENESYS_CLOUD_CLIENT_SECRET;

const MAX_RECONNECT_ATTEMPTS = 10;
const INITIAL_RECONNECT_DELAY = 1000;
const MAX_RECONNECT_DELAY = 60000;

// --- Authentication Helper ---
const platformClient = PlatformClient.create();
platformClient.setEnvironment(GENESYS_ENV);
platformClient.authClient.setCredentials(CLIENT_ID, CLIENT_SECRET);

async function getAccessToken() {
  try {
    const token = await platformClient.authClient.getAccessToken([
      'presence:read',
      'conversation:read'
    ]);
    return token;
  } catch (error) {
    console.error('OAuth Error:', error.message);
    throw error;
  }
}

// --- Subscription Payload ---
function createSubscriptionPayload() {
  return JSON.stringify({
    topics: [
      {
        topic: 'presence',
        id: '*',
        events: ['statusChange']
      },
      {
        topic: 'conversation',
        id: '*',
        events: ['created', 'updated']
      }
    ]
  });
}

// --- WebSocket Client ---
class NotificationClient {
  constructor() {
    this.ws = null;
    this.reconnectAttempts = 0;
    this.isIntentionalClose = false;
    this.subscriptionsSent = false;
  }

  async connect() {
    this.isIntentionalClose = false;
    await this._connectAndSubscribe();
  }

  async _connectAndSubscribe() {
    try {
      const token = await getAccessToken();
      const wsUrl = `wss://api.${GENESYS_ENV}/api/v2/notifications?access_token=${token}`;
      
      console.log(`[${new Date().toISOString()}] Connecting to ${wsUrl}`);
      this.ws = new WebSocket(wsUrl);

      this.ws.on('open', () => {
        console.log('Connected.');
        this.reconnectAttempts = 0;
        this.subscriptionsSent = false;
        this._sendSubscriptions();
      });

      this.ws.on('message', (data) => {
        this._handleMessage(data);
      });

      this.ws.on('error', (error) => {
        console.error('WS Error:', error.message);
      });

      this.ws.on('close', (code, reason) => {
        console.log(`Closed: ${code} - ${reason.toString()}`);
        if (!this.isIntentionalClose) {
          this._scheduleReconnect();
        }
      });

    } catch (error) {
      console.error('Connection failed:', error.message);
      this._scheduleReconnect();
    }
  }

  _sendSubscriptions() {
    if (this.subscriptionsSent) return;
    
    const payload = createSubscriptionPayload();
    setTimeout(() => {
      if (this.ws && this.ws.readyState === WebSocket.OPEN) {
        this.ws.send(payload);
        console.log('Subscriptions registered.');
        this.subscriptionsSent = true;
      }
    }, 100);
  }

  _handleMessage(data) {
    try {
      const notification = JSON.parse(data.toString());
      console.log(`[${notification.topic}] ${notification.eventType}:`, JSON.stringify(notification.data).substring(0, 100) + '...');
    } catch (e) {
      console.error('Parse error:', e);
    }
  }

  _scheduleReconnect() {
    if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
      console.error('Max retries reached. Exiting.');
      process.exit(1);
    }

    this.reconnectAttempts++;
    let delay = INITIAL_RECONNECT_DELAY * Math.pow(2, this.reconnectAttempts - 1);
    delay = Math.min(delay, MAX_RECONNECT_DELAY);
    delay += Math.random() * 1000; // Jitter

    console.log(`Reconnecting in ${Math.round(delay / 1000)}s...`);
    setTimeout(() => this._connectAndSubscribe(), delay);
  }

  disconnect() {
    this.isIntentionalClose = true;
    if (this.ws) this.ws.close(1000, 'Shutdown');
  }
}

// --- Main Execution ---
async function main() {
  if (!CLIENT_ID || !CLIENT_SECRET) {
    console.error('Missing CLIENT_ID or CLIENT_SECRET in .env file');
    process.exit(1);
  }

  const client = new NotificationClient();
  
  // Handle graceful shutdown
  process.on('SIGINT', () => {
    console.log('Shutting down...');
    client.disconnect();
    process.exit(0);
  });

  await client.connect();
}

main();

Common Errors & Debugging

Error: 401 Unauthorized

Cause: The access token provided in the WebSocket URL query parameter is invalid, expired, or lacks the required scopes.
Fix: Ensure your .env file contains valid credentials. Verify that the getAccessToken function is called before every connection attempt. Check that the scopes presence:read and conversation:read are granted to your OAuth client in the Genesys Cloud Admin portal.

Error: 403 Forbidden

Cause: The OAuth client does not have the necessary permissions to subscribe to the requested topics.
Fix: In the Genesys Cloud Admin portal, navigate to Security > OAuth Clients. Select your client and ensure the required scopes are checked. For presence, ensure the user associated with the client (if using JWT) or the client itself (if using Client Credentials) has the presence:read permission.

Error: WebSocket Connection Closed Abnormally (Code 1006)

Cause: Network interruption, firewall blocking the WebSocket upgrade, or the server dropping the connection due to inactivity.
Fix: The code above handles this via the close event listener. Ensure your network allows outbound WebSocket connections to api.mypurecloud.com on port 443. If you are behind a corporate proxy, configure the WebSocket constructor to use the proxy agent.

Error: Subscription Not Received

Cause: The subscription payload was sent before the WebSocket connection was fully established, or the payload format was incorrect.
Fix: The code uses a 100ms delay before sending subscriptions to ensure the server is ready. Ensure the topics array in createSubscriptionPayload matches the API documentation exactly. Invalid topic names will be ignored by the server without error.

Official References