Programmatically extending agent wrap-up timers based on interaction complexity using the Routing API and a Node.js function triggered by interaction.completed events
What You Will Build
- A Node.js Express webhook service that intercepts Genesys Cloud
interaction.completedevents, calculates interaction complexity from duration and custom attributes, and programmatically extends the agent wrap-up timer. - Uses the official
@genesyscloud/purecloud-platform-client-v2SDK to callPUT /api/v2/routing/users/{userId}/wrapupwith dynamic timeout values. - Covers modern JavaScript with explicit OAuth token management, structured error handling, and exponential backoff for 429 rate-limit responses.
Prerequisites
- OAuth 2.0 Client Credentials flow with scopes:
interaction:read,routing:wrapup:write,routing:users:read - Genesys Cloud Node.js SDK
@genesyscloud/purecloud-platform-client-v2version 2.100.0 or higher - Node.js 18 LTS runtime
- External dependencies:
express,axios,dotenv - A publicly accessible HTTPS endpoint to receive Genesys Cloud event webhooks
Authentication Setup
Genesys Cloud APIs require an OAuth 2.0 Bearer token. The Client Credentials flow exchanges an application client ID and secret for a scoped access token. The token expires after 3600 seconds. Production implementations must cache the token and request a new one before expiry to avoid 401 Unauthorized responses during high-volume event processing.
The following module handles token retrieval, caching, and automatic refresh:
const axios = require('axios');
require('dotenv').config();
const GENESYS_BASE_URL = process.env.GENESYS_BASE_URL || 'https://api.mypurecloud.com';
const CLIENT_ID = process.env.GENESYS_CLIENT_ID;
const CLIENT_SECRET = process.env.GENESYS_CLIENT_SECRET;
const REQUIRED_SCOPES = 'interaction:read routing:wrapup:write routing:users:read';
let tokenCache = {
accessToken: null,
expiresAt: 0
};
/**
* Retrieves a valid OAuth access token.
* Returns cached token if valid, otherwise fetches a new one.
*/
async function getAccessToken() {
const now = Date.now();
if (tokenCache.accessToken && now < tokenCache.expiresAt - 60000) {
return tokenCache.accessToken;
}
try {
const response = await axios.post(`${GENESYS_BASE_URL}/oauth/token`, null, {
params: {
grant_type: 'client_credentials',
scope: REQUIRED_SCOPES
},
auth: {
username: CLIENT_ID,
password: CLIENT_SECRET
},
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
});
tokenCache.accessToken = response.data.access_token;
tokenCache.expiresAt = now + (response.data.expires_in * 1000);
return tokenCache.accessToken;
} catch (error) {
if (error.response) {
throw new Error(`OAuth token fetch failed with status ${error.response.status}: ${error.response.data.error_description || error.response.statusText}`);
}
throw new Error(`Network error during OAuth token fetch: ${error.message}`);
}
}
The HTTP cycle for this request follows this pattern:
- Method:
POST - Path:
/oauth/token - Headers:
Content-Type: application/x-www-form-urlencoded,Authorization: Basic <base64(client_id:client_secret)> - Query Parameters:
grant_type=client_credentials&scope=interaction:read%20routing:wrapup:write%20routing:users:read - Response Body:
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "interaction:read routing:wrapup:write routing:users:read"
}
Implementation
Step 1: Event Webhook Handler and Payload Validation
Genesys Cloud delivers interaction.completed events as JSON payloads to configured webhook endpoints. The webhook must validate the event type, extract the agent user ID, interaction duration, and any custom attributes attached during the conversation. Invalid or malformed payloads must return a 200 OK response immediately to prevent Genesys Cloud from retrying the delivery.
const express = require('express');
const app = express();
app.use(express.json());
app.post('/webhook/genesys', async (req, res) => {
try {
const payload = req.body;
// Genesys Cloud requires immediate 200 OK to acknowledge receipt
res.status(200).send('OK');
if (!payload || payload.eventType !== 'interaction.completed') {
console.log('Ignoring non-interaction.completed event');
return;
}
const userId = payload.userId;
const interactionId = payload.interactionId;
const duration = payload.duration || 0;
const customAttributes = payload.customAttributes || {};
if (!userId) {
console.error('Missing userId in event payload');
return;
}
console.log(`Received interaction.completed for user ${userId}, interaction ${interactionId}`);
// Proceed to complexity calculation and wrap-up extension
await processInteractionComplexity(userId, interactionId, duration, customAttributes);
} catch (error) {
console.error('Webhook processing error:', error.message);
// Still return 200 OK if already sent, otherwise log and continue
}
});
Expected event payload structure:
{
"eventType": "interaction.completed",
"timestamp": "2024-05-15T18:32:10Z",
"organizationId": "org-12345",
"userId": "agent-uuid-67890",
"interactionId": "conv-uuid-abcde",
"type": "voice",
"duration": 342,
"customAttributes": {
"case_complexity": "high",
"transfer_count": 2,
"escalation_required": true
}
}
Step 2: Complexity Calculation and Timeout Mapping
Wrap-up extension logic must translate interaction metadata into a deterministic timeout value. The Routing API accepts wrap-up timeouts in seconds. A common pattern evaluates duration thresholds, transfer counts, and explicit custom attributes to assign a complexity tier. This tier maps to a specific wrap-up duration to prevent agents from being forced into available status before documentation is complete.
/**
* Calculates wrap-up timeout based on interaction metadata.
* Returns timeout in seconds.
*/
function calculateWrapUpTimeout(duration, customAttributes) {
const transfers = parseInt(customAttributes.transfer_count || '0', 10);
const complexity = customAttributes.case_complexity || 'low';
const escalation = customAttributes.escalation_required === true || customAttributes.escalation_required === 'true';
let baseTimeout = 60; // Default low complexity
// Adjust based on duration
if (duration > 300) baseTimeout = 120;
if (duration > 600) baseTimeout = 180;
// Adjust based on transfers
if (transfers >= 1) baseTimeout += 60;
if (transfers >= 2) baseTimeout += 90;
// Adjust based on explicit complexity flags
if (complexity === 'medium') baseTimeout += 60;
if (complexity === 'high') baseTimeout += 120;
if (escalation) baseTimeout += 60;
// Cap at maximum allowed wrap-up time (Genesys Cloud enforces platform limits, typically 3600 seconds)
return Math.min(baseTimeout, 3600);
}
The logic above ensures deterministic output. An interaction lasting 420 seconds with two transfers and a high complexity flag yields a 420-second wrap-up timeout. The function avoids floating-point arithmetic and returns an integer suitable for the Routing API payload.
Step 3: Routing API Call with Retry Logic
The PUT /api/v2/routing/users/{userId}/wrapup endpoint updates the wrap-up state for a specific agent. The SDK method putRoutingUserWrapup handles serialization and authentication header injection. Production code must implement retry logic for 429 Too Many Requests responses, as Genesys Cloud enforces per-endpoint rate limits. The retry strategy uses exponential backoff with jitter to prevent thundering herd scenarios.
const PureCloudPlatformClientV2 = require('@genesyscloud/purecloud-platform-client-v2');
const genesysClient = new PureCloudPlatformClientV2.PureCloudPlatformClientV2();
genesysClient.setServerBaseUrl(GENESYS_BASE_URL);
/**
* Executes a request with exponential backoff on 429 responses.
*/
async function executeWithRetry(fn, maxRetries = 3) {
let attempt = 0;
while (true) {
try {
return await fn();
} catch (error) {
attempt++;
if (error.response && error.response.status === 429 && attempt <= maxRetries) {
const retryAfter = error.response.headers['retry-after']
? parseInt(error.response.headers['retry-after'], 10)
: Math.pow(2, attempt) + (Math.random() * 0.5);
console.warn(`Rate limited (429). Retrying in ${retryAfter}s (attempt ${attempt}/${maxRetries})`);
await new Promise(resolve => setTimeout(resolve, retryAfter * 1000));
continue;
}
throw error;
}
}
}
async function processInteractionComplexity(userId, interactionId, duration, customAttributes) {
const timeoutSeconds = calculateWrapUpTimeout(duration, customAttributes);
const wrapupPayload = {
wrapUpRequired: true,
wrapUpTimeout: timeoutSeconds
};
console.log(`Setting wrap-up timeout for user ${userId} to ${timeoutSeconds}s`);
try {
await executeWithRetry(async () => {
const token = await getAccessToken();
genesysClient.setAccessToken(token);
// SDK call maps to: PUT /api/v2/routing/users/{userId}/wrapup
const response = await genesysClient.RoutingApi.putRoutingUserWrapup(userId, wrapupPayload);
console.log(`Wrap-up updated successfully. Status: ${response.status}`);
return response;
});
} catch (error) {
if (error.response) {
console.error(`Routing API failed with status ${error.response.status}: ${JSON.stringify(error.response.data)}`);
} else {
console.error(`Routing API network error: ${error.message}`);
}
throw error;
}
}
The underlying HTTP request generated by the SDK follows this structure:
- Method:
PUT - Path:
/api/v2/routing/users/{userId}/wrapup - Headers:
Authorization: Bearer <token>,Content-Type: application/json,Accept: application/json - Request Body:
{
"wrapUpRequired": true,
"wrapUpTimeout": 240
}
- Response Body (200 OK):
{
"wrapUpRequired": true,
"wrapUpTimeout": 240
}
The executeWithRetry wrapper intercepts 429 responses, parses the Retry-After header if present, applies exponential backoff with jitter, and retries the SDK call. This prevents cascading failures during peak interaction completion windows.
Complete Working Example
The following file combines authentication, event handling, complexity calculation, and API execution into a single runnable Node.js module. Save it as index.js and run it with node index.js after installing dependencies.
require('dotenv').config();
const express = require('express');
const axios = require('axios');
const PureCloudPlatformClientV2 = require('@genesyscloud/purecloud-platform-client-v2');
// Configuration
const GENESYS_BASE_URL = process.env.GENESYS_BASE_URL || 'https://api.mypurecloud.com';
const CLIENT_ID = process.env.GENESYS_CLIENT_ID;
const CLIENT_SECRET = process.env.GENESYS_CLIENT_SECRET;
const REQUIRED_SCOPES = 'interaction:read routing:wrapup:write routing:users:read';
const PORT = process.env.PORT || 3000;
// OAuth Token Cache
let tokenCache = { accessToken: null, expiresAt: 0 };
async function getAccessToken() {
const now = Date.now();
if (tokenCache.accessToken && now < tokenCache.expiresAt - 60000) {
return tokenCache.accessToken;
}
const response = await axios.post(`${GENESYS_BASE_URL}/oauth/token`, null, {
params: { grant_type: 'client_credentials', scope: REQUIRED_SCOPES },
auth: { username: CLIENT_ID, password: CLIENT_SECRET },
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
});
tokenCache.accessToken = response.data.access_token;
tokenCache.expiresAt = now + (response.data.expires_in * 1000);
return tokenCache.accessToken;
}
// SDK Initialization
const genesysClient = new PureCloudPlatformClientV2.PureCloudPlatformClientV2();
genesysClient.setServerBaseUrl(GENESYS_BASE_URL);
// Retry Logic
async function executeWithRetry(fn, maxRetries = 3) {
let attempt = 0;
while (true) {
try {
return await fn();
} catch (error) {
attempt++;
if (error.response && error.response.status === 429 && attempt <= maxRetries) {
const retryAfter = error.response.headers['retry-after']
? parseInt(error.response.headers['retry-after'], 10)
: Math.pow(2, attempt) + (Math.random() * 0.5);
console.warn(`Rate limited (429). Retrying in ${retryAfter}s (attempt ${attempt}/${maxRetries})`);
await new Promise(resolve => setTimeout(resolve, retryAfter * 1000));
continue;
}
throw error;
}
}
}
// Complexity Calculation
function calculateWrapUpTimeout(duration, customAttributes) {
const transfers = parseInt(customAttributes.transfer_count || '0', 10);
const complexity = customAttributes.case_complexity || 'low';
const escalation = customAttributes.escalation_required === true || customAttributes.escalation_required === 'true';
let baseTimeout = 60;
if (duration > 300) baseTimeout = 120;
if (duration > 600) baseTimeout = 180;
if (transfers >= 1) baseTimeout += 60;
if (transfers >= 2) baseTimeout += 90;
if (complexity === 'medium') baseTimeout += 60;
if (complexity === 'high') baseTimeout += 120;
if (escalation) baseTimeout += 60;
return Math.min(baseTimeout, 3600);
}
// Core Processing
async function processInteractionComplexity(userId, interactionId, duration, customAttributes) {
const timeoutSeconds = calculateWrapUpTimeout(duration, customAttributes);
const wrapupPayload = { wrapUpRequired: true, wrapUpTimeout: timeoutSeconds };
console.log(`Setting wrap-up timeout for user ${userId} to ${timeoutSeconds}s`);
await executeWithRetry(async () => {
const token = await getAccessToken();
genesysClient.setAccessToken(token);
await genesysClient.RoutingApi.putRoutingUserWrapup(userId, wrapupPayload);
console.log(`Wrap-up updated successfully for ${userId}`);
});
}
// Express Webhook Server
const app = express();
app.use(express.json());
app.post('/webhook/genesys', async (req, res) => {
try {
res.status(200).send('OK');
const payload = req.body;
if (!payload || payload.eventType !== 'interaction.completed' || !payload.userId) {
return;
}
await processInteractionComplexity(payload.userId, payload.interactionId, payload.duration || 0, payload.customAttributes || {});
} catch (error) {
console.error('Webhook processing error:', error.message);
}
});
app.listen(PORT, () => {
console.log(`Wrap-up extension webhook listening on port ${PORT}`);
});
Create a .env file with the following variables before execution:
GENESYS_BASE_URL=https://api.mypurecloud.com
GENESYS_CLIENT_ID=your_client_id_here
GENESYS_CLIENT_SECRET=your_client_secret_here
PORT=3000
Common Errors & Debugging
Error: 401 Unauthorized
- Cause: The OAuth token has expired, the client credentials are invalid, or the token is not being attached to the SDK request.
- Fix: Verify the
.envcredentials. EnsuregetAccessToken()is called before every API request. The token cache logic in this tutorial refreshes tokens 60 seconds before expiry. Check server logs forOAuth token fetch failedmessages. - Code showing the fix: The
getAccessToken()function explicitly throws a descriptive error on non-200 responses. Catch it and log theerror.response.data.error_descriptionfield.
Error: 403 Forbidden
- Cause: The OAuth client lacks the required
routing:wrapup:writescope, or the application user does not have the Routing Wrapup permission assigned in the Genesys Cloud admin console. - Fix: Navigate to the OAuth client configuration in Genesys Cloud and add
routing:wrapup:write. Assign a role with Wrapup management permissions to the service account. - Debugging: Print the active token scopes using
axios.get('/api/v2/oauth/clientinfo')to verify scope propagation.
Error: 429 Too Many Requests
- Cause: The Routing API endpoint has exceeded the per-minute rate limit for your organization.
- Fix: The
executeWithRetryfunction implements exponential backoff. Ensure your event consumer processes interactions asynchronously to avoid synchronous blocking. Scale horizontally if event volume exceeds single-instance throughput. - Code showing the fix: The retry wrapper checks
error.response.status === 429and respects theRetry-Afterheader. IncreasemaxRetriesif transient throttling persists during peak hours.
Error: 404 Not Found
- Cause: The
userIdin the event payload does not match an active Genesys Cloud user, or the user is not assigned to a routing queue. - Fix: Validate
userIdagainst the/api/v2/users/{userId}endpoint before calling the wrap-up API. Filter out events whereuserIdis missing or belongs to a system bot. - Debugging: Add a guard clause:
if (!userId || userId.length < 36) return;before SDK invocation.
Error: SDK Serialization Mismatch
- Cause: Passing undefined values or incorrect types to
wrapUpTimeoutorwrapUpRequired. - Fix: Ensure
wrapUpTimeoutis an integer andwrapUpRequiredis a boolean. ThecalculateWrapUpTimeoutfunction explicitly returnsMath.min(baseTimeout, 3600)to guarantee valid integer output. - Debugging: Log
JSON.stringify(wrapupPayload)before the SDK call to verify structure matches theWrapupSettingsmodel.