NICE CXone Webhook Signature Verification Failing with HMAC SHA256

We’ve set up an endpoint to consume real-time conversation events from CXone. The documentation mentions a signature header for verification, but the logic isn’t sticking. I’m trying to prevent replay attacks by validating the X-Nice-Webhook-Signature against the raw request body.

Here’s the Node.js handling the verification:

const crypto = require('crypto');

app.post('/webhook', (req, res) => {
 const signature = req.headers['x-nice-webhook-signature'];
 const timestamp = req.headers['x-nice-webhook-timestamp'];
 const body = JSON.stringify(req.body); // Raw body as string

 // Constructing the payload string as per docs: timestamp + body
 const payload = `${timestamp}.${body}`;
 
 const hmac = crypto.createHmac('sha256', process.env.WEBHOOK_SECRET);
 hmac.update(payload);
 const calculatedSignature = hmac.digest('hex');

 console.log('Expected:', signature);
 console.log('Calculated:', calculatedSignature);

 if (signature !== calculatedSignature) {
 console.log('Signature mismatch');
 return res.status(401).send('Invalid signature');
 }
 
 res.status(200).send('OK');
});

The signatures never match. Even when I log the raw body string, it looks identical to what I’m passing into the HMAC function. I’ve confirmed the secret key is correct by testing it in a local Python script where it works fine. The issue seems to be in how the payload string is constructed or encoded in the Node environment.

I’ve tried using req.body directly, JSON.stringify(req.body), and even reading req.rawBody if available. Nothing aligns with the header value. The timestamp header is present and looks valid (Unix epoch). Is there a specific encoding required for the body before hashing? Or maybe the order of concatenation is different than timestamp.body?

I’m stuck on this. The webhook returns 200 if I skip the check, but that’s not secure. Any ideas on what’s causing the hash mismatch?

const crypto = require('crypto');

app.post('/webhook', (req, res) => {
 const signature = req.headers['x-nice-webhook-signature'];
 const body = req.rawBody; // Ensure this is the raw buffer, not parsed JSON
 
 const expected = crypto
 .createHmac('sha256', process.env.WEBHOOK_SECRET)
 .update(body)
 .digest('hex');

 if (signature !== expected) {
 console.error('Signature mismatch');
 return res.status(403).send('Unauthorized');
 }
 
 res.status(200).send('OK');
});

The issue usually stems from how the request body is handled before hashing. If you’re using express.json() or similar middleware, the body gets parsed into an object. You can’t hash an object directly against the signature generated from the raw stream. The signature is calculated on the exact bytes received.

You need to capture the raw buffer first. In Express, you can do this by skipping the JSON parser for this specific route or by manually reading the stream.

// Middleware to capture raw body
app.use((req, res, next) => {
 let data = '';
 req.setEncoding('utf8');
 req.on('data', chunk => { data += chunk; });
 req.on('end', () => {
 req.rawBody = data;
 next();
 });
});

Make sure the secret you’re using matches exactly what’s configured in the CXone webhook settings. Even a trailing space causes a mismatch. I’ve seen this break dashboards in New Relic because the events never get validated and are dropped before ingestion.

Also, check the timestamp header if CXone sends one. Some setups require checking that the request isn’t older than a few minutes to prevent replay attacks. The signature alone doesn’t stop someone from resending an old valid payload.

If you’re still seeing failures, log both the signature and expected values. They should be identical hex strings. If they differ, the input to the hash function is wrong. Usually it’s the body encoding or missing raw data.