Verifying NICE Cognigy Webhook Signatures via API with Node.js
What You Will Build
- This tutorial builds a Node.js Express middleware that validates incoming NICE Cognigy webhook payloads using HMAC-SHA256 cryptographic signatures.
- It uses the Node.js built-in
cryptomodule and Express request lifecycle to verify payload integrity and enforce timestamp drift tolerance. - The implementation covers shared secret management, SIEM audit logging, performance metric tracking, and secure rejection of malicious requests.
Prerequisites
- Cognigy webhook configuration with a generated shared secret
- Node.js 18.0 or higher
express,pino,dotenv,uuidnpm packages- Basic understanding of HMAC cryptography and HTTP request signing
Authentication Setup
Cognigy inbound webhooks do not utilize OAuth 2.0 token exchange. They rely on a symmetric shared secret generated within the Cognigy Webflow configuration panel. The backend must store this secret in a secure environment variable. If your backend service calls Cognigy APIs in response to webhook events, you must configure an OAuth client with the webhook:read and bot:read scopes. This tutorial focuses exclusively on inbound signature verification, which requires no OAuth scope.
Create a .env file to store the cryptographic material:
COGNIGY_WEBHOOK_SECRET=your-generated-shared-secret-here
COGNIGY_TIMESTAMP_DRIFT_MS=30000
SIEM_ENDPOINT=https://siem.yourorg.com/api/v1/events
Load the configuration and initialize the cryptographic context:
import dotenv from 'dotenv';
dotenv.config();
if (!process.env.COGNIGY_WEBHOOK_SECRET) {
throw new Error('COGNIGY_WEBHOOK_SECRET environment variable is required');
}
const COGNIGY_SECRET = process.env.COGNIGY_WEBHOOK_SECRET;
const DRIFT_TOLERANCE_MS = parseInt(process.env.COGNIGY_TIMESTAMP_DRIFT_MS, 10) || 30000;
Implementation
Step 1: Initialize Express and Raw Body Capture
Cognigy signs the exact raw request body. Express express.json() parses and mutates the body, which breaks cryptographic verification. You must capture the raw buffer before parsing.
import express from 'express';
const app = express();
// Capture raw body for HMAC verification while still allowing JSON parsing
app.use(express.json({
verify: (req, res, buf) => {
req.rawBody = buf;
},
limit: '1mb'
}));
Expected Request Cycle
POST /webhooks/cognigy HTTP/1.1
Host: api.yourorg.com
Content-Type: application/json
x-cognigy-signature: sha256=a1b2c3d4e5f6...
x-cognigy-timestamp: 1698765432100
{"botId":"bot_123","sessionId":"sess_456","message":"Hello","timestamp":1698765432100}
Error Handling
If the payload exceeds the size limit, Express throws a PayloadTooLargeError. You must catch it globally:
app.use((err, req, res, next) => {
if (err.type === 'entity.too.large') {
return res.status(413).json({ error: 'Payload exceeds maximum size limit' });
}
next(err);
});
Step 2: Construct HMAC Signature Verification Logic
Cognigy appends the sha256= prefix to the hexadecimal digest. You must compute the expected signature using the shared secret and the raw request body, then perform a constant-time comparison to prevent timing attacks.
import crypto from 'crypto';
function computeExpectedSignature(secret, rawBody) {
const hmac = crypto.createHmac('sha256', secret);
hmac.update(rawBody);
return `sha256=${hmac.digest('hex')}`;
}
function verifySignature(receivedSignature, expectedSignature) {
// crypto.timingSafeEqual requires equal length buffers
const a = Buffer.from(receivedSignature);
const b = Buffer.from(expectedSignature);
if (a.length !== b.length) {
return false;
}
return crypto.timingSafeEqual(a, b);
}
Step 3: Implement Timestamp Validation with Drift Tolerance
Replay attacks occur when an adversary intercepts a valid webhook and resends it later. Cognigy includes a millisecond Unix timestamp in the x-cognigy-timestamp header. You must reject requests where the absolute difference between the server clock and the header exceeds your configured drift tolerance.
function validateTimestamp(timestampHeader, driftMs) {
const requestTime = parseInt(timestampHeader, 10);
if (isNaN(requestTime)) {
return { valid: false, reason: 'Invalid timestamp format' };
}
const serverTime = Date.now();
const drift = Math.abs(serverTime - requestTime);
if (drift > driftMs) {
return { valid: false, reason: `Timestamp drift ${drift}ms exceeds tolerance ${driftMs}ms` };
}
return { valid: true, drift };
}
Step 4: Build SIEM Integration Hooks and Audit Logging
Security information and event management systems require structured JSON logs. You will use pino to generate machine-readable audit trails. The hook forwards verification results to an external SIEM endpoint with exponential backoff retry logic for 429 rate limit responses.
import pino from 'pino';
import { v4 as uuidv4 } from 'uuid';
import http from 'http';
const logger = pino({
transport: { target: 'pino-pretty' },
level: 'info'
});
async function forwardToSiem(payload, endpointUrl) {
const maxRetries = 3;
let attempt = 0;
while (attempt < maxRetries) {
try {
const options = {
hostname: new URL(endpointUrl).hostname,
path: new URL(endpointUrl).pathname,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload)
}
};
const req = http.request(options, (res) => {
if (res.statusCode === 200 || res.statusCode === 201) {
return;
}
if (res.statusCode === 429) {
const retryAfter = res.headers['retry-after'] ? parseInt(res.headers['retry-after'], 10) : Math.pow(2, attempt);
throw new Error(`Rate limited. Retry after ${retryAfter}s`);
}
throw new Error(`SIEM returned status ${res.statusCode}`);
});
req.on('error', (err) => {
throw err;
});
req.write(payload);
req.end();
return;
} catch (err) {
attempt++;
if (attempt >= maxRetries) {
logger.error({ err: err.message }, 'SIEM forwarding failed after retries');
return;
}
const delay = Math.pow(2, attempt) * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
Step 5: Track Verification Metrics and Assemble Middleware
You must track success rates and latency for security performance monitoring. The middleware ties signature verification, timestamp validation, SIEM forwarding, and metric collection into a single Express middleware function.
const metrics = {
totalRequests: 0,
successfulVerifications: 0,
failedVerifications: 0,
totalLatencyMs: 0
};
export function cognigyWebhookVerifier(req, res, next) {
const startTime = Date.now();
const requestId = uuidv4();
metrics.totalRequests++;
const receivedSignature = req.headers['x-cognigy-signature'];
const timestampHeader = req.headers['x-cognigy-timestamp'];
if (!receivedSignature || !timestampHeader) {
return res.status(400).json({ error: 'Missing required signature or timestamp headers' });
}
const expectedSignature = computeExpectedSignature(COGNIGY_SECRET, req.rawBody);
const signatureValid = verifySignature(receivedSignature, expectedSignature);
const timestampCheck = validateTimestamp(timestampHeader, DRIFT_TOLERANCE_MS);
const verificationResult = signatureValid && timestampCheck.valid;
const latency = Date.now() - startTime;
if (!verificationResult) {
metrics.failedVerifications++;
const reason = signatureValid ? timestampCheck.reason : 'Signature mismatch';
logger.warn({
requestId,
ip: req.ip,
reason,
latencyMs: latency,
signatureProvided: receivedSignature ? receivedSignature.slice(0, 15) + '...' : null
}, 'Webhook verification failed');
if (process.env.SIEM_ENDPOINT) {
forwardToSiem(JSON.stringify({
event: 'webhook_verification_failed',
requestId,
timestamp: new Date().toISOString(),
ip: req.ip,
reason,
latencyMs: latency
}), process.env.SIEM_ENDPOINT);
}
return res.status(401).json({ error: 'Unauthorized', reason });
}
metrics.successfulVerifications++;
metrics.totalLatencyMs += latency;
logger.info({
requestId,
ip: req.ip,
latencyMs: latency,
driftMs: timestampCheck.drift
}, 'Webhook verification successful');
if (process.env.SIEM_ENDPOINT) {
forwardToSiem(JSON.stringify({
event: 'webhook_verification_success',
requestId,
timestamp: new Date().toISOString(),
ip: req.ip,
latencyMs: latency,
driftMs: timestampCheck.drift
}), process.env.SIEM_ENDPOINT);
}
req.cognigyVerification = { requestId, latency, drift: timestampCheck.drift };
next();
}
Complete Working Example
The following script combines all components into a production-ready server. It exposes the verifier middleware, a secure webhook route, and a metrics endpoint.
// server.js
import express from 'express';
import crypto from 'crypto';
import pino from 'pino';
import dotenv from 'dotenv';
import { v4 as uuidv4 } from 'uuid';
import http from 'http';
dotenv.config();
if (!process.env.COGNIGY_WEBHOOK_SECRET) {
throw new Error('COGNIGY_WEBHOOK_SECRET environment variable is required');
}
const COGNIGY_SECRET = process.env.COGNIGY_WEBHOOK_SECRET;
const DRIFT_TOLERANCE_MS = parseInt(process.env.COGNIGY_TIMESTAMP_DRIFT_MS, 10) || 30000;
const logger = pino({ level: 'info' });
const metrics = {
totalRequests: 0,
successfulVerifications: 0,
failedVerifications: 0,
totalLatencyMs: 0
};
function computeExpectedSignature(secret, rawBody) {
const hmac = crypto.createHmac('sha256', secret);
hmac.update(rawBody);
return `sha256=${hmac.digest('hex')}`;
}
function verifySignature(receivedSignature, expectedSignature) {
const a = Buffer.from(receivedSignature);
const b = Buffer.from(expectedSignature);
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
function validateTimestamp(timestampHeader, driftMs) {
const requestTime = parseInt(timestampHeader, 10);
if (isNaN(requestTime)) return { valid: false, reason: 'Invalid timestamp format' };
const serverTime = Date.now();
const drift = Math.abs(serverTime - requestTime);
if (drift > driftMs) return { valid: false, reason: `Timestamp drift ${drift}ms exceeds tolerance ${driftMs}ms` };
return { valid: true, drift };
}
async function forwardToSiem(payload, endpointUrl) {
const maxRetries = 3;
let attempt = 0;
while (attempt < maxRetries) {
try {
const options = {
hostname: new URL(endpointUrl).hostname,
path: new URL(endpointUrl).pathname,
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) }
};
const req = http.request(options, (res) => {
if (res.statusCode >= 200 && res.statusCode < 300) return;
if (res.statusCode === 429) {
const retryAfter = res.headers['retry-after'] ? parseInt(res.headers['retry-after'], 10) : Math.pow(2, attempt);
throw new Error(`Rate limited. Retry after ${retryAfter}s`);
}
throw new Error(`SIEM returned status ${res.statusCode}`);
});
req.on('error', (err) => { throw err; });
req.write(payload);
req.end();
return;
} catch (err) {
attempt++;
if (attempt >= maxRetries) {
logger.error({ err: err.message }, 'SIEM forwarding failed after retries');
return;
}
await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 1000));
}
}
}
const app = express();
app.use(express.json({ verify: (req, res, buf) => { req.rawBody = buf; }, limit: '1mb' }));
function cognigyWebhookVerifier(req, res, next) {
const startTime = Date.now();
const requestId = uuidv4();
metrics.totalRequests++;
const receivedSignature = req.headers['x-cognigy-signature'];
const timestampHeader = req.headers['x-cognigy-timestamp'];
if (!receivedSignature || !timestampHeader) {
return res.status(400).json({ error: 'Missing required signature or timestamp headers' });
}
const expectedSignature = computeExpectedSignature(COGNIGY_SECRET, req.rawBody);
const signatureValid = verifySignature(receivedSignature, expectedSignature);
const timestampCheck = validateTimestamp(timestampHeader, DRIFT_TOLERANCE_MS);
const verificationResult = signatureValid && timestampCheck.valid;
const latency = Date.now() - startTime;
if (!verificationResult) {
metrics.failedVerifications++;
const reason = signatureValid ? timestampCheck.reason : 'Signature mismatch';
logger.warn({ requestId, ip: req.ip, reason, latencyMs: latency }, 'Webhook verification failed');
if (process.env.SIEM_ENDPOINT) {
forwardToSiem(JSON.stringify({ event: 'webhook_verification_failed', requestId, timestamp: new Date().toISOString(), ip: req.ip, reason, latencyMs: latency }), process.env.SIEM_ENDPOINT);
}
return res.status(401).json({ error: 'Unauthorized', reason });
}
metrics.successfulVerifications++;
metrics.totalLatencyMs += latency;
logger.info({ requestId, ip: req.ip, latencyMs: latency, driftMs: timestampCheck.drift }, 'Webhook verification successful');
if (process.env.SIEM_ENDPOINT) {
forwardToSiem(JSON.stringify({ event: 'webhook_verification_success', requestId, timestamp: new Date().toISOString(), ip: req.ip, latencyMs: latency, driftMs: timestampCheck.drift }), process.env.SIEM_ENDPOINT);
}
req.cognigyVerification = { requestId, latency, drift: timestampCheck.drift };
next();
}
app.post('/webhooks/cognigy', cognigyWebhookVerifier, (req, res) => {
const { message, sessionId } = req.body;
logger.info({ sessionId, message }, 'Processing verified Cognigy webhook');
res.status(200).json({ status: 'processed', verificationId: req.cognigyVerification.requestId });
});
app.get('/metrics', (req, res) => {
const avgLatency = metrics.totalRequests > 0 ? metrics.totalLatencyMs / metrics.totalRequests : 0;
res.json({
totalRequests: metrics.totalRequests,
successfulVerifications: metrics.successfulVerifications,
failedVerifications: metrics.failedVerifications,
averageLatencyMs: Math.round(avgLatency * 100) / 100,
successRate: metrics.totalRequests > 0 ? (metrics.successfulVerifications / metrics.totalRequests * 100).toFixed(2) : 0
});
});
app.use((err, req, res, next) => {
if (err.type === 'entity.too.large') {
return res.status(413).json({ error: 'Payload exceeds maximum size limit' });
}
logger.error({ err: err.message }, 'Unhandled server error');
res.status(500).json({ error: 'Internal server error' });
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
logger.info({ port: PORT }, 'Cognigy webhook verifier server started');
});
Common Errors & Debugging
Error: 401 Unauthorized with reason “Signature mismatch”
- What causes it: The shared secret in your environment does not match the secret configured in Cognigy, or the raw body was modified before verification.
- How to fix it: Verify that
COGNIGY_WEBHOOK_SECRETmatches exactly. Ensureexpress.json()is configured with theverifyhook to capturereq.rawBodybefore parsing. - Code showing the fix:
app.use(express.json({
verify: (req, res, buf) => { req.rawBody = buf; }
}));
Error: 401 Unauthorized with reason “Timestamp drift exceeds tolerance”
- What causes it: Server clock desynchronization or network latency pushes the request outside the configured window.
- How to fix it: Adjust
COGNIGY_TIMESTAMP_DRIFT_MSin your environment. For high-latency global deployments, increase tolerance to 60000ms. Ensure your server uses NTP synchronization. - Code showing the fix:
COGNIGY_TIMESTAMP_DRIFT_MS=60000
Error: SIEM forwarding fails with 429 Rate Limit
- What causes it: The external SIEM endpoint enforces request rate limits during traffic spikes.
- How to fix it: The implementation includes exponential backoff retry logic. If failures persist, implement request batching or increase SIEM throughput limits.
- Code showing the fix:
if (res.statusCode === 429) {
const retryAfter = res.headers['retry-after'] ? parseInt(res.headers['retry-after'], 10) : Math.pow(2, attempt);
throw new Error(`Rate limited. Retry after ${retryAfter}s`);
}