Handling NICE Cognigy Webhook Events with TypeScript

Handling NICE Cognigy Webhook Events with TypeScript

What You Will Build

You will build an Express server that receives NICE Cognigy webhook events, validates cryptographic signatures, enforces idempotency via Redis, and processes business logic asynchronously. You will use the Cognigy Cloud inbound webhook HTTP interface with HMAC-SHA256 signature verification. You will write the implementation in TypeScript with Node.js 18+.

Prerequisites

  • Node.js 18 or higher
  • npm packages: express, zod, redis, bullmq, @types/express, typescript, tsx
  • Redis instance running locally or remotely
  • Cognigy Cloud workspace with webhook routing configured
  • Environment variables: COGNIGY_WEBHOOK_SECRET, REDIS_URL, CRM_API_KEY, CRM_ENDPOINT
  • No OAuth scopes are required. Cognigy secures inbound webhooks using HMAC signature verification rather than token-based authentication.

Authentication Setup

Cognigy does not use OAuth for webhook delivery. The platform signs the raw request body with a shared secret and transmits the result in the X-Cognigy-Signature header. You must configure this secret in your Cognigy Cloud workspace under Settings > Webhooks. The verification process requires a timing-safe comparison to prevent side-channel attacks.

import crypto from 'crypto';

const WEBHOOK_SECRET = process.env.COGNIGY_WEBHOOK_SECRET || '';

function verifySignature(payload: string, signature: string): boolean {
  if (!WEBHOOK_SECRET || !signature) return false;
  const hmac = crypto.createHmac('sha256', WEBHOOK_SECRET);
  const computedSignature = hmac.update(payload).digest('hex');
  return crypto.timingSafeEqual(
    Buffer.from(computedSignature),
    Buffer.from(signature)
  );
}

The function computes the expected HMAC-SHA256 digest and compares it against the incoming header. If the secret changes or the payload is modified in transit, the comparison returns false and the request is rejected.

Implementation

Step 1: Express Middleware and Route Matching

Cognigy requires the raw request body for signature verification. Express default JSON parsers modify the body stream, which breaks HMAC validation. You must disable automatic parsing for the webhook route and attach a raw body parser.

import express, { Request, Response, NextFunction } from 'express';

const app = express();
app.use(express.json({ limit: '1mb' }));

app.post('/webhooks/cognigy', express.raw({ type: 'application/json' }), async (req: Request, res: Response, next: NextFunction) => {
  const startTime = Date.now();
  (req as any).startTime = startTime;
  next();
});

The express.raw() middleware preserves the exact byte stream sent by Cognigy. The route captures the start timestamp for latency tracking. Cognigy enforces a strict 5-second response timeout, so all heavy processing must occur asynchronously.

Step 2: Zod Schema Definition and Payload Parsing

Define strict Zod schemas that match the Cognigy Cloud event structure. Cognigy sends structured payloads containing session identifiers, user input, intent data, and custom variables.

import { z } from 'zod';

const CognigyEventSchema = z.object({
  eventId: z.string().uuid(),
  eventType: z.enum(['userMessage', 'botMessage', 'sessionUpdate', 'intentMatch']),
  timestamp: z.string().datetime(),
  session: z.object({
    sessionId: z.string(),
    userId: z.string().nullable(),
    channel: z.string(),
    variables: z.record(z.any()).optional()
  }),
  message: z.object({
    text: z.string(),
    intent: z.string().nullable(),
    confidence: z.number().min(0).max(1).nullable()
  }).optional(),
  metadata: z.record(z.any()).optional()
});

type CognigyEvent = z.infer<typeof CognigyEventSchema>;

The schema enforces type safety at runtime. The z.record(z.any()) pattern handles dynamic session variables without breaking validation. The safeParse method returns a structured error object instead of throwing, which enables precise HTTP 400 responses.

Step 3: Signature Verification and Request Validation

Combine raw body parsing, cryptographic verification, and schema validation into a single middleware chain. The server must reject malformed requests immediately to prevent queue congestion.

app.post('/webhooks/cognigy', express.raw({ type: 'application/json' }), async (req: Request, res: Response, next: NextFunction) => {
  const startTime = Date.now();
  (req as any).startTime = startTime;

  try {
    const signature = req.headers['x-cognigy-signature'] as string;
    if (!verifySignature(req.body.toString(), signature)) {
      res.status(401).json({ error: 'Invalid signature' });
      return;
    }

    const parsedBody = JSON.parse(req.body.toString());
    const validationResult = CognigyEventSchema.safeParse(parsedBody);

    if (!validationResult.success) {
      res.status(400).json({ error: 'Invalid payload structure', details: validationResult.error.errors });
      return;
    }

    (req as any).cognigyEvent = validationResult.data;
    next();
  } catch (error) {
    next(error);
  }
});

Realistic Inbound Request

POST /webhooks/cognigy HTTP/1.1
Host: api.yourdomain.com
Content-Type: application/json
X-Cognigy-Signature: a1b2c3d4e5f6...

{
  "eventId": "550e8400-e29b-41d4-a716-446655440000",
  "eventType": "userMessage",
  "timestamp": "2024-06-15T14:32:10Z",
  "session": {
    "sessionId": "sess_98765",
    "userId": "usr_12345",
    "channel": "webchat",
    "variables": { "priority": "high" }
  },
  "message": {
    "text": "I need to update my billing address",
    "intent": "update_billing",
    "confidence": 0.94
  },
  "metadata": { "tenant": "acme_corp" }
}

Expected Response

HTTP/1.1 200 OK
Content-Type: application/json

{ "status": "accepted", "eventId": "550e8400-e29b-41d4-a716-446655440000" }

Step 4: Idempotent Event Processing with Redis Locks

Cognigy retries failed webhooks up to three times. Duplicate processing corrupts CRM records and inflates analytics. You must implement a distributed lock keyed by eventId to guarantee exactly-once processing.

import { createClient } from 'redis';

const redisClient = createClient({ url: process.env.REDIS_URL });
await redisClient.connect();

async function acquireIdempotencyLock(eventId: string, ttlSeconds: number = 300): Promise<boolean> {
  const lockKey = `cognigy:lock:${eventId}`;
  const acquired = await redisClient.set(lockKey, '1', { NX: true, EX: ttlSeconds });
  return acquired === 'OK';
}

The NX flag ensures the key is only created if it does not exist. The EX flag sets a time-to-live of 300 seconds. This window covers typical retry intervals while automatically expiring stale locks. If the lock exists, the server returns HTTP 200 to acknowledge receipt without reprocessing.

Step 5: Async Queue Worker for CRM Synchronization

Direct HTTP calls to external CRM systems block the event loop and violate the 5-second timeout. You must offload CRM synchronization to a background worker. BullMQ provides reliable job persistence and retry mechanisms.

import { Queue, Worker, Job } from 'bullmq';

const crmQueue = new Queue('crm-sync', { connection: redisClient });

async function processCrmJob(job: Job) {
  const { eventId, sessionId, userId, messageText } = job.data;
  try {
    const response = await fetch(process.env.CRM_ENDPOINT!, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${process.env.CRM_API_KEY}`
      },
      body: JSON.stringify({
        sessionId,
        userId,
        interactionText: messageText,
        source: 'cognigy-webhook',
        timestamp: new Date().toISOString()
      })
    });

    if (!response.ok) {
      throw new Error(`CRM API returned ${response.status}`);
    }

    await job.moveToCompleted({ status: 'synced' });
  } catch (error) {
    await job.moveToFailed(error as Error);
  }
}

const worker = new Worker('crm-sync', processCrmJob, {
  connection: redisClient,
  concurrency: 5
});

The worker processes jobs in parallel up to the concurrency limit. Failed jobs automatically retry based on BullMQ default backoff strategies. The webhook handler acknowledges receipt immediately after queueing, ensuring Cognigy stops retrying.

Step 6: Latency Tracking and Audit Logging

Compliance requirements demand immutable audit trails. You must log processing latency, status codes, event identifiers, and error states. The logging middleware attaches to the response lifecycle to capture final outcomes.

app.post('/webhooks/cognigy', express.raw({ type: 'application/json' }), async (req: Request, res: Response, next: NextFunction) => {
  const startTime = Date.now();
  (req as any).startTime = startTime;

  try {
    const signature = req.headers['x-cognigy-signature'] as string;
    if (!verifySignature(req.body.toString(), signature)) {
      res.status(401).json({ error: 'Invalid signature' });
      return;
    }

    const parsedBody = JSON.parse(req.body.toString());
    const validationResult = CognigyEventSchema.safeParse(parsedBody);

    if (!validationResult.success) {
      res.status(400).json({ error: 'Invalid payload structure', details: validationResult.error.errors });
      return;
    }

    const event: CognigyEvent = validationResult.data;

    const lockAcquired = await acquireIdempotencyLock(event.eventId);
    if (!lockAcquired) {
      console.log(`[AUDIT] Duplicate event ignored | EventId: ${event.eventId} | Latency: ${Date.now() - startTime}ms`);
      res.status(200).json({ status: 'duplicate', eventId: event.eventId });
      return;
    }

    await crmQueue.add('sync', {
      eventId: event.eventId,
      sessionId: event.session.sessionId,
      userId: event.session.userId,
      messageText: event.message?.text || ''
    });

    const latency = Date.now() - startTime;
    console.log(`[AUDIT] Processed ${event.eventType} | EventId: ${event.eventId} | Latency: ${latency}ms | Status: 200`);

    res.status(200).json({ status: 'accepted', eventId: event.eventId });
  } catch (error) {
    const latency = Date.now() - startTime;
    console.error(`[AUDIT] Error processing webhook | Latency: ${latency}ms | Status: 500 | Error: ${(error as Error).message}`);
    res.status(500).json({ error: 'Internal processing error' });
  }
});

The audit logs capture timing, event classification, and outcome status. These logs feed directly into SIEM platforms or log aggregation tools for security compliance and performance monitoring.

Complete Working Example

import express, { Request, Response, NextFunction } from 'express';
import crypto from 'crypto';
import { z } from 'zod';
import { createClient } from 'redis';
import { Queue, Worker, Job } from 'bullmq';

const app = express();
app.use(express.json({ limit: '1mb' }));

const WEBHOOK_SECRET = process.env.COGNIGY_WEBHOOK_SECRET || '';
const redisClient = createClient({ url: process.env.REDIS_URL || 'redis://localhost:6379' });

const CognigyEventSchema = z.object({
  eventId: z.string().uuid(),
  eventType: z.enum(['userMessage', 'botMessage', 'sessionUpdate', 'intentMatch']),
  timestamp: z.string().datetime(),
  session: z.object({
    sessionId: z.string(),
    userId: z.string().nullable(),
    channel: z.string(),
    variables: z.record(z.any()).optional()
  }),
  message: z.object({
    text: z.string(),
    intent: z.string().nullable(),
    confidence: z.number().min(0).max(1).nullable()
  }).optional(),
  metadata: z.record(z.any()).optional()
});

type CognigyEvent = z.infer<typeof CognigyEventSchema>;

function verifySignature(payload: string, signature: string): boolean {
  if (!WEBHOOK_SECRET || !signature) return false;
  const hmac = crypto.createHmac('sha256', WEBHOOK_SECRET);
  const computedSignature = hmac.update(payload).digest('hex');
  return crypto.timingSafeEqual(
    Buffer.from(computedSignature),
    Buffer.from(signature)
  );
}

async function acquireIdempotencyLock(eventId: string, ttlSeconds: number = 300): Promise<boolean> {
  const lockKey = `cognigy:lock:${eventId}`;
  const acquired = await redisClient.set(lockKey, '1', { NX: true, EX: ttlSeconds });
  return acquired === 'OK';
}

const crmQueue = new Queue('crm-sync', { connection: redisClient });

async function processCrmJob(job: Job) {
  const { eventId, sessionId, userId, messageText } = job.data;
  try {
    const response = await fetch(process.env.CRM_ENDPOINT || 'https://api.example-crm.com/v1/interactions', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${process.env.CRM_API_KEY}`
      },
      body: JSON.stringify({
        sessionId,
        userId,
        interactionText: messageText,
        source: 'cognigy-webhook',
        timestamp: new Date().toISOString()
      })
    });

    if (!response.ok) {
      throw new Error(`CRM API returned ${response.status}`);
    }

    await job.moveToCompleted({ status: 'synced' });
  } catch (error) {
    await job.moveToFailed(error as Error);
  }
}

const worker = new Worker('crm-sync', processCrmJob, {
  connection: redisClient,
  concurrency: 5
});

app.post('/webhooks/cognigy', express.raw({ type: 'application/json' }), async (req: Request, res: Response, next: NextFunction) => {
  const startTime = Date.now();
  (req as any).startTime = startTime;

  try {
    const signature = req.headers['x-cognigy-signature'] as string;
    if (!verifySignature(req.body.toString(), signature)) {
      res.status(401).json({ error: 'Invalid signature' });
      return;
    }

    const parsedBody = JSON.parse(req.body.toString());
    const validationResult = CognigyEventSchema.safeParse(parsedBody);

    if (!validationResult.success) {
      res.status(400).json({ error: 'Invalid payload structure', details: validationResult.error.errors });
      return;
    }

    const event: CognigyEvent = validationResult.data;

    const lockAcquired = await acquireIdempotencyLock(event.eventId);
    if (!lockAcquired) {
      console.log(`[AUDIT] Duplicate event ignored | EventId: ${event.eventId} | Latency: ${Date.now() - startTime}ms`);
      res.status(200).json({ status: 'duplicate', eventId: event.eventId });
      return;
    }

    await crmQueue.add('sync', {
      eventId: event.eventId,
      sessionId: event.session.sessionId,
      userId: event.session.userId,
      messageText: event.message?.text || ''
    });

    const latency = Date.now() - startTime;
    console.log(`[AUDIT] Processed ${event.eventType} | EventId: ${event.eventId} | Latency: ${latency}ms | Status: 200`);

    res.status(200).json({ status: 'accepted', eventId: event.eventId });
  } catch (error) {
    const latency = Date.now() - startTime;
    console.error(`[AUDIT] Error processing webhook | Latency: ${latency}ms | Status: 500 | Error: ${(error as Error).message}`);
    res.status(500).json({ error: 'Internal processing error' });
  }
});

async function startServer() {
  await redisClient.connect();
  const PORT = process.env.PORT || 3000;
  app.listen(PORT, () => {
    console.log(`Cognigy webhook handler listening on port ${PORT}`);
  });
}

startServer();

Common Errors & Debugging

Error: HTTP 401 Unauthorized

  • Cause: The HMAC signature does not match the computed digest. This occurs when the shared secret is mismatched, the payload is modified by a reverse proxy, or the header casing differs.
  • Fix: Verify the COGNIGY_WEBHOOK_SECRET matches the Cognigy workspace configuration. Ensure no middleware modifies the request body before signature verification. Check that the header name is exactly x-cognigy-signature.

Error: HTTP 400 Bad Request

  • Cause: Zod schema validation fails. Cognigy payload structures change during platform updates. Missing required fields or incorrect data types trigger rejection.
  • Fix: Log validationResult.error.errors to identify the exact field violation. Update the Zod schema to match the current Cognigy API version. Use .optional() for fields that may be absent in specific event types.

Error: Redis Lock Collision (Duplicate Processing)

  • Cause: The NX lock returns false because the eventId is already processing. Cognigy retries failed requests within the TTL window.
  • Fix: Return HTTP 200 immediately when the lock is held. Increase the TTL if your CRM sync jobs exceed 300 seconds. Implement a dead-letter queue for jobs that fail repeatedly.

Error: HTTP 500 Internal Server Error

  • Cause: Redis connection failure, queue rejection, or unhandled promise rejection. The event loop blocks and Cognigy times out.
  • Fix: Add connection retry logic for Redis. Wrap queue operations in try-catch blocks. Monitor worker concurrency limits. Implement circuit breakers for external CRM endpoints.

Official References