Submit and Track WhatsApp Message Template Approvals in Genesys Cloud Using Node.js

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-sdk v1.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 category matches UTILITY, MARKETING, or AUTHENTICATION. 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:write and whatsapp: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 submitTemplateWithRetry function handles this automatically by reading the Retry-After header 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 providerMessage field in the polling response. Adjust template text to remove promotional language in UTILITY categories. Ensure all examples arrays 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');
}

Official References