Verifying Genesys Cloud Webhook Signatures in Node.js - Replay Attack Prevention

We’re building a custom event consumer for our agent desktop backend. The goal is to catch conversation:updated events via a webhook endpoint hosted on an internal Node.js service (Express). Everything works fine when we just dump the payload to a log, but now we need to verify the signature to prevent replay attacks.

The docs mention the X-Genesys-Signature header. I’ve been trying to replicate the verification logic using crypto.createHmac('sha256', secret). The issue is that the signature in the header doesn’t match what I’m generating locally.

Here’s the relevant snippet from our route handler:

app.post('/webhook/genesys', (req, res) => {
 const signature = req.headers['x-genesys-signature'];
 const timestamp = req.headers['x-genesys-timestamp'];
 const body = req.body;
 
 // Constructing the string to sign
 // Docs say it's HMAC-SHA256 of (timestamp + body)
 const stringToSign = timestamp + JSON.stringify(body);
 
 const hmac = crypto.createHmac('sha256', process.env.GENESYS_WEBHOOK_SECRET);
 hmac.update(stringToSign);
 const calculatedSignature = hmac.digest('hex');
 
 if (calculatedSignature !== signature) {
 console.log('Signature mismatch');
 console.log('Expected:', signature);
 console.log('Calculated:', calculatedSignature);
 return res.status(403).send('Invalid signature');
 }
 
 res.status(200).send('OK');
});

The calculatedSignature is always different. I’ve tried using req.rawBody but that’s undefined in Express unless you use a raw middleware, which breaks JSON parsing. If I parse the JSON first, stringify it again, the property order might change, right? That would break the hash.

Is there a specific order for the JSON keys? Or am I constructing the stringToSign wrong? The documentation is pretty light on the exact byte-level representation expected for the body part of the signature calculation. We’re on the latest version of the platform, and the webhook is configured to send the signature header.

The docs say “sign the body with the shared secret”, but they don’t explicitly show the Node.js crypto snippet. You’re probably missing the update step or the encoding.

Here is how I do it in C#, but the logic is identical for Node. You need to hash the raw request body, not the parsed JSON object.

// C# Example for reference
var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(sharedSecret));
var signature = Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes(rawBody)));

In Node, it looks like this:

const crypto = require('crypto');

function verifySignature(req, res, next) {
 const signature = req.headers['x-genesys-signature'];
 const sharedSecret = process.env.GENESYS_WEBHOOK_SECRET;
 
 // Crucial: req.body must be the raw string, not an object.
 // If you use bodyParser.json(), you lose the raw buffer.
 // You might need a middleware to capture raw body before parsing.
 const payload = JSON.stringify(req.body); // Re-serialize if already parsed, but order matters!
 
 const hmac = crypto.createHmac('sha256', sharedSecret);
 hmac.update(payload);
 const calculatedSignature = hmac.digest('base64');
 
 if (signature !== calculatedSignature) {
 return res.status(401).send('Signature mismatch');
 }
 next();
}

Wait. The replay attack part. The signature only proves the request came from Genesys with that secret. It doesn’t prove it’s fresh. If someone captures the packet and resends it, the signature will still match.

You need to check the timestamp in the webhook payload itself. The event object usually has a timestamp. Or better, check the X-Genesys-Request-Id header if available, or implement a simple nonce store in Redis.

The docs mention X-Genesys-Signature but they are light on the replay prevention details. I usually store the request_id or a hash of the payload in a short-lived cache (5 mins) and reject duplicates.

Also, watch out for the body encoding. If your Node app parses the body into an object before you hash it, the JSON key order might change, breaking the signature check. Genesys sends it in a specific order. You should hash the raw buffer.

// Middleware to keep raw body
app.use(express.json({
 verify: (req, res, buf) => {
 req.rawBody = buf.toString();
 }
}));

Then hash req.rawBody. Not JSON.stringify(req.body). The latter can reorder keys. I lost two hours on that once.

Cause: The signature verification fails because the payload is being stringified after parsing, which changes the byte order or removes whitespace. You have to hash the raw buffer from the request stream. Also, make sure you are using the correct shared secret from the integration settings, not the client ID.

Solution: Here is the exact Express middleware snippet that works. It grabs the raw body, generates the HMAC, and compares it to the header. If they don’t match, it rejects the request immediately.

const crypto = require('crypto');

app.post('/webhook', (req, res) => {
 const signature = req.headers['x-genesys-signature'];
 const sharedSecret = process.env.GENESYS_SHARED_SECRET;

 // Calculate HMAC-SHA256 of the raw body
 const hmac = crypto.createHmac('sha256', sharedSecret);
 const digest = hmac.update(req.body).digest('hex');

 if (signature !== digest) {
 console.error('Signature mismatch');
 return res.status(401).send('Unauthorized');
 }

 res.status(200).send('OK');
});

Don’t forget to enable body parsing with raw type in Express if you’re not already.