Validating NICE Cognigy Incoming Webhook Signatures in Go

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_SECRETS environment 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.EncodeToString on 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-Timestamp header exceeds the configured drift window, or the server clock is unsynchronized.
  • Fix: Enable NTP synchronization on the host. Increase maxDrift temporarily if network latency is high. Cognigy timestamps are generated at webhook dispatch time.
  • Code adjustment: Adjust maxDrift in the validator initialization. Monitor clock skew with chronyc 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 forwarding X-Forwarded-For correctly.
  • 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 ValidateIP if Cognigy uses dynamic IP pools. Parse net.ParseCIDR for 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.

Official References