Submit and Track WhatsApp Message Template Approvals in Genesys Cloud Using Node.js
What You Will Build
- A Node.js module that constructs, validates, and submits WhatsApp message templates to Genesys Cloud for provider approval.
- Uses the Genesys Cloud Messaging API v2 and the official Node.js SDK.
- Covers TypeScript/JavaScript with async/await, axios, and built-in validation pipelines.
Prerequisites
- OAuth 2.0 Client Credentials flow with scopes:
messaging:template:write,messaging:template:read,whatsapp:template:manage,messaging:webhook:read - Genesys Cloud Node SDK
@genesyscloud/purecloud-sdkv1.0.0+ - Node.js 18+ LTS
- External dependencies:
npm i axios zod uuid fs-extra
Authentication Setup
Genesys Cloud uses standard OAuth 2.0 client credentials for server-to-server integrations. The following code fetches a bearer token, caches it, and handles refresh logic before any API call.
import axios from 'axios';
const GENESYS_ENV = 'mypurecloud.ie';
const CLIENT_ID = process.env.GENESYS_CLIENT_ID;
const CLIENT_SECRET = process.env.GENESYS_CLIENT_SECRET;
let authToken = null;
let tokenExpiry = 0;
async function getAuthToken() {
const now = Date.now();
if (authToken && now < tokenExpiry) {
return authToken;
}
const response = await axios.post(
`https://${GENESYS_ENV}/oauth/token`,
{
grant_type: 'client_credentials',
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
scope: 'messaging:template:write messaging:template:read whatsapp:template:manage'
},
{
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
}
);
authToken = response.data.access_token;
tokenExpiry = now + (response.data.expires_in * 1000) - 5000; // 5s buffer
return authToken;
}
Implementation
Step 1: Payload Construction and Schema Validation
WhatsApp templates require strict adherence to Meta provider constraints. You must normalize language codes, validate the category matrix, enforce maximum variable counts, and verify media attachment formats. The following pipeline uses zod for schema validation and implements policy compliance and spam score verification before submission.
import { z } from 'zod';
import { v4 as uuidv4 } from 'uuid';
// Language normalization mapping for WhatsApp provider constraints
const LANGUAGE_MAP = {
'en-US': 'en', 'en-GB': 'en', 'pt-BR': 'pt', 'pt-PT': 'pt',
'es-ES': 'es', 'es-MX': 'es', 'fr-FR': 'fr', 'de-DE': 'de',
'ja-JP': 'ja', 'zh-CN': 'zh', 'zh-TW': 'zh'
};
function normalizeLanguage(input) {
const normalized = LANGUAGE_MAP[input] || input.split('-')[0];
return normalized;
}
// Zod schema for WhatsApp template component
const ComponentSchema = z.object({
type: z.enum(['HEADER', 'BODY', 'BUTTON']),
text: z.string().optional(),
format: z.enum(['TEXT', 'IMAGE', 'VIDEO', 'DOCUMENT', 'AUDIO']).optional(),
examples: z.array(z.object({ language: z.string(), text: z.string() })).optional()
});
// Policy compliance and spam score verification pipeline
async function runCompliancePipeline(payload) {
const policyViolations = [];
const spamScore = Math.random() * 10; // Simulated spam scoring service
// Check for prohibited marketing language in utility templates
if (payload.category === 'UTILITY' && /buy|discount|free|limited/i.test(payload.components[0]?.text || '')) {
policyViolations.push('Marketing language detected in UTILITY category');
}
// Spam threshold check
if (spamScore > 8.5) {
policyViolations.push(`High spam score detected: ${spamScore.toFixed(2)}`);
}
if (policyViolations.length > 0) {
throw new Error(`Pipeline rejection: ${policyViolations.join(', ')}`);
}
return { passed: true, spamScore, timestamp: new Date().toISOString() };
}
export async function buildAndValidateTemplate(rawConfig) {
const { name, language, category, components } = rawConfig;
// Validate against provider constraints
const validCategories = ['UTILITY', 'MARKETING', 'AUTHENTICATION'];
if (!validCategories.includes(category)) {
throw new Error(`Invalid category: ${category}. Must be one of ${validCategories.join(', ')}`);
}
const normalizedLang = normalizeLanguage(language);
// Count variables across all components
const variableRegex = /\{\{(\d+)\}\}/g;
let maxVarIndex = 0;
components.forEach(comp => {
const matches = comp.text?.match(variableRegex) || [];
matches.forEach(m => {
const idx = parseInt(m.replace(/\{\{|\}\}/g, ''), 10);
if (idx > maxVarIndex) maxVarIndex = idx;
});
});
if (maxVarIndex > 3) {
throw new Error(`Variable count exceeds provider limit. Found ${maxVarIndex}, maximum is 3.`);
}
// Media attachment validation
components.forEach(comp => {
if (['IMAGE', 'VIDEO', 'DOCUMENT', 'AUDIO'].includes(comp.format)) {
if (!comp.examples || comp.examples.length === 0) {
throw new Error(`Media component ${comp.format} requires example URLs for provider validation.`);
}
}
});
// Run compliance pipeline
const pipelineResult = await runCompliancePipeline({ category, components });
const validatedPayload = {
name,
language: normalizedLang,
category,
components: components.map(c => ({
type: c.type,
text: c.text,
format: c.format || 'TEXT',
examples: c.examples || undefined
})),
metadata: {
requestId: uuidv4(),
complianceCheck: pipelineResult,
submittedAt: new Date().toISOString()
}
};
return validatedPayload;
}
Step 2: Atomic POST Submission and Format Verification
The submission uses an atomic POST operation to the Genesys Cloud Messaging API. The code includes exponential backoff retry logic for 429 rate limits and verifies the response format before proceeding.
import axios from 'axios';
const BASE_URL = `https://${process.env.GENESYS_ENV || 'mypurecloud.ie'}/api/v2`;
async function submitTemplateWithRetry(payload, token, maxRetries = 3) {
let attempt = 0;
while (attempt < maxRetries) {
try {
const response = await axios.post(
`${BASE_URL}/messaging/whatsapp/templates`,
payload,
{
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'Accept': 'application/json'
},
timeout: 15000
}
);
// Format verification
if (!response.data.id || !response.data.status) {
throw new Error('Unexpected response format from Genesys Cloud API');
}
return response.data;
} catch (error) {
if (error.response?.status === 429) {
const retryAfter = error.response.headers['retry-after'] || Math.pow(2, attempt);
console.warn(`Rate limited. Retrying in ${retryAfter}s...`);
await new Promise(res => setTimeout(res, retryAfter * 1000));
attempt++;
continue;
}
throw error;
}
}
throw new Error('Max retries exceeded for template submission');
}
Step 3: Status Polling and Webhook Synchronization
Genesys Cloud updates template status asynchronously as Meta processes the request. You must poll the template endpoint and synchronize the final state with external marketing platforms via webhook callbacks.
async function pollTemplateStatus(templateId, token, intervalMs = 5000, maxAttempts = 60) {
for (let i = 0; i < maxAttempts; i++) {
const response = await axios.get(
`${BASE_URL}/messaging/whatsapp/templates/${templateId}`,
{
headers: { 'Authorization': `Bearer ${token}` },
timeout: 10000
}
);
const status = response.data.status;
console.log(`Poll ${i + 1}: Template status is ${status}`);
if (['APPROVED', 'REJECTED', 'UPDATE_REQUESTED'].includes(status)) {
return response.data;
}
await new Promise(res => setTimeout(res, intervalMs));
}
throw new Error('Polling timeout: Template status did not resolve');
}
async function syncWebhook(templateData, externalEndpoint) {
try {
await axios.post(externalEndpoint, {
event: 'template.approval.updated',
payload: {
id: templateData.id,
name: templateData.name,
status: templateData.status,
providerMessage: templateData.providerMessage,
updatedAt: templateData.updatedAt
}
}, { timeout: 8000 });
} catch (err) {
console.error('Webhook sync failed:', err.message);
}
}
Step 4: Metrics Tracking and Audit Logging
Production messaging systems require governance. This step tracks submission latency, calculates success rates, and writes structured audit logs for compliance review.
import fs from 'fs-extra';
const METRICS = { total: 0, success: 0, failed: 0, latencies: [] };
export async function trackMetrics(durationMs, isSuccess) {
METRICS.total++;
if (isSuccess) {
METRICS.success++;
} else {
METRICS.failed++;
}
METRICS.latencies.push(durationMs);
}
export function getMetrics() {
const avgLatency = METRICS.latencies.length > 0
? METRICS.latencies.reduce((a, b) => a + b, 0) / METRICS.latencies.length
: 0;
return {
total: METRICS.total,
success: METRICS.success,
failed: METRICS.failed,
successRate: METRICS.total > 0 ? (METRICS.success / METRICS.total * 100).toFixed(2) + '%' : '0%',
avgLatencyMs: avgLatency.toFixed(2)
};
}
export async function writeAuditLog(action, templateId, details) {
const logEntry = {
timestamp: new Date().toISOString(),
action,
templateId,
details,
metrics: getMetrics()
};
await fs.appendFile('audit_logs.json', JSON.stringify(logEntry) + '\n');
}
Complete Working Example
The following script combines authentication, validation, submission, polling, webhook sync, and audit logging into a single executable module. Replace the environment variables with your Genesys Cloud credentials.
import { getAuthToken } from './auth.js';
import { buildAndValidateTemplate } from './validation.js';
import { submitTemplateWithRetry } from './submit.js';
import { pollTemplateStatus, syncWebhook } from './polling.js';
import { trackMetrics, writeAuditLog } from './metrics.js';
async function main() {
const startTime = Date.now();
const templateConfig = {
name: 'shipping_update_v2',
language: 'en-US',
category: 'UTILITY',
components: [
{
type: 'BODY',
text: 'Your order {{1}} is out for delivery. Expected arrival: {{2}}.'
}
]
};
try {
// 1. Authenticate
const token = await getAuthToken();
// 2. Validate and construct payload
const payload = await buildAndValidateTemplate(templateConfig);
await writeAuditLog('template.validation.passed', null, { name: payload.name });
// 3. Submit to Genesys Cloud
const submissionResult = await submitTemplateWithRetry(payload, token);
const submissionLatency = Date.now() - startTime;
await trackMetrics(submissionLatency, true);
await writeAuditLog('template.submitted', submissionResult.id, { latencyMs: submissionLatency });
console.log('Template submitted successfully:', submissionResult);
// 4. Poll for provider approval
const finalStatus = await pollTemplateStatus(submissionResult.id, token);
await writeAuditLog('template.status.resolved', finalStatus.id, { status: finalStatus.status });
// 5. Sync with external marketing platform
await syncWebhook(finalStatus, process.env.EXTERNAL_WEBHOOK_URL || 'https://example.com/webhook');
console.log('Final status:', finalStatus.status);
console.log('Metrics:', require('./metrics.js').getMetrics());
} catch (error) {
const failureLatency = Date.now() - startTime;
await trackMetrics(failureLatency, false);
await writeAuditLog('template.operation.failed', null, { error: error.message, latencyMs: failureLatency });
console.error('Operation failed:', error.message);
}
}
main();
Common Errors & Debugging
Error: 400 Bad Request
- What causes it: Invalid category, unsupported language code, variable count exceeding provider limits, or malformed component structure.
- How to fix it: Verify the
categorymatchesUTILITY,MARKETING, orAUTHENTICATION. Ensure variable placeholders follow the{{1}}format and do not exceed three. Check that media components include valid example URLs. - Code showing the fix:
// Validation guard before submission
if (maxVarIndex > 3) {
throw new Error(`Variable count exceeds provider limit. Found ${maxVarIndex}, maximum is 3.`);
}
Error: 401 Unauthorized or 403 Forbidden
- What causes it: Expired OAuth token or missing scopes.
- How to fix it: Ensure the client credentials request includes
messaging:template:writeandwhatsapp:template:manage. Implement token caching with a refresh buffer as shown in the Authentication Setup section. - Code showing the fix:
// Token refresh buffer prevents edge-case 401s
tokenExpiry = now + (response.data.expires_in * 1000) - 5000;
Error: 429 Too Many Requests
- What causes it: Exceeding Genesys Cloud API rate limits during bulk template submissions.
- How to fix it: Implement exponential backoff retry logic. The
submitTemplateWithRetryfunction handles this automatically by reading theRetry-Afterheader or calculating delay based on attempt count. - Code showing the fix:
if (error.response?.status === 429) {
const retryAfter = error.response.headers['retry-after'] || Math.pow(2, attempt);
await new Promise(res => setTimeout(res, retryAfter * 1000));
attempt++;
continue;
}
Error: Provider Rejection (Status: REJECTED)
- What causes it: Meta rejects the template due to policy violations, spam triggers, or missing example translations.
- How to fix it: Review the
providerMessagefield in the polling response. Adjust template text to remove promotional language inUTILITYcategories. Ensure allexamplesarrays contain valid localized text. - Code showing the fix:
// Compliance pipeline simulation
if (payload.category === 'UTILITY' && /buy|discount|free|limited/i.test(payload.components[0]?.text || '')) {
throw new Error('Marketing language detected in UTILITY category');
}