Inject Mock Survey Responses into NICE CXone Outbound Campaigns Using TypeScript

Inject Mock Survey Responses into NICE CXone Outbound Campaigns Using TypeScript

What You Will Build

A TypeScript module that generates, validates, and injects synthetic survey responses into NICE CXone outbound campaigns, complete with scoring matrices, skip logic verification, demographic weighting, webhook synchronization, and audit logging. The code uses the CXone REST API via axios to execute atomic POST operations against campaign and survey endpoints. The implementation runs in Node.js 18+.

Prerequisites

  • CXone OAuth client configured as confidential with redirect URI set to urn:ietf:wg:oauth:2.0:oob
  • Required OAuth scopes: campaign:read, survey:write, webhooks:write, analytics:read
  • Node.js 18+ and TypeScript 5+
  • Dependencies: axios, zod, uuid, dotenv
  • A running CXone environment with an active outbound campaign and associated survey definition

Authentication Setup

CXone uses OAuth 2.0 client credentials flow for server-to-server API access. The following function retrieves an access token and implements exponential backoff for refresh operations.

import axios, { AxiosError } from 'axios';
import * as crypto from 'crypto';

interface CXoneAuthConfig {
  clientId: string;
  clientSecret: string;
  environment: string; // e.g., 'us-east-1'
}

interface TokenResponse {
  access_token: string;
  token_type: string;
  expires_in: number;
}

export class CXoneAuthenticator {
  private tokenCache: { token: string; expiry: number } | null = null;
  private baseAuthUrl: string;

  constructor(private config: CXoneAuthConfig) {
    this.baseAuthUrl = `https://${config.environment}.platform.niceincontact.com/oauth2/token`;
  }

  async getAccessToken(): Promise<string> {
    if (this.tokenCache && Date.now() < this.tokenCache.expiry) {
      return this.tokenCache.token;
    }

    try {
      const response = await axios.post<TokenResponse>(
        this.baseAuthUrl,
        new URLSearchParams({
          grant_type: 'client_credentials',
          client_id: this.config.clientId,
          client_secret: this.config.clientSecret,
          scope: 'campaign:read survey:write webhooks:write analytics:read'
        }).toString(),
        {
          headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
        }
      );

      this.tokenCache = {
        token: response.data.access_token,
        expiry: Date.now() + (response.data.expires_in * 1000) - 60000 // 1 minute buffer
      };

      return this.tokenCache.token;
    } catch (error) {
      if (error instanceof AxiosError && error.response?.status === 401) {
        throw new Error('OAuth authentication failed. Verify client credentials and scope permissions.');
      }
      throw error;
    }
  }
}

Implementation

Step 1: Campaign Configuration Retrieval and Schema Validation

Before injecting mock responses, you must fetch the campaign survey structure to enforce engine constraints. CXone enforces a maximum question block limit and strict schema rules for scoring matrices. The following function retrieves campaign metadata and validates it against Zod schemas.

import { z } from 'zod';

const SurveySchema = z.object({
  campaignId: z.string().uuid(),
  surveyId: z.string().uuid(),
  maxQuestionBlocks: z.number().min(1).max(50),
  questions: z.array(z.object({
    id: z.string(),
    type: z.enum(['rating', 'multiple_choice', 'text', 'demographic']),
    required: z.boolean(),
    scoringMatrix: z.record(z.string(), z.number()).optional(),
    skipLogic: z.array(z.object({
      condition: z.string(),
      targetQuestionId: z.string()
    })).optional()
  }))
});

type SurveyConfig = z.infer<typeof SurveySchema>;

export async function fetchAndValidateCampaignConfig(
  authToken: string,
  campaignId: string,
  environment: string
): Promise<SurveyConfig> {
  const baseUrl = `https://${environment}.platform.niceincontact.com/api/v2`;
  
  const response = await axios.get(`${baseUrl}/outbound/campaigns/${campaignId}`, {
    headers: { Authorization: `Bearer ${authToken}` }
  });

  const rawConfig = response.data;
  
  try {
    return SurveySchema.parse({
      campaignId: rawConfig.id,
      surveyId: rawConfig.survey.id,
      maxQuestionBlocks: rawConfig.survey.max_blocks || 30,
      questions: rawConfig.survey.questions
    });
  } catch (error) {
    if (error instanceof z.ZodError) {
      throw new Error(`Campaign survey schema validation failed: ${error.errors.map(e => e.message).join(', ')}`);
    }
    throw error;
  }
}

Step 2: Synthetic Data Generation with Demographic Weighting

Mock payloads must include response references, scoring matrices, and a simulate directive. Demographic weighting ensures the synthetic data matches production distribution patterns. The following function generates atomic POST payloads.

import { v4 as uuidv4 } from 'uuid';

interface MockResponsePayload {
  campaignId: string;
  surveyId: string;
  interactionId: string;
  simulate: boolean;
  demographicWeight: number;
  answers: Array<{
    questionId: string;
    value: string | number | string[];
    score: number | null;
  }>;
  timestamp: string;
}

export function generateMockPayload(
  config: SurveyConfig,
  demographicWeights: Record<string, number>
): MockResponsePayload {
  const answers = config.questions.map(question => {
    let value: string | number | string[];
    let score: number | null = null;

    switch (question.type) {
      case 'rating':
        value = Math.floor(Math.random() * 5) + 1;
        score = question.scoringMatrix?.[String(value)] ?? null;
        break;
      case 'multiple_choice':
        const options = ['option_a', 'option_b', 'option_c'];
        value = options[Math.floor(Math.random() * options.length)];
        score = question.scoringMatrix?.[value] ?? null;
        break;
      case 'demographic':
        const weights = Object.values(demographicWeights);
        const totalWeight = weights.reduce((a, b) => a + b, 0);
        const rand = Math.random() * totalWeight;
        let cumulative = 0;
        let selectedWeight = 0;
        for (const [key, weight] of Object.entries(demographicWeights)) {
          cumulative += weight;
          if (rand <= cumulative) {
            selectedWeight = weight;
            value = key;
            break;
          }
        }
        score = null;
        break;
      default:
        value = 'mock_text_response_' + uuidv4().slice(0, 8);
        score = null;
    }

    return { questionId: question.id, value, score };
  });

  return {
    campaignId: config.campaignId,
    surveyId: config.surveyId,
    interactionId: uuidv4(),
    simulate: true,
    demographicWeight: Object.values(demographicWeights)[0] ?? 1.0,
    answers,
    timestamp: new Date().toISOString()
  };
}

Step 3: Skip Logic Verification and Answer Consistency Pipeline

CXone survey engines enforce skip logic and answer consistency rules. Injecting invalid skip paths causes mocking failures and skewed analytics. The following pipeline validates answers against skip logic before submission.

export function verifySkipLogicAndConsistency(
  payload: MockResponsePayload,
  config: SurveyConfig
): boolean {
  const answeredIds = payload.answers.map(a => a.questionId);
  const requiredIds = config.questions.filter(q => q.required).map(q => q.id);

  for (const reqId of requiredIds) {
    if (!answeredIds.includes(reqId)) {
      console.warn(`Consistency check failed: required question ${reqId} is missing.`);
      return false;
    }
  }

  for (const question of config.questions) {
    if (!question.skipLogic) continue;

    const currentAnswer = payload.answers.find(a => a.questionId === question.id);
    if (!currentAnswer) continue;

    for (const rule of question.skipLogic) {
      const conditionMatch = String(currentAnswer.value).includes(rule.condition);
      if (conditionMatch && !answeredIds.includes(rule.targetQuestionId)) {
        console.warn(`Skip logic violation: question ${question.id} triggered skip to ${rule.targetQuestionId}, but answer missing.`);
        return false;
      }
    }
  }

  return true;
}

Step 4: Atomic POST Injection with Webhook Synchronization and Audit Logging

The final step executes the atomic POST operation, tracks latency, synchronizes with external QA dashboards via webhooks, and generates audit logs for testing governance. The code includes retry logic for 429 rate limits.

interface AuditLog {
  timestamp: string;
  campaignId: string;
  interactionId: string;
  status: 'success' | 'failed' | 'retried';
  latencyMs: number;
  error?: string;
}

export class CXoneResponseMocker {
  private auditLogs: AuditLog[] = [];
  private webhookUrl: string;

  constructor(
    private authToken: string,
    private environment: string,
    webhookUrl: string
  ) {
    this.webhookUrl = webhookUrl;
  }

  async injectMockResponse(payload: MockResponsePayload): Promise<AuditLog> {
    const baseUrl = `https://${this.environment}.platform.niceincontact.com/api/v2`;
    const startTime = Date.now();
    const logEntry: AuditLog = {
      timestamp: new Date().toISOString(),
      campaignId: payload.campaignId,
      interactionId: payload.interactionId,
      status: 'success',
      latencyMs: 0
    };

    let retries = 0;
    const maxRetries = 3;

    while (retries <= maxRetries) {
      try {
        const response = await axios.post(`${baseUrl}/survey/responses`, payload, {
          headers: {
            Authorization: `Bearer ${this.authToken}`,
            'Content-Type': 'application/json'
          }
        });

        logEntry.latencyMs = Date.now() - startTime;
        await this.syncWebhook(payload, logEntry);
        this.auditLogs.push(logEntry);
        return logEntry;
      } catch (error) {
        if (error instanceof AxiosError && error.response?.status === 429) {
          const retryAfter = parseInt(error.response?.headers['retry-after'] || '2', 10);
          console.log(`Rate limit hit. Retrying in ${retryAfter}s...`);
          await new Promise(resolve => setTimeout(resolve, retryAfter * 1000));
          retries++;
          logEntry.status = 'retried';
          continue;
        }
        if (error instanceof AxiosError && error.response?.status === 422) {
          logEntry.status = 'failed';
          logEntry.error = 'Validation error: schema mismatch or skip logic violation';
          logEntry.latencyMs = Date.now() - startTime;
          this.auditLogs.push(logEntry);
          throw new Error(`Mock injection failed: ${error.response?.data}`);
        }
        logEntry.status = 'failed';
        logEntry.error = error instanceof Error ? error.message : 'Unknown error';
        logEntry.latencyMs = Date.now() - startTime;
        this.auditLogs.push(logEntry);
        throw error;
      }
    }

    logEntry.status = 'failed';
    logEntry.error = 'Max retries exceeded for 429 rate limit';
    this.auditLogs.push(logEntry);
    throw new Error('Mock injection failed after retries');
  }

  private async syncWebhook(payload: MockResponsePayload, logEntry: AuditLog): Promise<void> {
    try {
      await axios.post(this.webhookUrl, {
        event: 'mock_response_injected',
        payload: {
          campaignId: payload.campaignId,
          interactionId: payload.interactionId,
          simulate: payload.simulate
        },
        audit: logEntry
      }, {
        headers: { 'Content-Type': 'application/json' }
      });
    } catch (error) {
      console.warn('Webhook synchronization failed:', error);
    }
  }

  getAuditLogs(): AuditLog[] {
    return this.auditLogs;
  }
}

Complete Working Example

The following script ties all components together. Replace the placeholder credentials and environment values before execution.

import dotenv from 'dotenv';
dotenv.config();

async function runMockPipeline() {
  const config = {
    clientId: process.env.CXONE_CLIENT_ID!,
    clientSecret: process.env.CXONE_CLIENT_SECRET!,
    environment: process.env.CXONE_ENVIRONMENT!,
    campaignId: process.env.CXONE_CAMPAIGN_ID!,
    webhookUrl: process.env.QA_DASHBOARD_WEBHOOK!
  };

  const authenticator = new CXoneAuthenticator(config);
  const authToken = await authenticator.getAccessToken();

  console.log('Fetching campaign configuration...');
  const surveyConfig = await fetchAndValidateCampaignConfig(authToken, config.campaignId, config.environment);

  const demographicWeights = {
    '18-24': 0.15,
    '25-34': 0.30,
    '35-44': 0.25,
    '45-54': 0.20,
    '55+': 0.10
  };

  const mocker = new CXoneResponseMocker(authToken, config.environment, config.webhookUrl);
  const batchSize = 10;

  console.log(`Generating and injecting ${batchSize} mock responses...`);
  
  for (let i = 0; i < batchSize; i++) {
    const payload = generateMockPayload(surveyConfig, demographicWeights);
    
    if (!verifySkipLogicAndConsistency(payload, surveyConfig)) {
      console.warn(`Payload ${i} failed consistency check. Skipping.`);
      continue;
    }

    try {
      const log = await mocker.injectMockResponse(payload);
      console.log(`Injection ${i} completed. Latency: ${log.latencyMs}ms`);
    } catch (error) {
      console.error(`Injection ${i} failed:`, error);
    }
  }

  const logs = mocker.getAuditLogs();
  const successRate = logs.filter(l => l.status === 'success').length / logs.length;
  console.log(`Pipeline complete. Success rate: ${(successRate * 100).toFixed(2)}%`);
  console.log('Audit logs:', JSON.stringify(logs, null, 2));
}

runMockPipeline().catch(console.error);

Common Errors & Debugging

Error: 401 Unauthorized

  • Cause: OAuth token expired, invalid client credentials, or missing scope permissions.
  • Fix: Verify CXONE_CLIENT_ID and CXONE_CLIENT_SECRET match the CXone admin console configuration. Ensure the token cache expiry buffer is respected. The CXoneAuthenticator class automatically handles refresh, but manual token expiration outside the cache window will trigger re-authentication.
  • Code Fix: The authenticator throws a descriptive error on 401. Wrap the pipeline in a try-catch and log the token expiry timestamp.

Error: 403 Forbidden

  • Cause: The OAuth client lacks survey:write or campaign:read scopes, or the campaign is locked by another process.
  • Fix: Navigate to the CXone admin console, verify the OAuth client scope assignments, and ensure the target campaign status allows injection. The simulate directive requires the campaign to be in a test or paused state.

Error: 422 Unprocessable Entity

  • Cause: Mock payload violates survey engine constraints, exceeds maximum question block limits, or contains invalid scoring matrix references.
  • Fix: Review the Zod schema validation output. Ensure maxQuestionBlocks does not exceed the survey definition limit. Verify that every scoring matrix key matches an actual answer value. The verifySkipLogicAndConsistency function catches most structural violations before the POST request.

Error: 429 Too Many Requests

  • Cause: Exceeding CXone rate limits for survey injection endpoints.
  • Fix: The CXoneResponseMocker implements automatic retry with Retry-After header parsing. For high-volume mocking, implement a token bucket rate limiter before calling injectMockResponse. Reduce batch size or add a 500ms delay between iterations.

Official References