Deleting Genesys Cloud EventBridge Unused Targets via Node.js
What You Will Build
- A Node.js module that identifies orphaned EventBridge targets, validates dependency constraints, executes atomic HTTP DELETE operations, and generates structured audit logs.
- The implementation uses the Genesys Cloud REST API with OAuth 2.0 client credentials and native
fetchfor HTTP operations. - The tutorial covers Node.js 18+ with zero external dependencies, focusing on production-grade error handling, pagination, and metrics tracking.
Prerequisites
- OAuth confidential client registered in Genesys Cloud with the following scopes:
eventbridge:targets:read,eventbridge:targets:write,eventbridge:rules:read - Genesys Cloud API v2 base domain (e.g.,
usw2.pure.cloud) - Node.js 18 or higher (native
fetchsupport) - No external npm packages required
Authentication Setup
Genesys Cloud uses OAuth 2.0 client credentials flow. You must exchange your client ID and secret for an access token before calling any EventBridge endpoints. The token expires after 3600 seconds. The following function handles initial authentication and implements a refresh guard to prevent repeated token requests.
const BASE_DOMAIN = process.env.GENESYS_DOMAIN || 'usw2.pure.cloud';
const CLIENT_ID = process.env.GENESYS_CLIENT_ID;
const CLIENT_SECRET = process.env.GENESYS_CLIENT_SECRET;
const API_BASE = `https://${BASE_DOMAIN}/api/v2`;
const OAUTH_BASE = `https://${BASE_DOMAIN}/oauth`;
let cachedToken = null;
let tokenExpiry = 0;
async function getAccessToken() {
if (cachedToken && Date.now() < tokenExpiry - 60000) {
return cachedToken;
}
const response = await fetch(`${OAUTH_BASE}/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
scope: 'eventbridge:targets:read eventbridge:targets:write eventbridge:rules:read'
})
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`OAuth authentication failed ${response.status}: ${errorText}`);
}
const data = await response.json();
cachedToken = data.access_token;
tokenExpiry = Date.now() + (data.expires_in * 1000);
return cachedToken;
}
The getAccessToken function caches the token and refreshes only when less than 60 seconds remain before expiry. This prevents unnecessary authentication requests during batch operations.
Implementation
Step 1: Initialize API Client & Retry Logic
All HTTP operations require a stable client that handles rate limiting and transient failures. The following helper wraps fetch, injects the bearer token, and implements exponential backoff for 429 responses.
async function apiCall(url, options = {}) {
const token = await getAccessToken();
const headers = {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
...options.headers
};
const maxRetries = 3;
let attempt = 0;
while (attempt <= maxRetries) {
const response = await fetch(url, { ...options, headers });
if (response.status === 429 && attempt < maxRetries) {
const retryAfter = response.headers.get('Retry-After') || Math.pow(2, attempt);
await new Promise(resolve => setTimeout(resolve, retryAfter * 1000));
attempt++;
continue;
}
if (!response.ok) {
const errorBody = await response.text();
throw new Error(`API request failed ${response.status} ${url}: ${errorBody}`);
}
if (response.status === 204) return null;
return response.json();
}
throw new Error(`Max retries exceeded for ${url}`);
}
The apiCall function automatically attaches the OAuth token, parses JSON responses, and retries on 429 rate limits. It throws structured errors for all other HTTP failures.
Step 2: Fetch Targets & Build Usage Matrix
You must retrieve all EventBridge targets and calculate their reference counts. The usage matrix maps each target ID to the number of rules that reference it. Genesys Cloud returns paginated results using pageNumber and pageSize.
async function fetchAllTargets() {
const targets = [];
let pageNumber = 1;
const pageSize = 20;
while (true) {
const response = await apiCall(`${API_BASE}/eventbridge/targets`, {
headers: { 'Accept': 'application/json' },
params: { pageNumber, pageSize }
});
if (!response.entities || response.entities.length === 0) break;
targets.push(...response.entities);
if (targets.length >= response.total) break;
pageNumber++;
}
return targets;
}
async function fetchAllRules() {
const rules = [];
let pageNumber = 1;
const pageSize = 20;
while (true) {
const response = await apiCall(`${API_BASE}/eventbridge/rules`, {
headers: { 'Accept': 'application/json' }
});
if (!response.entities || response.entities.length === 0) break;
rules.push(...response.entities);
if (rules.length >= response.total) break;
pageNumber++;
}
return rules;
}
function buildUsageMatrix(targets, rules) {
const matrix = {};
targets.forEach(target => {
matrix[target.id] = {
targetRef: target.id,
name: target.name,
referenceCount: 0,
referencedByRules: []
};
});
rules.forEach(rule => {
const targetId = rule.targetId;
if (matrix[targetId]) {
matrix[targetId].referenceCount++;
matrix[targetId].referencedByRules.push({
ruleId: rule.id,
name: rule.name,
status: rule.status
});
}
});
return matrix;
}
The buildUsageMatrix function iterates through rules and increments the reference count for each target. This matrix drives the orphan detection logic in the next step.
Step 3: Validate Purge Directives & Dependency Constraints
Before deletion, you must validate targets against a purge directive. The directive defines maximum batch limits, cascade deletion rules, and active status constraints. Targets with active rules or non-zero reference counts must be rejected.
const PURGE_DIRECTIVE = {
maxBatchSize: 10,
allowCascadeDelete: false,
requireOrphanStatus: true,
maxActiveRuleThreshold: 0
};
function validatePurgeCandidate(targetData, directive) {
const violations = [];
if (directive.requireOrphanStatus && targetData.referenceCount > 0) {
violations.push(`Target ${targetData.targetRef} has ${targetData.referenceCount} active rule references`);
}
const activeRules = targetData.referencedByRules.filter(r => r.status === 'ACTIVE');
if (activeRules.length > directive.maxActiveRuleThreshold) {
violations.push(`Target ${targetData.targetRef} has ${activeRules.length} active rules`);
}
if (!directive.allowCascadeDelete && targetData.referenceCount > 0) {
violations.push(`Cascade deletion is disabled. Target ${targetData.targetRef} cannot be removed with dependencies`);
}
return {
isValid: violations.length === 0,
violations,
targetRef: targetData.targetRef
};
}
The validation function returns a structured result indicating whether a target meets the purge directive. It enforces orphan detection and blocks cascade deletion unless explicitly permitted.
Step 4: Execute Atomic DELETE Operations & Orphan Detection
Validated targets are deleted using atomic HTTP DELETE operations. Each deletion is tracked for latency, success rate, and webhook synchronization. The purge iteration processes targets in batches to respect API limits.
async function deleteTarget(targetId, auditLogger) {
const startTime = performance.now();
const targetRef = targetId;
try {
await apiCall(`${API_BASE}/eventbridge/targets/${targetId}`, {
method: 'DELETE'
});
const latency = performance.now() - startTime;
auditLogger.log({
action: 'TARGET_DELETED',
targetRef,
status: 'SUCCESS',
latencyMs: Math.round(latency * 100) / 100,
timestamp: new Date().toISOString()
});
return { targetRef, success: true, latency };
} catch (error) {
const latency = performance.now() - startTime;
auditLogger.log({
action: 'TARGET_DELETE_FAILED',
targetRef,
status: 'FAILED',
error: error.message,
latencyMs: Math.round(latency * 100) / 100,
timestamp: new Date().toISOString()
});
return { targetRef, success: false, latency, error: error.message };
}
}
async function processPurgeBatch(validTargets, auditLogger, webhookUrl) {
const batch = validTargets.slice(0, PURGE_DIRECTIVE.maxBatchSize);
const results = [];
for (const target of batch) {
const result = await deleteTarget(target.targetRef, auditLogger);
results.push(result);
if (result.success && webhookUrl) {
try {
await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
event: 'TARGET_REMOVED',
targetRef: result.targetRef,
timestamp: new Date().toISOString(),
purgeBatchId: crypto.randomUUID()
})
});
} catch (webhookError) {
auditLogger.log({
action: 'WEBHOOK_FAILURE',
targetRef: result.targetRef,
error: webhookError.message,
timestamp: new Date().toISOString()
});
}
}
}
return results;
}
The processPurgeBatch function iterates through validated targets, executes atomic deletes, measures latency, and triggers external webhooks on success. Failures are captured without halting the batch.
Step 5: Webhook Synchronization & Audit Logging
The audit logger aggregates all operations into a structured pipeline. It tracks purge success rates, latency averages, and generates governance-ready logs.
class PurgeAuditLogger {
constructor() {
this.logs = [];
this.metrics = {
totalProcessed: 0,
successfulDeletions: 0,
failedDeletions: 0,
totalLatencyMs: 0
};
}
log(entry) {
this.logs.push(entry);
this.metrics.totalProcessed++;
if (entry.status === 'SUCCESS') {
this.metrics.successfulDeletions++;
this.metrics.totalLatencyMs += entry.latencyMs;
} else if (entry.action.includes('DELETE_FAILED')) {
this.metrics.failedDeletions++;
}
}
getReport() {
const successRate = this.metrics.totalProcessed > 0
? (this.metrics.successfulDeletions / this.metrics.totalProcessed * 100).toFixed(2)
: 0;
const avgLatency = this.metrics.successfulDeletions > 0
? (this.metrics.totalLatencyMs / this.metrics.successfulDeletions).toFixed(2)
: 0;
return {
metrics: { ...this.metrics, successRate, avgLatencyMs: avgLatency },
logs: this.logs
};
}
}
The logger maintains in-memory state for the current purge run. You can extend it to write to stdout, file, or external logging services by modifying the log method.
Complete Working Example
The following script combines all components into a single executable module. It fetches targets, builds the usage matrix, validates against the purge directive, processes deletions in batches, and outputs a governance report.
const crypto = require('crypto');
const BASE_DOMAIN = process.env.GENESYS_DOMAIN || 'usw2.pure.cloud';
const CLIENT_ID = process.env.GENESYS_CLIENT_ID;
const CLIENT_SECRET = process.env.GENESYS_CLIENT_SECRET;
const API_BASE = `https://${BASE_DOMAIN}/api/v2`;
const OAUTH_BASE = `https://${BASE_DOMAIN}/oauth`;
const WEBHOOK_URL = process.env.TARGET_REMOVED_WEBHOOK_URL || null;
let cachedToken = null;
let tokenExpiry = 0;
async function getAccessToken() {
if (cachedToken && Date.now() < tokenExpiry - 60000) {
return cachedToken;
}
const response = await fetch(`${OAUTH_BASE}/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
scope: 'eventbridge:targets:read eventbridge:targets:write eventbridge:rules:read'
})
});
if (!response.ok) throw new Error(`OAuth failed ${response.status}: ${await response.text()}`);
const data = await response.json();
cachedToken = data.access_token;
tokenExpiry = Date.now() + (data.expires_in * 1000);
return cachedToken;
}
async function apiCall(url, options = {}) {
const token = await getAccessToken();
const headers = { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', ...options.headers };
let attempt = 0;
while (attempt <= 3) {
const response = await fetch(url, { ...options, headers });
if (response.status === 429 && attempt < 3) {
await new Promise(r => setTimeout(r, (response.headers.get('Retry-After') || Math.pow(2, attempt)) * 1000));
attempt++;
continue;
}
if (!response.ok) throw new Error(`API ${response.status} ${url}: ${await response.text()}`);
if (response.status === 204) return null;
return response.json();
}
throw new Error(`Max retries exceeded for ${url}`);
}
async function fetchAllEntities(endpoint) {
const entities = [];
let pageNumber = 1;
while (true) {
const response = await apiCall(`${API_BASE}/eventbridge/${endpoint}`, { params: { pageNumber, pageSize: 20 } });
if (!response.entities || response.entities.length === 0) break;
entities.push(...response.entities);
if (entities.length >= response.total) break;
pageNumber++;
}
return entities;
}
const PURGE_DIRECTIVE = { maxBatchSize: 10, allowCascadeDelete: false, requireOrphanStatus: true, maxActiveRuleThreshold: 0 };
function validatePurgeCandidate(targetData, directive) {
const violations = [];
if (directive.requireOrphanStatus && targetData.referenceCount > 0) {
violations.push(`Target ${targetData.targetRef} has ${targetData.referenceCount} references`);
}
const activeRules = targetData.referencedByRules.filter(r => r.status === 'ACTIVE');
if (activeRules.length > directive.maxActiveRuleThreshold) {
violations.push(`Target ${targetData.targetRef} has ${activeRules.length} active rules`);
}
return { isValid: violations.length === 0, violations, targetRef: targetData.targetRef };
}
async function deleteTarget(targetId, logger) {
const start = performance.now();
try {
await apiCall(`${API_BASE}/eventbridge/targets/${targetId}`, { method: 'DELETE' });
const latency = performance.now() - start;
logger.log({ action: 'TARGET_DELETED', targetRef: targetId, status: 'SUCCESS', latencyMs: Math.round(latency * 100) / 100, timestamp: new Date().toISOString() });
return { targetRef: targetId, success: true, latency };
} catch (error) {
const latency = performance.now() - start;
logger.log({ action: 'TARGET_DELETE_FAILED', targetRef: targetId, status: 'FAILED', error: error.message, latencyMs: Math.round(latency * 100) / 100, timestamp: new Date().toISOString() });
return { targetRef: targetId, success: false, latency, error: error.message };
}
}
class PurgeAuditLogger {
constructor() {
this.logs = [];
this.metrics = { totalProcessed: 0, successfulDeletions: 0, failedDeletions: 0, totalLatencyMs: 0 };
}
log(entry) {
this.logs.push(entry);
this.metrics.totalProcessed++;
if (entry.status === 'SUCCESS') { this.metrics.successfulDeletions++; this.metrics.totalLatencyMs += entry.latencyMs; }
else if (entry.action.includes('DELETE_FAILED')) this.metrics.failedDeletions++;
}
getReport() {
const successRate = this.metrics.totalProcessed > 0 ? (this.metrics.successfulDeletions / this.metrics.totalProcessed * 100).toFixed(2) : 0;
const avgLatency = this.metrics.successfulDeletions > 0 ? (this.metrics.totalLatencyMs / this.metrics.successfulDeletions).toFixed(2) : 0;
return { metrics: { ...this.metrics, successRate, avgLatencyMs: avgLatency }, logs: this.logs };
}
}
async function runPurge() {
console.log('Fetching EventBridge targets and rules...');
const targets = await fetchAllEntities('targets');
const rules = await fetchAllEntities('rules');
const matrix = {};
targets.forEach(t => { matrix[t.id] = { targetRef: t.id, name: t.name, referenceCount: 0, referencedByRules: [] }; });
rules.forEach(r => { if (matrix[r.targetId]) { matrix[r.targetId].referenceCount++; matrix[r.targetId].referencedByRules.push({ ruleId: r.id, name: r.name, status: r.status }); } });
const candidates = Object.values(matrix).map(d => validatePurgeCandidate(d, PURGE_DIRECTIVE));
const validTargets = candidates.filter(c => c.isValid).map(c => matrix[c.targetRef]);
const rejected = candidates.filter(c => !c.isValid);
console.log(`Validated ${validTargets.length} targets for purge. Rejected ${rejected.length} due to dependencies.`);
if (rejected.length > 0) rejected.forEach(r => console.log(' REJECTED:', r.violations.join('; ')));
const logger = new PurgeAuditLogger();
const batchResults = [];
let offset = 0;
while (offset < validTargets.length) {
const batch = validTargets.slice(offset, offset + PURGE_DIRECTIVE.maxBatchSize);
const results = [];
for (const target of batch) {
const res = await deleteTarget(target.targetRef, logger);
results.push(res);
if (res.success && WEBHOOK_URL) {
try { await fetch(WEBHOOK_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ event: 'TARGET_REMOVED', targetRef: res.targetRef, timestamp: new Date().toISOString(), purgeBatchId: crypto.randomUUID() }) }); }
catch (e) { logger.log({ action: 'WEBHOOK_FAILURE', targetRef: res.targetRef, error: e.message, timestamp: new Date().toISOString() }); }
}
}
batchResults.push(...results);
offset += PURGE_DIRECTIVE.maxBatchSize;
}
console.log('Purge complete. Generating audit report...');
console.log(JSON.stringify(logger.getReport(), null, 2));
}
runPurge().catch(err => { console.error('Fatal error during purge:', err); process.exit(1); });
Common Errors & Debugging
Error: 401 Unauthorized
- Cause: Expired OAuth token or invalid client credentials.
- Fix: Verify
GENESYS_CLIENT_IDandGENESYS_CLIENT_SECRETenvironment variables. Ensure the token cache refreshes before expiry. ThegetAccessTokenfunction automatically handles refresh, but network timeouts during token exchange will trigger this error. - Code fix: Add explicit credential validation before execution.
Error: 403 Forbidden
- Cause: Missing OAuth scopes or insufficient permissions for the EventBridge API.
- Fix: Confirm the OAuth client has
eventbridge:targets:read,eventbridge:targets:write, andeventbridge:rules:read. Assign the client to a user or service account with EventBridge Administrator role. - Code fix: Check the token introspection endpoint to verify granted scopes.
Error: 409 Conflict
- Cause: Attempting to delete a target referenced by active rules or violating cascade constraints.
- Fix: The validation pipeline catches this before deletion. Review the
violationsarray in the rejected candidates list. Deactivate or reassign dependent rules before purging. - Code fix: Adjust
PURGE_DIRECTIVE.allowCascadeDeleteto true only when rule cleanup is handled externally.
Error: 429 Too Many Requests
- Cause: Exceeding Genesys Cloud rate limits during batch operations.
- Fix: The
apiCallfunction implements exponential backoff. If persistent, reducePURGE_DIRECTIVE.maxBatchSizeand add delays between batches. - Code fix: Monitor
Retry-Afterheader values and adjust sleep intervals accordingly.