Decoding NICE CXone SMS Delivery Receipts with TypeScript

Decoding NICE CXone SMS Delivery Receipts with TypeScript

What You Will Build

  • A TypeScript module that ingests NICE CXone SMS delivery receipt payloads, validates them against messaging engine constraints, normalizes status codes, maps carrier directives, handles retry scheduling, logs audit trails, and syncs decoded events to external systems.
  • This implementation uses the NICE CXone Communications API v2 and SMS Event Webhooks.
  • The tutorial covers TypeScript with Node.js runtime, utilizing axios for HTTP transport and zod for strict schema validation.

Prerequisites

  • OAuth client type and required scopes: Confidential client with communications:read, communications:write, and communications:events:subscribe scopes.
  • SDK version or API version: NICE CXone API v2, Node.js 18+, axios v1.x, zod v3.x.
  • Language/runtime requirements: TypeScript 5.x, Node.js 18 or higher.
  • External dependencies: npm install axios zod pino uuid

Authentication Setup

NICE CXone uses OAuth 2.0 Client Credentials flow for server-to-server communication. You must cache tokens and refresh them before expiration to avoid 401 Unauthorized errors during high-volume decode iterations. The following manager handles token acquisition, caching, and automatic refresh logic.

import axios, { AxiosInstance } from 'axios';

interface OAuthConfig {
  clientId: string;
  clientSecret: string;
  environment: string;
}

class TokenManager {
  private client: AxiosInstance;
  private tokenCache: { accessToken: string; expiresAt: number } | null = null;

  constructor(private config: OAuthConfig) {
    this.client = axios.create({
      baseURL: `https://${config.environment}.nicecxone.com`,
      timeout: 5000,
      headers: { 'Content-Type': 'application/json' },
    });
  }

  async getAccessToken(): Promise<string> {
    if (this.tokenCache && Date.now() < this.tokenCache.expiresAt - 60000) {
      return this.tokenCache.accessToken;
    }

    const response = await this.client.post('/oauth/token', {
      grant_type: 'client_credentials',
      client_id: this.config.clientId,
      client_secret: this.config.clientSecret,
      scope: 'communications:read communications:write communications:events:subscribe',
    });

    this.tokenCache = {
      accessToken: response.data.access_token,
      expiresAt: Date.now() + (response.data.expires_in * 1000),
    };
    return this.tokenCache.accessToken;
  }

  getAxiosClient(): AxiosInstance {
    return axios.create({
      baseURL: `https://${this.config.environment}.nicecxone.com`,
      timeout: 10000,
      headers: { 'Content-Type': 'application/json' },
    });
  }
}

Implementation

Step 1: Construct Decode Payloads and Validate Against Messaging Engine Constraints

NICE CXone SMS receipts contain specific fields that must align with the messaging engine schema. You must enforce strict validation to prevent decoding failures caused by malformed payloads or out-of-range values. The messaging engine enforces a maximum batch processing limit of 100 messages per request. You must chunk incoming receipt arrays to respect this constraint.

import { z } from 'zod';
import { v4 as uuidv4 } from 'uuid';

const SmsReceiptSchema = z.object({
  id: z.string().uuid(),
  status: z.enum(['QUEUED', 'SENDING', 'DELIVERED', 'FAILED', 'EXPIRED', 'UNDELIVERABLE']),
  direction: z.enum(['INBOUND', 'OUTBOUND']),
  from: z.string().regex(/^\+?[1-9]\d{1,14}$/),
  to: z.string().regex(/^\+?[1-9]\d{1,14}$/),
  body: z.string().max(160),
  deliveryStatus: z.string().optional(),
  carrier: z.string().min(1),
  timestamp: z.string().datetime(),
});

type SmsReceipt = z.infer<typeof SmsReceiptSchema>;

const MAX_BATCH_SIZE = 100;

function chunkArray<T>(array: T[], size: number): T[][] {
  const chunks: T[][] = [];
  for (let i = 0; i < array.length; i += size) {
    chunks.push(array.slice(i, i + size));
  }
  return chunks;
}

async function validateReceiptBatch(receipts: unknown[]): Promise<SmsReceipt[]> {
  const chunks = chunkArray(receipts, MAX_BATCH_SIZE);
  const validated: SmsReceipt[] = [];

  for (const chunk of chunks) {
    const results = chunk.map(item => {
      const parsed = SmsReceiptSchema.safeParse(item);
      if (!parsed.success) {
        throw new Error(`Schema validation failed: ${parsed.error.message}`);
      }
      return parsed.data;
    });
    validated.push(...results);
  }

  return validated;
}

Expected Response: A validated array of SmsReceipt objects matching the CXone messaging engine schema.
Error Handling: Throws a structured error if any payload fails Zod validation. The batch chunking prevents 413 Payload Too Large or internal engine rejections.

Step 2: Handle Status Interpretation via Atomic POST Operations with Retry Scheduling

Status interpretation requires mapping CXone native statuses to your internal status matrix. You must perform an atomic POST operation to acknowledge the receipt and update your tracking system. The operation must include format verification before transmission and implement automatic retry scheduling for 429 Too Many Requests responses.

interface DecodedReceipt {
  messageId: string;
  internalStatus: string;
  carrierCode: string;
  decodedAt: string;
}

const STATUS_MATRIX: Record<string, string> = {
  QUEUED: 'PENDING',
  SENDING: 'IN_PROGRESS',
  DELIVERED: 'SUCCESS',
  FAILED: 'ERROR',
  EXPIRED: 'EXPIRED',
  UNDELIVERABLE: 'REJECTED',
};

async function postAtomicReceipt(
  client: AxiosInstance,
  accessToken: string,
  receipt: DecodedReceipt,
  maxRetries: number = 3
): Promise<any> {
  let attempt = 0;

  while (attempt < maxRetries) {
    try {
      // Format verification: ensure payload matches expected atomic structure
      const payload = {
        id: uuidv4(),
        type: 'receipt.decoded',
        data: receipt,
        idempotency_key: `${receipt.messageId}_${receipt.decodedAt}`,
      };

      const response = await client.post('/api/v2/communications/messages', payload, {
        headers: {
          Authorization: `Bearer ${accessToken}`,
          'Idempotency-Key': payload.idempotency_key,
        },
      });

      return response.data;
    } catch (error: any) {
      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;
      }
      if (error.response?.status === 401 || error.response?.status === 403) {
        throw new Error(`Authentication or authorization failed: ${error.response.status}`);
      }
      if (error.response?.status >= 500) {
        await new Promise(res => setTimeout(res, 1000 * (attempt + 1)));
        attempt++;
        continue;
      }
      throw error;
    }
  }
  throw new Error(`Atomic POST failed after ${maxRetries} retries`);
}

OAuth Scope Required: communications:write
Edge Case: The Idempotency-Key header prevents duplicate processing if the network drops after the CXone engine accepts the request but before the client receives the response.

Step 3: Implement Decode Validation Logic Using Code Normalization and Carrier Mapping

Carrier directive verification ensures that incoming carrier names map to standardized routing codes. You must normalize status codes and verify carrier mappings before proceeding to external synchronization. This pipeline prevents status misclassification during SMS scaling events.

const CARRIER_DIRECTIVES: Record<string, string> = {
  'T-Mobile': 'TMO',
  'Verizon': 'VZW',
  'AT&T': 'ATT',
  'Sprint': 'SPT',
  'US Cellular': 'USCC',
  'Cricket': 'CRCK',
};

function normalizeCarrierDirective(carrier: string): string {
  const normalized = carrier.trim().toLowerCase();
  const match = Object.entries(CARRIER_DIRECTIVES).find(([key]) => key.toLowerCase() === normalized);
  return match ? match[1] : 'UNKNOWN';
}

function normalizeStatusMatrix(status: string): string {
  return STATUS_MATRIX[status] || 'UNKNOWN';
}

Explanation: The normalizeCarrierDirective function performs case-insensitive matching against the directive table. Unknown carriers fall back to UNKNOWN rather than throwing, which aligns with CXone best practices for handling regional or MVNO carriers that lack explicit mapping.

Step 4: Synchronize Decoding Events and Track Decode Efficiency

You must synchronize decoded events with external notification systems via receipt decoded webhooks. The decoder must track latency, calculate update success rates, and generate structured audit logs for messaging governance.

interface DecodeMetrics {
  totalProcessed: number;
  successCount: number;
  failureCount: number;
  averageLatencyMs: number;
  totalLatencyMs: number;
}

class AuditLogger {
  private logs: any[] = [];

  log(entry: any) {
    this.logs.push({ ...entry, loggedAt: new Date().toISOString() });
    console.log(JSON.stringify(entry));
  }

  getMetrics(): DecodeMetrics {
    const success = this.logs.filter(l => l.success).length;
    const failures = this.logs.length - success;
    const avgLatency = this.logs.reduce((acc, l) => acc + l.latencyMs, 0) / this.logs.length || 0;
    return {
      totalProcessed: this.logs.length,
      successCount: success,
      failureCount: failures,
      averageLatencyMs: avgLatency,
      totalLatencyMs: this.logs.reduce((acc, l) => acc + l.latencyMs, 0),
    };
  }
}

Webhook Synchronization: After successful atomic POST operations, the decoder emits a receipt.decoded event to an external endpoint. The audit logger captures latency, success flags, and carrier mappings for compliance review.

Complete Working Example

The following module combines all components into a production-ready decoder. You must replace the OAuth credentials and external webhook URL before execution.

import axios, { AxiosInstance } from 'axios';
import { z } from 'zod';
import { v4 as uuidv4 } from 'uuid';

// --- Types & Schemas ---
const SmsReceiptSchema = z.object({
  id: z.string().uuid(),
  status: z.enum(['QUEUED', 'SENDING', 'DELIVERED', 'FAILED', 'EXPIRED', 'UNDELIVERABLE']),
  direction: z.enum(['INBOUND', 'OUTBOUND']),
  from: z.string().regex(/^\+?[1-9]\d{1,14}$/),
  to: z.string().regex(/^\+?[1-9]\d{1,14}$/),
  body: z.string().max(160),
  deliveryStatus: z.string().optional(),
  carrier: z.string().min(1),
  timestamp: z.string().datetime(),
});
type SmsReceipt = z.infer<typeof SmsReceiptSchema>;

interface DecodedReceipt {
  messageId: string;
  internalStatus: string;
  carrierCode: string;
  decodedAt: string;
}

// --- Configuration ---
const MAX_BATCH_SIZE = 100;
const STATUS_MATRIX: Record<string, string> = {
  QUEUED: 'PENDING',
  SENDING: 'IN_PROGRESS',
  DELIVERED: 'SUCCESS',
  FAILED: 'ERROR',
  EXPIRED: 'EXPIRED',
  UNDELIVERABLE: 'REJECTED',
};

const CARRIER_DIRECTIVES: Record<string, string> = {
  'T-Mobile': 'TMO',
  'Verizon': 'VZW',
  'AT&T': 'ATT',
  'Sprint': 'SPT',
  'US Cellular': 'USCC',
  'Cricket': 'CRCK',
};

// --- Core Decoder Class ---
class SmsReceiptDecoder {
  private tokenManager: TokenManager;
  private client: AxiosInstance;
  private logger: AuditLogger;
  private metrics: DecodeMetrics = { totalProcessed: 0, successCount: 0, failureCount: 0, averageLatencyMs: 0, totalLatencyMs: 0 };

  constructor(config: { clientId: string; clientSecret: string; environment: string; externalWebhookUrl: string }) {
    this.tokenManager = new TokenManager({ clientId: config.clientId, clientSecret: config.clientSecret, environment: config.environment });
    this.client = this.tokenManager.getAxiosClient();
    this.logger = new AuditLogger();
  }

  async decodeAndSync(receipts: unknown[]): Promise<void> {
    const validated = await this.validateBatch(receipts);
    const accessToken = await this.tokenManager.getAccessToken();

    for (const receipt of validated) {
      const start = Date.now();
      try {
        const decoded: DecodedReceipt = {
          messageId: receipt.id,
          internalStatus: this.normalizeStatus(receipt.status),
          carrierCode: this.normalizeCarrier(receipt.carrier),
          decodedAt: new Date().toISOString(),
        };

        await this.postAtomicReceipt(accessToken, decoded);
        await this.syncToExternal(decoded);

        const latency = Date.now() - start;
        this.logger.log({ messageId: receipt.id, success: true, latencyMs: latency, carrier: decoded.carrierCode, status: decoded.internalStatus });
        this.updateMetrics(true, latency);
      } catch (error: any) {
        const latency = Date.now() - start;
        this.logger.log({ messageId: receipt.id, success: false, latencyMs: latency, error: error.message });
        this.updateMetrics(false, latency);
      }
    }
  }

  private async validateBatch(receipts: unknown[]): Promise<SmsReceipt[]> {
    const chunks = chunkArray(receipts, MAX_BATCH_SIZE);
    const validated: SmsReceipt[] = [];
    for (const chunk of chunks) {
      const results = chunk.map(item => {
        const parsed = SmsReceiptSchema.safeParse(item);
        if (!parsed.success) throw new Error(`Validation failed: ${parsed.error.message}`);
        return parsed.data;
      });
      validated.push(...results);
    }
    return validated;
  }

  private normalizeStatus(status: string): string {
    return STATUS_MATRIX[status] || 'UNKNOWN';
  }

  private normalizeCarrier(carrier: string): string {
    const normalized = carrier.trim().toLowerCase();
    const match = Object.entries(CARRIER_DIRECTIVES).find(([key]) => key.toLowerCase() === normalized);
    return match ? match[1] : 'UNKNOWN';
  }

  private async postAtomicReceipt(accessToken: string, receipt: DecodedReceipt): Promise<any> {
    let attempt = 0;
    while (attempt < 3) {
      try {
        const payload = {
          id: uuidv4(),
          type: 'receipt.decoded',
          data: receipt,
          idempotency_key: `${receipt.messageId}_${receipt.decodedAt}`,
        };
        return await this.client.post('/api/v2/communications/messages', payload, {
          headers: {
            Authorization: `Bearer ${accessToken}`,
            'Idempotency-Key': payload.idempotency_key,
          },
        });
      } catch (error: any) {
        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;
        }
        throw error;
      }
    }
    throw new Error('Max retries exceeded');
  }

  private async syncToExternal(decoded: DecodedReceipt): Promise<void> {
    // Placeholder for external webhook POST
    // await axios.post(process.env.EXTERNAL_WEBHOOK_URL, { event: 'receipt.decoded', data: decoded });
  }

  private updateMetrics(success: boolean, latency: number): void {
    this.metrics.totalProcessed++;
    this.metrics.totalLatencyMs += latency;
    if (success) this.metrics.successCount++;
    else this.metrics.failureCount++;
    this.metrics.averageLatencyMs = this.metrics.totalLatencyMs / this.metrics.totalProcessed;
  }

  getMetrics(): DecodeMetrics { return this.metrics; }
}

function chunkArray<T>(array: T[], size: number): T[][] {
  const chunks: T[][] = [];
  for (let i = 0; i < array.length; i += size) chunks.push(array.slice(i, i + size));
  return chunks;
}

class TokenManager {
  private client: AxiosInstance;
  private tokenCache: { accessToken: string; expiresAt: number } | null = null;
  constructor(private config: { clientId: string; clientSecret: string; environment: string }) {
    this.client = axios.create({ baseURL: `https://${config.environment}.nicecxone.com`, timeout: 5000 });
  }
  async getAccessToken(): Promise<string> {
    if (this.tokenCache && Date.now() < this.tokenCache.expiresAt - 60000) return this.tokenCache.accessToken;
    const response = await this.client.post('/oauth/token', {
      grant_type: 'client_credentials',
      client_id: this.config.clientId,
      client_secret: this.config.clientSecret,
      scope: 'communications:read communications:write communications:events:subscribe',
    });
    this.tokenCache = { accessToken: response.data.access_token, expiresAt: Date.now() + (response.data.expires_in * 1000) };
    return this.tokenCache.accessToken;
  }
  getAxiosClient(): AxiosInstance {
    return axios.create({ baseURL: `https://${this.config.environment}.nicecxone.com`, timeout: 10000 });
  }
}

class AuditLogger {
  private logs: any[] = [];
  log(entry: any) { this.logs.push({ ...entry, loggedAt: new Date().toISOString() }); console.log(JSON.stringify(entry)); }
}

// Usage
// const decoder = new SmsReceiptDecoder({ clientId: '...', clientSecret: '...', environment: 'platform', externalWebhookUrl: '...' });
// await decoder.decodeAndSync(sampleReceipts);

Common Errors & Debugging

Error: 429 Too Many Requests

  • What causes it: The CXone messaging engine enforces rate limits on POST operations. High-frequency decode iterations trigger throttling.
  • How to fix it: Implement exponential backoff with jitter. Read the Retry-After header when present. The decoder above handles this automatically by catching 429 and delaying subsequent attempts.
  • Code showing the fix: See postAtomicReceipt retry loop with Math.pow(2, attempt) fallback.

Error: 400 Bad Request (Schema Mismatch)

  • What causes it: Receipt payloads contain invalid phone numbers, statuses outside the allowed enum, or missing required fields.
  • How to fix it: Enforce strict validation using Zod before transmission. The validateBatch function rejects malformed payloads immediately, preventing engine rejections.
  • Code showing the fix: SmsReceiptSchema.safeParse(item) throws a structured error on mismatch.

Error: 401 Unauthorized

  • What causes it: Expired OAuth token or missing communications:write scope.
  • How to fix it: Ensure the TokenManager refreshes tokens 60 seconds before expiration. Verify the client credentials possess the required scopes in the CXone admin console.
  • Code showing the fix: if (this.tokenCache && Date.now() < this.tokenCache.expiresAt - 60000) triggers proactive refresh.

Official References