Validating NICE Cognigy Incoming Webhook Signatures in Go
What You Will Build
A production-ready Go HTTP server that ingests, cryptographically verifies, and audits incoming NICE Cognigy webhooks. The application enforces HMAC-SHA256 signature validation, timestamp drift limits, source IP whitelisting, atomic rejection tracking, SIEM callback synchronization, and structured audit logging. The code uses the Go standard library for cryptographic operations, atomic counters, and HTTP handling.
Prerequisites
- NICE Cognigy bot configured with an outgoing webhook endpoint and a shared secret key
- Go 1.21+ runtime
- Standard library packages:
crypto/hmac,crypto/sha256,encoding/hex,net/http,sync/atomic,time,log/slog,net,context - Access to a SIEM endpoint for callback synchronization (a mock endpoint is provided for local testing)
- Network access to Cognigy webhook delivery IPs (documented in Cognigy infrastructure documentation)
Authentication Setup
NICE Cognigy webhooks authenticate via a shared secret rather than OAuth. You must configure the secret in the Cognigy Canvas webhook node. The Go server loads a secret key matrix to support key rotation without service interruption. The following code demonstrates how to initialize the secret store and load credentials from environment variables.
package main
import (
"os"
"strings"
)
// SecretMatrix holds multiple active secrets to support rotation.
// The first secret is treated as the primary validation key.
type SecretMatrix struct {
Primary string
Active []string
}
func LoadSecretMatrix() *SecretMatrix {
raw := os.Getenv("COGNIGY_WEBHOOK_SECRETS")
if raw == "" {
panic("COGNIGY_WEBHOOK_SECRETS environment variable is required")
}
secrets := strings.Split(raw, ",")
// Trim whitespace and filter empty strings
var active []string
for _, s := range secrets {
trimmed := strings.TrimSpace(s)
if trimmed != "" {
active = append(active, trimmed)
}
}
if len(active) == 0 {
panic("COGNIGY_WEBHOOK_SECRETS contains no valid keys")
}
return &SecretMatrix{
Primary: active[0],
Active: active,
}
}
Configure Cognigy to send X-Cognigy-Signature and X-Cognigy-Timestamp headers. The signature is generated by Cognigy using HMAC-SHA256 over the raw request body with your shared secret. The timestamp is a Unix epoch value in seconds. Store the secret in your deployment environment and reference it via COGNIGY_WEBHOOK_SECRETS. Separate multiple secrets with commas to enable zero-downtime rotation.
Implementation
Step 1: HTTP Server Initialization and Request Parsing
The server exposes a single endpoint that reads the raw body, extracts cryptographic headers, and delegates to the validation pipeline. The handler enforces strict content-type verification and returns structured error responses.
package main
import (
"encoding/json"
"log/slog"
"net/http"
"time"
)
type WebhookPayload struct {
BotID string `json:"botId"`
Intent string `json:"intent"`
Entities map[string]interface{} `json:"entities,omitempty"`
Timestamp time.Time `json:"timestamp"`
}
type ErrorResponse struct {
Error string `json:"error"`
Code int `json:"code"`
Message string `json:"message"`
}
func writeError(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(ErrorResponse{
Error: http.StatusText(status),
Code: status,
Message: msg,
})
}
func WebhookHandler(v *WebhookValidator) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
defer func() {
duration := time.Since(start).Microseconds()
v.latencySum.Add(duration)
}()
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "Only POST requests are accepted")
return
}
if r.Header.Get("Content-Type") != "application/json" {
writeError(w, http.StatusUnsupportedMediaType, "Content-Type must be application/json")
return
}
err := v.Validate(r)
if err != nil {
v.rejectionCount.Add(1)
status := http.StatusUnauthorized
if err.Error() == "ip_not_whitelisted" {
status = http.StatusForbidden
} else if err.Error() == "timestamp_expired" {
status = http.StatusGone
}
writeError(w, status, err.Error())
return
}
// Process payload if validation passes
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"status": "validated"})
}
}
The handler captures request latency, enforces HTTP method and content-type constraints, and delegates to the validator. Validation failures increment the atomic rejection counter and return appropriate HTTP status codes.
Step 2: Timestamp Tolerance and Replay Attack Prevention
Cognigy includes a Unix timestamp in the X-Cognigy-Timestamp header. The validator enforces a maximum drift window to prevent replay attacks. Requests outside the tolerance window are rejected before cryptographic verification.
package main
import (
"fmt"
"time"
)
func (v *WebhookValidator) ValidateTimestamp(r *http.Request) error {
tsHeader := r.Header.Get("X-Cognigy-Timestamp")
if tsHeader == "" {
return fmt.Errorf("missing_timestamp")
}
var ts int64
_, err := fmt.Sscanf(tsHeader, "%d", &ts)
if err != nil {
return fmt.Errorf("invalid_timestamp_format")
}
now := time.Now().Unix()
drift := now - ts
if drift < 0 {
drift = -drift
}
if drift > int64(v.maxDrift.Seconds()) {
return fmt.Errorf("timestamp_expired")
}
return nil
}
The maximum drift is configurable via maxDrift. Production deployments typically use a 300-second window. The validator computes absolute drift to handle minor clock skew in either direction. Requests exceeding the threshold receive an http.StatusGone response.
Step 3: HMAC-SHA256 Signature Verification and Secret Key Matrix
The validator iterates over the active secret matrix to compute HMAC-SHA256 hashes over the raw request body. It uses constant-time comparison to prevent timing attacks. The first matching secret validates the request.
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
)
func (v *WebhookValidator) ValidateSignature(r *http.Request) error {
sigHeader := r.Header.Get("X-Cognigy-Signature")
if sigHeader == "" {
return fmt.Errorf("missing_signature")
}
body, err := io.ReadAll(r.Body)
if err != nil {
return fmt.Errorf("body_read_failed")
}
defer r.Body.Close()
for _, secret := range v.secrets.Active {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := mac.Sum(nil)
provided, err := hex.DecodeString(sigHeader)
if err != nil {
continue
}
if hmac.Equal(expected, provided) {
return nil
}
}
return fmt.Errorf("signature_mismatch")
}
The validator reads the body once, iterates through the secret matrix, and decodes the hex signature header. hmac.Equal performs constant-time comparison. If no secret matches, the validator returns a mismatch error. This approach supports seamless key rotation while maintaining cryptographic integrity.
Step 4: Source IP Whitelisting and Atomic Rejection Tracking
Network-level validation prevents spoofed webhook sources. The validator extracts the client IP, checks it against an allowed list, and updates atomic counters for security analytics.
package main
import (
"fmt"
"net"
"strings"
"sync/atomic"
)
type WebhookValidator struct {
secrets *SecretMatrix
allowedIPs []string
maxDrift time.Duration
siemURL string
rejectionCount atomic.Int64
latencySum atomic.Int64
}
func NewWebhookValidator(secrets *SecretMatrix, allowedIPs []string, maxDrift time.Duration, siemURL string) *WebhookValidator {
return &WebhookValidator{
secrets: secrets,
allowedIPs: allowedIPs,
maxDrift: maxDrift,
siemURL: siemURL,
}
}
func (v *WebhookValidator) ValidateIP(r *http.Request) error {
remote := r.RemoteAddr
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
parts := strings.Split(forwarded, ",")
remote = strings.TrimSpace(parts[0])
}
ip, _, err := net.SplitHostPort(remote)
if err != nil {
ip = remote
}
for _, allowed := range v.allowedIPs {
if ip == allowed {
return nil
}
}
return fmt.Errorf("ip_not_whitelisted")
}
The validator checks X-Forwarded-For for proxied deployments and falls back to RemoteAddr. It strips port numbers and performs exact match validation against the whitelist. Atomic counters track rejection volume and cumulative latency for downstream security dashboards.
Step 5: SIEM Callback Synchronization and Audit Logging
Validated webhooks trigger asynchronous SIEM callbacks and structured audit logs. The callback runs in a goroutine to avoid blocking the HTTP response. Retries handle transient 429 rate limits from the SIEM endpoint.
package main
import (
"bytes"
"context"
"encoding/json"
"log/slog"
"net/http"
"time"
)
type AuditRecord struct {
Event string `json:"event"`
Timestamp time.Time `json:"timestamp"`
BotID string `json:"bot_id"`
Intent string `json:"intent"`
Validated bool `json:"validated"`
LatencyUs int64 `json:"latency_us"`
Rejections int64 `json:"rejections"`
}
func (v *WebhookValidator) SyncSIEM(r *http.Request, validated bool, latencyUs int64) {
body := r.Body
// Reconstruct body for logging if needed, or use cached payload
var payload WebhookPayload
if err := json.Unmarshal(bodyBytes, &payload); err != nil {
payload = WebhookPayload{}
}
record := AuditRecord{
Event: "webhook_validation",
Timestamp: time.Now(),
BotID: payload.BotID,
Intent: payload.Intent,
Validated: validated,
LatencyUs: latencyUs,
Rejections: v.rejectionCount.Load(),
}
slog.Info("webhook_audit", "record", record)
// Async SIEM callback with retry logic
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
payloadBytes, _ := json.Marshal(record)
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, v.siemURL, bytes.NewBuffer(payloadBytes))
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 5 * time.Second}
for attempt := 0; attempt < 3; attempt++ {
resp, err := client.Do(req)
if err == nil && resp.StatusCode < 500 {
resp.Body.Close()
return
}
if err != nil || resp.StatusCode == http.StatusTooManyRequests {
time.Sleep(time.Duration(attempt+1) * 500 * time.Millisecond)
continue
}
resp.Body.Close()
return
}
}()
}
The validator logs structured JSON for compliance verification and dispatches an asynchronous callback to the SIEM endpoint. The callback implements exponential backoff for 429 responses and retries up to three times. Audit records capture validation status, latency, and cumulative rejection counts.
Complete Working Example
The following program combines all components into a runnable server. It loads configuration, initializes the validator, and starts the HTTP listener.
package main
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"os"
"time"
)
var bodyBytes []byte
func main() {
secrets := LoadSecretMatrix()
allowedIPs := []string{"192.168.1.100", "10.0.0.50"}
maxDrift := 300 * time.Second
siemURL := "https://siem.example.com/api/v1/events"
validator := NewWebhookValidator(secrets, allowedIPs, maxDrift, siemURL)
http.HandleFunc("/webhook/cognigy", func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
defer func() {
validator.latencySum.Add(time.Since(start).Microseconds())
}()
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "Only POST requests are accepted")
return
}
if r.Header.Get("Content-Type") != "application/json" {
writeError(w, http.StatusUnsupportedMediaType, "Content-Type must be application/json")
return
}
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
writeError(w, http.StatusInternalServerError, "body_read_failed")
return
}
defer r.Body.Close()
// Reattach body for signature validation
r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
if err := validator.Validate(r); err != nil {
validator.rejectionCount.Add(1)
status := http.StatusUnauthorized
if err.Error() == "ip_not_whitelisted" {
status = http.StatusForbidden
} else if err.Error() == "timestamp_expired" {
status = http.StatusGone
}
writeError(w, status, err.Error())
validator.SyncSIEM(r, false, 0)
return
}
latency := time.Since(start).Microseconds()
validator.SyncSIEM(r, true, latency)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"status": "validated"})
})
slog.Info("server starting", "port", ":8080")
if err := http.ListenAndServe(":8080", nil); err != nil {
slog.Error("server failed", "error", err)
os.Exit(1)
}
}
Run the server with COGNIGY_WEBHOOK_SECRETS=your-secret-here go run main.go. Test validation with the following curl command:
curl -X POST http://localhost:8080/webhook/cognigy \
-H "Content-Type: application/json" \
-H "X-Cognigy-Signature: $(echo -n '{"botId":"b1","intent":"test"}' | openssl dgst -sha256 -hmac "your-secret-here" | awk '{print $NF}')" \
-H "X-Cognigy-Timestamp: $(date +%s)" \
-d '{"botId":"b1","intent":"test","entities":{},"timestamp":"2024-01-01T00:00:00Z"}'
Expected response for valid requests:
{"status":"validated"}
Expected response for expired timestamps:
{"error":"Gone","code":410,"message":"timestamp_expired"}
Common Errors & Debugging
Error: 401 Unauthorized - signature_mismatch
- Cause: The shared secret in Cognigy does not match any key in the
COGNIGY_WEBHOOK_SECRETSenvironment variable, or the signature header contains whitespace. - Fix: Verify the secret matches exactly. Ensure the signature is computed over the raw JSON body without trailing newlines. Use
hex.EncodeToStringon the client side to match Go expectations. - Code adjustment: Log the provided signature and computed hash during debugging. Disable production logging after verification.
Error: 410 Gone - timestamp_expired
- Cause: The
X-Cognigy-Timestampheader exceeds the configured drift window, or the server clock is unsynchronized. - Fix: Enable NTP synchronization on the host. Increase
maxDrifttemporarily if network latency is high. Cognigy timestamps are generated at webhook dispatch time. - Code adjustment: Adjust
maxDriftin the validator initialization. Monitor clock skew withchronyc tracking.
Error: 403 Forbidden - ip_not_whitelisted
- Cause: The request originates from an IP address not listed in
allowedIPs, or a reverse proxy is not forwardingX-Forwarded-Forcorrectly. - Fix: Update the whitelist with Cognigy delivery IPs. Configure your load balancer to preserve client IPs. Verify proxy headers in the request logs.
- Code adjustment: Add CIDR range support to
ValidateIPif Cognigy uses dynamic IP pools. Parsenet.ParseCIDRfor flexible matching.
Error: 429 Too Many Requests - SIEM callback throttling
- Cause: The external SIEM endpoint enforces rate limits on inbound event ingestion.
- Fix: The validator implements automatic retry with exponential backoff. If failures persist, increase the SIEM rate limit or implement a message queue buffer.
- Code adjustment: Add a persistent retry queue for dropped events. Track callback success rates separately from webhook validation metrics.