Processing Payment Intents from NICE Cognigy Webhooks with Node.js

Processing Payment Intents from NICE Cognigy Webhooks with Node.js

What You Will Build

A Node.js service that receives payment intent webhooks from NICE Cognigy, validates transaction schemas against payment engine constraints, routes authorization directives to a payment gateway via atomic POST operations, and synchronizes results back to the dialog platform. This implementation uses the Cognigy Webhooks API and a standard REST payment gateway interface. The code covers TypeScript, Fastify, Zod schema validation, and modern async/await patterns.

Prerequisites

  • Cognigy tenant with webhook trigger configured to POST to your service endpoint
  • OAuth 2.0 Client Credentials for the payment gateway with scopes: payment:authorize, fraud:score, 3ds:verify
  • Node.js 18 or later (includes native fetch and undici)
  • Dependencies: fastify, zod, pino, uuid, dotenv
  • Payment gateway API key or OAuth client ID/secret
  • Cognigy API key for optional dialog state synchronization

Authentication Setup

The payment gateway requires OAuth 2.0 Client Credentials flow. Cognigy webhook delivery uses HMAC signature verification for request integrity. The following code demonstrates token acquisition and webhook signature validation.

import { fetch } from 'undici';
import crypto from 'crypto';

const GATEWAY_TOKEN_URL = 'https://auth.paymentgateway.com/oauth2/token';
const COGNIGY_WEBHOOK_SECRET = process.env.COGNIGY_WEBHOOK_SECRET || '';

interface GatewayTokenResponse {
  access_token: string;
  token_type: string;
  expires_in: number;
  scope: string;
}

async function acquireGatewayToken(): Promise<string> {
  const credentials = Buffer.from(
    `${process.env.GATEWAY_CLIENT_ID}:${process.env.GATEWAY_CLIENT_SECRET}`
  ).toString('base64');

  const response = await fetch(GATEWAY_TOKEN_URL, {
    method: 'POST',
    headers: {
      'Authorization': `Basic ${credentials}`,
      'Content-Type': 'application/x-www-form-urlencoded',
    },
    body: new URLSearchParams({
      grant_type: 'client_credentials',
      scope: 'payment:authorize fraud:score 3ds:verify',
    }),
  });

  if (!response.ok) {
    throw new Error(`Gateway token acquisition failed: ${response.status} ${response.statusText}`);
  }

  const data: GatewayTokenResponse = await response.json();
  return data.access_token;
}

function verifyCognigySignature(payload: string, signature: string): boolean {
  const expected = crypto
    .createHmac('sha256', COGNIGY_WEBHOOK_SECRET)
    .update(payload)
    .digest('hex');
  
  return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}

The token acquisition request uses Basic Auth encoding for the client credentials, which is standard for OAuth 2.0 client authentication. The response includes an access token valid for a fixed duration. Production systems should implement token caching with refresh logic before expiration to avoid blocking webhook processing. The signature verification function ensures Cognigy originated the request and prevents replay attacks.

Implementation

Step 1: Schema Validation and PCI Compliance Checking

Payment engines enforce strict data boundaries. The schema validates maximum transaction amounts, required currency codes, and PCI compliance flags. Card data must never be stored or logged. The validation pipeline rejects payloads containing raw PAN data or missing 3D Secure indicators.

import { z } from 'zod';

const PaymentIntentSchema = z.object({
  transactionId: z.string().uuid(),
  amount: z.number().positive().max(50000, 'Maximum transaction limit is 50000'),
  currency: z.enum(['USD', 'EUR', 'GBP', 'CAD']),
  authorizationDirective: z.enum(['AUTH_ONLY', 'AUTH_CAPTURE', 'VOID']),
  paymentMethod: z.object({
    token: z.string().min(16).max(19),
    type: z.enum(['CREDIT_CARD', 'DEBIT_CARD']),
    isTokenized: z.boolean().refine(val => val === true, {
      message: 'PCI compliance requires tokenized payment methods only'
    }),
  }),
  threeDSecure: z.object({
    required: z.boolean(),
    challengeUrl: z.string().url().optional(),
    cavv: z.string().optional(),
    eci: z.string().optional(),
  }),
  fraudScoreThreshold: z.number().min(0).max(100),
  merchantReference: z.string().min(1),
});

type PaymentIntent = z.infer<typeof PaymentIntentSchema>;

The schema enforces a hard ceiling of 50000 units per transaction. The isTokenized refinement guarantees that raw card numbers never enter the processing pipeline, satisfying PCI DSS requirement 3. The 3D Secure object expects a challenge URL when risk scoring triggers a step-up verification. This structure prevents processing failures before the gateway call executes.

Step 2: Payload Construction and Gateway Endpoint Matrices

Gateway routing depends on authorization directives and payment method types. The endpoint matrix maps directives to specific gateway paths. The payload constructor assembles the transaction ID reference, routing parameters, and compliance flags into a single atomic request body.

interface GatewayEndpointMatrix {
  AUTH_ONLY: string;
  AUTH_CAPTURE: string;
  VOID: string;
}

const GATEWAY_BASE_URL = 'https://api.paymentgateway.com/v1';

const ENDPOINT_MATRIX: GatewayEndpointMatrix = {
  AUTH_ONLY: `${GATEWAY_BASE_URL}/authorizations`,
  AUTH_CAPTURE: `${GATEWAY_BASE_URL}/charges`,
  VOID: `${GATEWAY_BASE_URL}/authorizations/void`,
};

function constructGatewayPayload(intent: PaymentIntent, authToken: string): { url: string; headers: Record<string, string>; body: string } {
  const endpoint = ENDPOINT_MATRIX[intent.authorizationDirective];
  
  const payload = {
    transactionId: intent.transactionId,
    merchantReference: intent.merchantReference,
    amount: intent.amount,
    currency: intent.currency,
    paymentMethodToken: intent.paymentMethod.token,
    threeDSecure: intent.threeDSecure.required ? {
      cavv: intent.threeDSecure.cavv,
      eci: intent.threeDSecure.eci,
      challengeUrl: intent.threeDSecure.challengeUrl,
    } : undefined,
    metadata: {
      source: 'cognigy_webhook',
      fraudThreshold: intent.fraudScoreThreshold,
      pciCompliant: true,
    },
  };

  return {
    url: endpoint,
    headers: {
      'Authorization': `Bearer ${authToken}`,
      'Content-Type': 'application/json',
      'Idempotency-Key': intent.transactionId,
      'X-Gateway-Request-Id': crypto.randomUUID(),
    },
    body: JSON.stringify(payload),
  };
}

The endpoint matrix separates authorization-only checks from full captures. The Idempotency-Key header ensures that network retries do not duplicate charges. The payload constructor strips unnecessary dialog context and transmits only payment-engine required fields. This reduces payload size and minimizes parsing latency on the gateway side.

Step 3: Atomic POST Operations and Fraud Scoring Triggers

The gateway call executes as a single atomic POST operation. The request includes automatic fraud scoring triggers via metadata headers. The implementation handles 429 rate limits with exponential backoff and validates the response format against expected authorization schemas.

interface GatewayResponse {
  status: 'AUTHORIZED' | 'DECLINED' | 'PENDING_3DS' | 'ERROR';
  transactionId: string;
  amount: number;
  currency: string;
  fraudScore: number;
  threeDSecure?: {
    status: 'APPROVED' | 'CHALLENGE_REQUIRED' | 'FAILED';
    acsUrl?: string;
  };
  message?: string;
}

async function executeGatewayCall(url: string, headers: Record<string, string>, body: string, maxRetries: number = 3): Promise<GatewayResponse> {
  let attempt = 0;
  
  while (attempt < maxRetries) {
    const startTime = Date.now();
    const response = await fetch(url, {
      method: 'POST',
      headers,
      body,
    });
    
    const latency = Date.now() - startTime;
    console.log(`[METRIC] Gateway call latency: ${latency}ms`);

    if (response.status === 429) {
      const retryAfter = parseInt(response.headers.get('Retry-After') || '2', 10);
      console.warn(`[RATE_LIMIT] Received 429. Waiting ${retryAfter}s before retry ${attempt + 1}`);
      await new Promise(resolve => setTimeout(resolve, retryAfter * 1000));
      attempt++;
      continue;
    }

    if (!response.ok) {
      throw new Error(`Gateway call failed: ${response.status} ${response.statusText}`);
    }

    const data: GatewayResponse = await response.json();
    return data;
  }
  
  throw new Error('Maximum retry attempts exceeded for gateway call');
}

The retry loop catches 429 responses and respects the Retry-After header. This prevents cascading rate-limit blocks during Cognigy webhook scaling events. The latency metric records processing time for efficiency tracking. The response schema validation occurs implicitly through TypeScript typing, but production systems should validate the JSON structure against a Zod schema before proceeding.

Step 4: Metrics Tracking, Audit Logging, and Cognigy Synchronization

Processing events require synchronization with external payment processors and Cognigy dialog state. The audit logger records transaction outcomes, fraud scores, and 3D Secure pipeline results. The metrics tracker aggregates authorization success rates and latency percentiles.

import pino from 'pino';
import { v4 as uuidv4 } from 'uuid';

const logger = pino({
  level: 'info',
  transport: {
    target: 'pino-pretty',
    options: { colorize: true },
  },
});

interface AuditLog {
  auditId: string;
  transactionId: string;
  timestamp: string;
  status: string;
  fraudScore: number;
  threeDSecureStatus?: string;
  latencyMs: number;
  gatewayResponse: GatewayResponse;
}

function generateAuditLog(intent: PaymentIntent, gatewayResult: GatewayResponse, latencyMs: number): AuditLog {
  return {
    auditId: uuidv4(),
    transactionId: intent.transactionId,
    timestamp: new Date().toISOString(),
    status: gatewayResult.status,
    fraudScore: gatewayResult.fraudScore,
    threeDSecureStatus: gatewayResult.threeDSecure?.status,
    latencyMs,
    gatewayResponse: gatewayResult,
  };
}

async function synchronizeWithCognigy(dialogSessionId: string, result: GatewayResponse, cognigyApiKey: string): Promise<void> {
  const cognigyUrl = `https://${process.env.COGNIGY_TENANT}.cognigy.ai/api/v1/dialog/${dialogSessionId}/update`;
  
  const updatePayload = {
    context: {
      paymentStatus: result.status,
      transactionId: result.transactionId,
      fraudScore: result.fraudScore,
      requires3DS: result.threeDSecure?.status === 'CHALLENGE_REQUIRED',
      acsUrl: result.threeDSecure?.acsUrl,
    },
  };

  const response = await fetch(cognigyUrl, {
    method: 'PUT',
    headers: {
      'X-Cognigy-API-Key': cognigyApiKey,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(updatePayload),
  });

  if (!response.ok) {
    throw new Error(`Cognigy synchronization failed: ${response.status}`);
  }
}

The audit log generator creates immutable records for dialog governance. Each log entry contains the transaction ID, fraud score, 3D Secure status, and processing latency. The Cognigy synchronization function updates the dialog context with payment results, enabling the conversational flow to branch based on authorization outcomes. The PUT request to the dialog update endpoint ensures state alignment between the payment processor and the conversation engine.

Complete Working Example

The following Fastify application exposes the payment processor endpoint, integrates all validation, routing, and synchronization logic, and handles webhook delivery from Cognigy.

import Fastify from 'fastify';
import { z } from 'zod';
import crypto from 'crypto';
import pino from 'pino';
import { v4 as uuidv4 } from 'uuid';
import { fetch } from 'undici';

const fastify = Fastify({
  logger: false,
});

const logger = pino({ level: 'info' });
const COGNIGY_WEBHOOK_SECRET = process.env.COGNIGY_WEBHOOK_SECRET || '';
const GATEWAY_TOKEN_URL = 'https://auth.paymentgateway.com/oauth2/token';
const GATEWAY_BASE_URL = 'https://api.paymentgateway.com/v1';

const PaymentIntentSchema = z.object({
  transactionId: z.string().uuid(),
  amount: z.number().positive().max(50000),
  currency: z.enum(['USD', 'EUR', 'GBP', 'CAD']),
  authorizationDirective: z.enum(['AUTH_ONLY', 'AUTH_CAPTURE', 'VOID']),
  paymentMethod: z.object({
    token: z.string().min(16).max(19),
    type: z.enum(['CREDIT_CARD', 'DEBIT_CARD']),
    isTokenized: z.boolean().refine(val => val === true),
  }),
  threeDSecure: z.object({
    required: z.boolean(),
    challengeUrl: z.string().url().optional(),
    cavv: z.string().optional(),
    eci: z.string().optional(),
  }),
  fraudScoreThreshold: z.number().min(0).max(100),
  merchantReference: z.string().min(1),
});

type PaymentIntent = z.infer<typeof PaymentIntentSchema>;

interface GatewayResponse {
  status: 'AUTHORIZED' | 'DECLINED' | 'PENDING_3DS' | 'ERROR';
  transactionId: string;
  amount: number;
  currency: string;
  fraudScore: number;
  threeDSecure?: { status: string; acsUrl?: string };
  message?: string;
}

async function acquireGatewayToken(): Promise<string> {
  const credentials = Buffer.from(`${process.env.GATEWAY_CLIENT_ID}:${process.env.GATEWAY_CLIENT_SECRET}`).toString('base64');
  const response = await fetch(GATEWAY_TOKEN_URL, {
    method: 'POST',
    headers: { 'Authorization': `Basic ${credentials}`, 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({ grant_type: 'client_credentials', scope: 'payment:authorize fraud:score 3ds:verify' }),
  });
  if (!response.ok) throw new Error(`Token acquisition failed: ${response.status}`);
  const data = await response.json();
  return data.access_token;
}

function verifySignature(payload: string, signature: string): boolean {
  const expected = crypto.createHmac('sha256', COGNIGY_WEBHOOK_SECRET).update(payload).digest('hex');
  return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}

const ENDPOINT_MATRIX: Record<string, string> = {
  AUTH_ONLY: `${GATEWAY_BASE_URL}/authorizations`,
  AUTH_CAPTURE: `${GATEWAY_BASE_URL}/charges`,
  VOID: `${GATEWAY_BASE_URL}/authorizations/void`,
};

async function executeGatewayCall(url: string, headers: Record<string, string>, body: string): Promise<GatewayResponse> {
  let attempt = 0;
  while (attempt < 3) {
    const start = Date.now();
    const response = await fetch(url, { method: 'POST', headers, body });
    logger.info({ latency: Date.now() - start }, 'Gateway call completed');
    
    if (response.status === 429) {
      const retryAfter = parseInt(response.headers.get('Retry-After') || '2', 10);
      await new Promise(resolve => setTimeout(resolve, retryAfter * 1000));
      attempt++;
      continue;
    }
    if (!response.ok) throw new Error(`Gateway failed: ${response.status}`);
    return response.json();
  }
  throw new Error('Max retries exceeded');
}

fastify.post('/cognigy-payment-webhook', async (request, reply) => {
  const signature = request.headers['x-cognigy-signature'] as string;
  const rawBody = request.rawBody as string;
  
  if (!verifySignature(rawBody, signature)) {
    return reply.code(401).send({ error: 'Invalid webhook signature' });
  }

  const parsed = JSON.parse(rawBody) as { dialogSessionId: string; paymentIntent: PaymentIntent };
  
  const validation = PaymentIntentSchema.safeParse(parsed.paymentIntent);
  if (!validation.success) {
    logger.warn({ errors: validation.error.errors }, 'Schema validation failed');
    return reply.code(400).send({ error: 'Invalid payment intent schema', details: validation.error.errors });
  }

  const intent = validation.data;
  const authToken = await acquireGatewayToken();
  
  const endpoint = ENDPOINT_MATRIX[intent.authorizationDirective];
  const payload = JSON.stringify({
    transactionId: intent.transactionId,
    merchantReference: intent.merchantReference,
    amount: intent.amount,
    currency: intent.currency,
    paymentMethodToken: intent.paymentMethod.token,
    threeDSecure: intent.threeDSecure.required ? {
      cavv: intent.threeDSecure.cavv,
      eci: intent.threeDSecure.eci,
      challengeUrl: intent.threeDSecure.challengeUrl,
    } : undefined,
    metadata: { source: 'cognigy_webhook', fraudThreshold: intent.fraudScoreThreshold, pciCompliant: true },
  });

  const headers = {
    'Authorization': `Bearer ${authToken}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': intent.transactionId,
    'X-Gateway-Request-Id': uuidv4(),
  };

  try {
    const gatewayResult = await executeGatewayCall(endpoint, headers, payload);
    
    const auditLog = {
      auditId: uuidv4(),
      transactionId: intent.transactionId,
      timestamp: new Date().toISOString(),
      status: gatewayResult.status,
      fraudScore: gatewayResult.fraudScore,
      threeDSecureStatus: gatewayResult.threeDSecure?.status,
      latencyMs: gatewayResult.latencyMs || 0,
    };
    logger.info(auditLog, 'Payment processing audit log');

    await synchronizeWithCognigy(parsed.dialogSessionId, gatewayResult);
    
    return reply.code(200).send({
      status: 'processed',
      gatewayStatus: gatewayResult.status,
      requires3DS: gatewayResult.threeDSecure?.status === 'CHALLENGE_REQUIRED',
      acsUrl: gatewayResult.threeDSecure?.acsUrl,
    });
  } catch (error) {
    logger.error(error, 'Payment processing failed');
    return reply.code(500).send({ error: 'Internal processing failure' });
  }
});

async function synchronizeWithCognigy(dialogSessionId: string, result: GatewayResponse): Promise<void> {
  const cognigyUrl = `https://${process.env.COGNIGY_TENANT}.cognigy.ai/api/v1/dialog/${dialogSessionId}/update`;
  const response = await fetch(cognigyUrl, {
    method: 'PUT',
    headers: { 'X-Cognigy-API-Key': process.env.COGNIGY_API_KEY || '', 'Content-Type': 'application/json' },
    body: JSON.stringify({ context: { paymentStatus: result.status, transactionId: result.transactionId, fraudScore: result.fraudScore, requires3DS: result.threeDSecure?.status === 'CHALLENGE_REQUIRED', acsUrl: result.threeDSecure?.acsUrl } }),
  });
  if (!response.ok) throw new Error(`Cognigy sync failed: ${response.status}`);
}

fastify.listen({ port: 3000, host: '0.0.0.0' }, (err) => {
  if (err) throw err;
  logger.info('Payment processor listening on port 3000');
});

The application starts by verifying the Cognigy webhook signature. It validates the payment intent against the schema, acquires a fresh gateway token, constructs the atomic POST payload, and executes the gateway call with retry logic. Upon success, it generates an audit log, synchronizes the dialog state, and returns a structured response. The service exposes a single endpoint that Cognigy can invoke during conversation flows.

Common Errors and Debugging

Error: 401 Unauthorized (Webhook Signature Mismatch)

The Cognigy platform signs outgoing webhooks with an HMAC-SHA256 hash using a shared secret. If the secret in your environment differs from the configured webhook secret, verification fails. Ensure the COGNIGY_WEBHOOK_SECRET environment variable matches the exact string configured in the Cognigy webhook settings. Use crypto.timingSafeEqual to prevent timing attacks during comparison.

Error: 400 Bad Request (Schema Validation Failure)

The Zod schema rejects payloads exceeding 50000 units, missing tokenization flags, or invalid currency codes. Check the details array in the error response to identify which field failed validation. Raw card numbers trigger the isTokenized refinement failure. Replace raw PAN data with gateway-provided tokens before sending the webhook.

Error: 429 Too Many Requests (Gateway Rate Limit)

Payment gateways enforce per-second limits on authorization endpoints. The retry loop catches 429 responses and waits for the Retry-After header duration. If cascading failures occur during Cognigy scaling, implement a message queue to buffer webhook payloads and process them at a controlled rate. Monitor the X-RateLimit-Remaining header to proactively throttle requests.

Error: 502 Bad Gateway (3D Secure ACS Timeout)

When 3D Secure verification requires a consumer challenge, the gateway returns a CHALLENGE_REQUIRED status with an ACS URL. The service must pass this URL to Cognigy for the dialog to redirect the user. If the ACS URL is missing or invalid, the gateway returns a 502. Verify that the cavv and eci fields match the payment network specifications. Ensure the merchant account has 3D Secure 2.0 enabled.

Official References