Verifying Genesys Cloud webhook signature in Kotlin

Trying to validate the X-GC-Signature header on inbound webhooks to stop replay attacks. The docs mention HMAC-SHA256, but I’m struggling with the base string. Here’s the Kotlin snippet I’m using:

fun verifySignature(payload: String, signature: String, secret: String): Boolean {
 val mac = Mac.getInstance("HmacSHA256")
 mac.init(SecretKeySpec(secret.toByteArray(), "HmacSHA256"))
 val bytes = mac.doFinal(payload.toByteArray())
 return HexFormat.of().formatHex(bytes) == signature
}

It always returns false. Am I hashing the raw JSON or the URL-decoded body?

fun verifySignature(payload: String, signature: String, secret: String): Boolean {
val mac = Mac.getInstance(“HmacSHA256”)
mac.init(SecretKeySpec(secret.toByteArray(Charsets.UTF_8), “HmacSHA256”))

// Critical: Use the raw bytes of the payload, not the string representation
val payloadBytes = payload.toByteArray(Charsets.UTF_8)
val bytes = mac.doFinal(payloadBytes)

// Convert to hex string for comparison
val expectedSignature = bytes.joinToString(“”) { “%02x”.format(it) }

// Use constant-time comparison to prevent timing attacks
return MessageDigest.isEqual(expectedSignature.toByteArray(), signature.toByteArray())
}


The main issue here is how you are handling the payload bytes. Genesys Cloud signs the raw body of the HTTP request. If your framework decodes the body into a String object first, you might be dealing with encoding shifts or whitespace normalization that changes the byte sequence. You need to grab the raw input stream bytes before any decoding happens.

Also, look at the `toByteArray()` call on the secret. You must specify `Charsets.UTF_8`. Without it, Kotlin defaults to the platform charset, which breaks on Linux servers if the locale isn't set correctly. That's a silent failure waiting to happen.

Another gotcha is the comparison. Never use `==` or `.equals()` for cryptographic signatures. It's vulnerable to timing attacks. Use `MessageDigest.isEqual()` or a library like Bouncy Castle's `MacUtil.constantTimeAreEqual`. It takes an extra millisecond but stops attackers from guessing the signature byte-by-byte based on response time.

Make sure the `secret` you are using matches the one generated in the Admin UI under Integrations > Webhooks > Security. Copy-paste errors with trailing newlines are common. Trim the secret before using it.

If you are using Spring Boot, consider creating a custom `Filter` that intercepts the request, reads the raw body once, verifies the signature, and then wraps the stream so downstream controllers can still read it. Otherwise, you'll hit the "stream already closed" error.

Been there. The payload string often loses raw byte fidelity during logging or transport. Don’t trust the String object. Grab the raw request body bytes directly from the HTTP handler before any JSON parsing happens. That’s the only way to match the signature Genesys Cloud calculated. Use request.bodyAsBytes() in your controller.