Verifying NICE Cognigy.AI Webhook Signatures via REST API with Java
What You Will Build
- A production-ready Java service that validates incoming Cognigy.AI webhook signatures using HMAC-SHA256, enforces timestamp skew limits, and rejects unauthorized payloads before they impact your bot infrastructure.
- A reusable signature verification pipeline that tracks latency, logs audit trails, and triggers external security monitoring callbacks.
- The implementation uses standard Java 17+ libraries with no external framework dependencies.
Prerequisites
- OAuth client type and required scopes: Inbound webhook verification uses shared secret authentication. OAuth is not required for signature validation. If you manage secrets via the Cognigy.AI Management API, use a service account with
webhook:writescope on/api/v2/bots/{botId}/webhooks. - SDK version or API version: Cognigy.AI Webhook API v2. Java 17 or higher.
- Language/runtime requirements: JDK 17+, standard
java.net.http,java.time,javax.cryptopackages. - External dependencies: None. The example uses built-in Java modules only.
Authentication Setup
Webhook signature verification relies on a shared secret key configured in the Cognigy.AI Studio webhook settings. The client (Cognigy.AI) signs each payload using HMAC-SHA256 with the secret. Your server must store the secret securely and validate the signature on every request. Token caching does not apply here. Instead, you will implement a secret key matrix to support key rotation without downtime.
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
public class SecretKeyMatrix {
private final CopyOnWriteArrayList<String> activeSecrets = new CopyOnWriteArrayList<>();
public SecretKeyMatrix(String primarySecret) {
activeSecrets.add(primarySecret);
}
public void rotateSecret(String oldSecret, String newSecret) {
activeSecrets.remove(oldSecret);
activeSecrets.add(newSecret);
}
public List<String> getActiveSecrets() {
return List.copyOf(activeSecrets);
}
}
Implementation
Step 1: Configure Timestamp Validation Directives and Clock Skew Limits
Replay attacks succeed when your server accepts stale requests. Cognigy.AI includes an X-Cognigy-Timestamp header in Unix epoch milliseconds. You must validate this timestamp against your system clock and enforce a maximum clock skew limit. The example below defines a validation directive that rejects requests older than 300 seconds.
import java.time.Instant;
public class TimestampValidationDirective {
private final long maxClockSkewSeconds;
public TimestampValidationDirective(long maxClockSkewSeconds) {
this.maxClockSkewSeconds = maxClockSkewSeconds;
}
public boolean validate(long requestTimestampMillis) {
Instant requestInstant = Instant.ofEpochMilli(requestTimestampMillis);
Instant serverInstant = Instant.now();
long skewSeconds = Math.abs(serverInstant.getEpochSecond() - requestInstant.getEpochSecond());
return skewSeconds <= maxClockSkewSeconds;
}
}
Step 2: Implement HMAC Signature Verification Pipeline
The verification pipeline performs an atomic format check and cryptographic validation. Cognigy.AI sends the signature in the X-Cognigy-Signature header. The payload body is concatenated with the raw timestamp string before hashing. You must use MessageDigest.isEqual() for constant-time comparison to prevent timing attacks.
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
public class HmacSignatureVerifier {
private static final String HMAC_SHA256 = "HmacSHA256";
public boolean verify(String secret, String timestamp, String payload, String providedSignature) {
try {
String message = timestamp + payload;
Mac mac = Mac.getInstance(HMAC_SHA256);
SecretKeySpec keySpec = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HMAC_SHA256);
mac.init(keySpec);
byte[] hash = mac.doFinal(message.getBytes(StandardCharsets.UTF_8));
String computedSignature = bytesToHex(hash);
return MessageDigest.isEqual(computedSignature.getBytes(StandardCharsets.UTF_8),
providedSignature.getBytes(StandardCharsets.UTF_8));
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
throw new RuntimeException("HMAC verification failed: " + e.getMessage(), e);
}
}
private String bytesToHex(byte[] bytes) {
StringBuilder hexString = new StringBuilder(2 * bytes.length);
for (byte b : bytes) {
String hex = Integer.toHexString(0xff & b);
if (hex.length() == 1) hexString.append('0');
hexString.append(hex);
}
return hexString.toString();
}
}
Step 3: Build the REST Endpoint with Automatic Rejection Triggers
The endpoint receives POST requests from Cognigy.AI. It executes an atomic verification sequence: parse headers, validate timestamp skew, check signature against the secret matrix, and return HTTP 200 on success. Any failure triggers an automatic rejection with the appropriate HTTP status code. You will also track verification latency and rejection rates.
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import java.io.*;
import java.net.InetSocketAddress;
import java.net.http.HttpServer;
import java.util.*;
import java.util.concurrent.atomic.AtomicLong;
public class WebhookVerificationHandler implements HttpHandler {
private final SecretKeyMatrix secretMatrix;
private final TimestampValidationDirective timestampDirective;
private final HmacSignatureVerifier hmacVerifier;
private final AtomicLong totalRequests = new AtomicLong(0);
private final AtomicLong rejectedRequests = new AtomicLong(0);
private final AtomicLong totalLatencyMs = new AtomicLong(0);
private final List<SecurityCallback> callbacks = new ArrayList<>();
public WebhookVerificationHandler(SecretKeyMatrix secretMatrix,
TimestampValidationDirective timestampDirective,
HmacSignatureVerifier hmacVerifier) {
this.secretMatrix = secretMatrix;
this.timestampDirective = timestampDirective;
this.hmacVerifier = hmacVerifier;
}
public void addSecurityCallback(SecurityCallback callback) {
callbacks.add(callback);
}
@Override
public void handle(HttpExchange exchange) throws IOException {
long startMs = System.currentTimeMillis();
totalRequests.incrementAndGet();
String method = exchange.getRequestMethod();
if (!"POST".equals(method)) {
sendResponse(exchange, 405, "{\"error\":\"Method not allowed\"}");
trackMetrics(startMs, true);
return;
}
String signatureHeader = exchange.getRequestHeaders().getFirst("X-Cognigy-Signature");
String timestampHeader = exchange.getRequestHeaders().getFirst("X-Cognigy-Timestamp");
if (signatureHeader == null || timestampHeader == null || timestampHeader.isEmpty()) {
sendResponse(exchange, 400, "{\"error\":\"Missing required headers\"}");
trackMetrics(startMs, true);
logAudit(exchange, 400, "MISSING_HEADERS");
return;
}
long requestTimestamp;
try {
requestTimestamp = Long.parseLong(timestampHeader);
} catch (NumberFormatException e) {
sendResponse(exchange, 400, "{\"error\":\"Invalid timestamp format\"}");
trackMetrics(startMs, true);
logAudit(exchange, 400, "INVALID_TIMESTAMP_FORMAT");
return;
}
if (!timestampDirective.validate(requestTimestamp)) {
sendResponse(exchange, 403, "{\"error\":\"Timestamp expired or clock skew exceeded\"}");
trackMetrics(startMs, true);
logAudit(exchange, 403, "CLOCK_SKEW_EXCEEDED");
return;
}
String payload = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
boolean signatureValid = secretMatrix.getActiveSecrets().stream()
.anyMatch(secret -> hmacVerifier.verify(secret, timestampHeader, payload, signatureHeader));
if (!signatureValid) {
sendResponse(exchange, 401, "{\"error\":\"Invalid signature\"}");
trackMetrics(startMs, true);
logAudit(exchange, 401, "SIGNATURE_MISMATCH");
return;
}
// Simulate rate limiting check for security gateway constraints
if (isRateLimited()) {
sendResponse(exchange, 429, "{\"error\":\"Rate limit exceeded\"}");
trackMetrics(startMs, true);
logAudit(exchange, 429, "RATE_LIMITED");
return;
}
sendResponse(exchange, 200, "{\"status\":\"verified\"}");
trackMetrics(startMs, false);
logAudit(exchange, 200, "VERIFIED");
triggerCallbacks(true, exchange.getRequestHeaders().toString());
}
private boolean isRateLimited() {
// Placeholder for external rate limit store or sliding window
return false;
}
private void trackMetrics(long startMs, boolean rejected) {
long latency = System.currentTimeMillis() - startMs;
totalLatencyMs.addAndGet(latency);
if (rejected) rejectedRequests.incrementAndGet();
triggerCallbacks(false, String.format("latency=%dms,rejected=%s", latency, rejected));
}
private void logAudit(HttpExchange exchange, int statusCode, String reason) {
Map<String, Object> auditEntry = new LinkedHashMap<>();
auditEntry.put("timestamp", Instant.now().toString());
auditEntry.put("status", statusCode);
auditEntry.put("reason", reason);
auditEntry.put("clientIp", exchange.getRemoteAddress().getHostString());
auditEntry.put("path", exchange.getRequestURI().getPath());
System.out.println("AUDIT_LOG: " + new com.google.gson.Gson().toJson(auditEntry));
}
private void triggerCallbacks(boolean verified, String context) {
for (SecurityCallback cb : callbacks) {
cb.onVerificationEvent(verified, context);
}
}
private void sendResponse(HttpExchange exchange, int statusCode, String body) throws IOException {
byte[] responseBytes = body.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.sendResponseHeaders(statusCode, responseBytes.length);
exchange.getResponseBody().write(responseBytes);
exchange.close();
}
public interface SecurityCallback {
void onVerificationEvent(boolean verified, String context);
}
public double getRejectionRate() {
long total = totalRequests.get();
return total == 0 ? 0.0 : (double) rejectedRequests.get() / total;
}
public long getAverageLatencyMs() {
long total = totalRequests.get();
return total == 0 ? 0 : totalLatencyMs.get() / total;
}
}
Step 4: Expose Atomic GET Verification Endpoint for Automated Management
You must expose a /signature/verify GET endpoint to allow automated systems to test signature formats without triggering full webhook processing. This endpoint performs format verification and returns a structured validation result.
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
import java.net.URI;
import java.nio.charset.StandardCharsets;
public class AtomicVerificationGetHandler implements HttpHandler {
private final HmacSignatureVerifier verifier;
private final SecretKeyMatrix matrix;
private final TimestampValidationDirective directive;
public AtomicVerificationGetHandler(HmacSignatureVerifier verifier,
SecretKeyMatrix matrix,
TimestampValidationDirective directive) {
this.verifier = verifier;
this.matrix = matrix;
this.directive = directive;
}
@Override
public void handle(HttpExchange exchange) throws IOException {
if (!"GET".equals(exchange.getRequestMethod())) {
sendResponse(exchange, 405, "{\"error\":\"GET required\"}");
return;
}
URI uri = exchange.getRequestURI();
String query = uri.getRawQuery();
if (query == null) {
sendResponse(exchange, 400, "{\"error\":\"Missing query parameters\"}");
return;
}
String timestamp = extractParam(query, "timestamp");
String payload = extractParam(query, "payload");
String signature = extractParam(query, "signature");
if (timestamp == null || payload == null || signature == null) {
sendResponse(exchange, 400, "{\"error\":\"Missing timestamp, payload, or signature\"}");
return;
}
boolean formatValid = !timestamp.isEmpty() && !payload.isEmpty() && !signature.isEmpty();
boolean skewValid = directive.validate(Long.parseLong(timestamp));
boolean hmacValid = matrix.getActiveSecrets().stream()
.anyMatch(s -> verifier.verify(s, timestamp, payload, signature));
int status = (formatValid && skewValid && hmacValid) ? 200 : 403;
String response = String.format("{\"format_valid\":%s,\"skew_valid\":%s,\"hmac_valid\":%s}",
formatValid, skewValid, hmacValid);
sendResponse(exchange, status, response);
}
private String extractParam(String query, String key) {
for (String param : query.split("&")) {
if (param.startsWith(key + "=")) {
return param.substring(key.length() + 1).replace("+", " ");
}
}
return null;
}
private void sendResponse(HttpExchange exchange, int statusCode, String body) throws IOException {
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.sendResponseHeaders(statusCode, bytes.length);
exchange.getResponseBody().write(bytes);
exchange.close();
}
}
Complete Working Example
The following script initializes the verification pipeline, binds the HTTP server to port 8080, registers the POST webhook handler and the GET verification endpoint, and starts a background metrics reporter. Run it directly with java WebhookSignatureVerifier.java.
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
public class WebhookSignatureVerifier {
public static void main(String[] args) throws Exception {
String primarySecret = System.getenv("COGNIGY_WEBHOOK_SECRET");
if (primarySecret == null || primarySecret.isEmpty()) {
throw new IllegalStateException("COGNIGY_WEBHOOK_SECRET environment variable is required");
}
SecretKeyMatrix secretMatrix = new SecretKeyMatrix(primarySecret);
TimestampValidationDirective timestampDirective = new TimestampValidationDirective(300);
HmacSignatureVerifier hmacVerifier = new HmacSignatureVerifier();
WebhookVerificationHandler webhookHandler = new WebhookVerificationHandler(
secretMatrix, timestampDirective, hmacVerifier);
AtomicVerificationGetHandler getHandler = new AtomicVerificationGetHandler(
hmacVerifier, secretMatrix, timestampDirective);
webhookHandler.addSecurityCallback((verified, context) ->
System.out.println("SECURITY_CALLBACK: verified=" + verified + " | " + context));
HttpServer server = HttpServer.create(new InetSocketAddress(8080), 0);
server.createContext("/webhooks/cognigy", webhookHandler);
server.createContext("/signature/verify", getHandler);
server.setExecutor(Executors.newFixedThreadPool(4));
server.start();
ScheduledExecutorService metricsReporter = Executors.newSingleThreadScheduledExecutor();
metricsReporter.scheduleAtFixedRate(() -> {
System.out.println(String.format("METRICS: rejection_rate=%.2f%%, avg_latency=%dms",
webhookHandler.getRejectionRate() * 100, webhookHandler.getAverageLatencyMs()));
}, 0, 10, TimeUnit.SECONDS);
System.out.println("Webhook signature verifier running on http://localhost:8080");
}
}
Common Errors & Debugging
Error: 401 Unauthorized (Invalid Signature)
- What causes it: The HMAC-SHA256 computation does not match the
X-Cognigy-Signatureheader. This occurs when the secret key is rotated in Cognigy.AI but not updated in your matrix, or when the payload is modified in transit. - How to fix it: Verify the secret key matches the one in Cognigy.AI Studio. Ensure your server reads the raw request body without decompression or character encoding transformations before hashing. Use the
/signature/verifyGET endpoint to test the exact payload and timestamp. - Code showing the fix:
// Ensure raw bytes are used for hashing
byte[] rawPayload = exchange.getRequestBody().readAllBytes();
String payload = new String(rawPayload, StandardCharsets.UTF_8);
Error: 403 Forbidden (Timestamp Expired)
- What causes it: The
X-Cognigy-Timestampheader exceeds the configuredmaxClockSkewSecondslimit. This indicates clock drift between your server and Cognigy.AI, or a delayed network route. - How to fix it: Synchronize your server clock using NTP. If network latency is consistently high, increase the skew limit cautiously (maximum 600 seconds recommended). Never disable timestamp validation.
- Code showing the fix:
// Adjust skew limit during initialization if network conditions require it
TimestampValidationDirective directive = new TimestampValidationDirective(600);
Error: 429 Too Many Requests
- What causes it: Your security gateway or rate limiter detects excessive verification attempts. This triggers automatic rejection to prevent brute-force signature guessing.
- How to fix it: Implement exponential backoff on the Cognigy.AI side or increase your rate limit thresholds. Verify that you are not retrying failed webhooks without respecting retry-after headers.
- Code showing the fix:
// Implement sliding window rate limit check
private boolean isRateLimited() {
// Replace with Redis or in-memory sliding window counter
return false;
}
Error: 500 Internal Server Error (Verification Pipeline Failure)
- What causes it: Cryptographic provider misconfiguration, missing
X-Cognigy-Timestampheader parsing, or unhandled exceptions in the HMAC pipeline. - How to fix it: Wrap cryptographic operations in try-catch blocks that log the exact exception. Ensure
java.securityproviders includeSUNorBCFIPSif using FIPS mode. Validate that the environment variableCOGNIGY_WEBHOOK_SECRETis loaded before server startup. - Code showing the fix:
try {
Mac mac = Mac.getInstance("HmacSHA256");
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("JDK missing HmacSHA256 provider", e);
}