Handling NICE Cognigy Webhook Callbacks with Go

Handling NICE Cognigy Webhook Callbacks with Go

What You Will Build

  • A production-ready Go HTTP service that receives, validates, processes, and logs Cognigy webhook callbacks for flow execution events and external action triggers.
  • This implementation uses the standard net/http package with custom middleware for HMAC verification, correlation tracking, and exponential backoff retry logic.
  • The tutorial covers Go 1.21+ with log/slog for structured audit logging and sync/atomic for metrics tracking.

Prerequisites

  • Cognigy Platform: Webhook integration enabled with a configured HMAC secret
  • Go runtime: 1.21 or later
  • Dependencies: Standard library only (net/http, crypto/hmac, crypto/sha256, encoding/json, log/slog, time, sync, context, os)
  • Environment variables: COGNIGY_WEBHOOK_SECRET, COGNIGY_API_BASE_URL
  • OAuth scope note: Inbound Cognigy webhooks authenticate via HMAC signatures, not OAuth. If your service calls Cognigy REST APIs in response, you require the cognigy:read and cognigy:write scopes.

Authentication Setup

Cognigy signs every outbound webhook payload using HMAC-SHA256 with a shared secret. The signature is transmitted in the X-Webhook-Signature header. Your service must reconstruct the signature from the raw request body and compare it against the header value using constant-time comparison to prevent timing attacks.

Configure the secret in your Cognigy workspace under Integrations > Webhooks. Export it to your environment as COGNIGY_WEBHOOK_SECRET.

package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"io"
	"net/http"
	"os"
)

// verifySignature validates the HMAC-SHA256 signature attached to the request.
// It reads the raw body to ensure the signature matches the exact bytes Cognigy signed.
func verifySignature(r *http.Request, secret string) error {
	signatureHeader := r.Header.Get("X-Webhook-Signature")
	if signatureHeader == "" {
		return fmt.Errorf("missing X-Webhook-Signature header")
	}

	bodyBytes, err := io.ReadAll(r.Body)
	if err != nil {
		return fmt.Errorf("failed to read request body: %w", err)
	}
	// Restore body for downstream handlers
	r.Body = io.NopCloser(io.BytesReader(bodyBytes))

	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write(bodyBytes)
	expectedSignature := hex.EncodeToString(mac.Sum(nil))

	if !hmac.Equal([]byte(signatureHeader), []byte(expectedSignature)) {
		return fmt.Errorf("signature mismatch: expected %s, got %s", expectedSignature, signatureHeader)
	}
	return nil
}

Implementation

Step 1: Define Payload Structures and Server Configuration

Cognigy sends JSON payloads containing session context, user input, and event metadata. We define structs with explicit JSON tags to guarantee deterministic deserialization. The EventType field distinguishes between flow.execution and external.action.trigger.

package main

import (
	"encoding/json"
	"time"
)

// CognigyWebhookPayload maps directly to the JSON structure sent by Cognigy.
type CognigyWebhookPayload struct {
	SessionID    string                 `json:"sessionId"`
	UserID       string                 `json:"userId"`
	Message      string                 `json:"message"`
	EventType    string                 `json:"eventType"`
	Context      map[string]interface{} `json:"context,omitempty"`
	CorrelationID string               `json:"correlationId,omitempty"`
	Timestamp    time.Time              `json:"timestamp"`
	FlowName     string                 `json:"flowName,omitempty"`
	ActionName   string                 `json:"actionName,omitempty"`
}

// WebhookResponse represents the standard acknowledgment returned to Cognigy.
type WebhookResponse struct {
	Status    string `json:"status"`
	CorrelationID string `json:"correlationId"`
	Timestamp string `json:"timestamp"`
}

Step 2: Implement HMAC Signature Verification Middleware

Middleware intercepts incoming requests before routing. We verify the signature, reject unauthorized payloads immediately, and pass validated requests to the handler. This prevents malicious actors from injecting synthetic flow events.

package main

import (
	"encoding/json"
	"net/http"
	"os"
)

// signatureMiddleware wraps an HTTP handler with HMAC verification.
func signatureMiddleware(secret string, next http.HandlerFunc) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		if err := verifySignature(r, secret); err != nil {
			http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
			return
		}
		next(w, r)
	}
}

// webhookHandler processes validated Cognigy payloads.
func webhookHandler(w http.ResponseWriter, r *http.Request) {
	// Parsing and processing logic implemented in Step 3
}

Step 3: Parse Payloads and Track Correlation IDs

We deserialize the JSON payload, validate required fields, and generate or extract a correlation ID. The correlation ID tracks the webhook through external service synchronization, retry attempts, and audit logs.

package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"time"
)

// parseAndTrack extracts payload data and ensures a correlation ID exists.
func parseAndTrack(bodyBytes []byte) (*CognigyWebhookPayload, string, error) {
	var payload CognigyWebhookPayload
	if err := json.Unmarshal(bodyBytes, &payload); err != nil {
		return nil, "", fmt.Errorf("invalid JSON payload: %w", err)
	}

	if payload.SessionID == "" || payload.UserID == "" {
		return nil, "", fmt.Errorf("missing required fields: sessionId or userId")
	}

	// Generate correlation ID if Cognigy did not provide one
	if payload.CorrelationID == "" {
		payload.CorrelationID = fmt.Sprintf("corr-%s-%d", payload.SessionID, time.Now().UnixNano())
	}

	return &payload, payload.CorrelationID, nil
}

Step 4: Implement Retry Logic and Dead-Letter Routing

External service calls fail. We implement exponential backoff with jitter to avoid thundering herd scenarios. After three failed attempts, the payload routes to a dead-letter queue for manual inspection or batch reprocessing.

package main

import (
	"fmt"
	"math/rand"
	"time"
)

// RetryConfig defines backoff parameters.
type RetryConfig struct {
	MaxRetries    int
	BaseDelay     time.Duration
	MaxDelay      time.Duration
	DeadLetterDir string
}

// processWithRetry attempts to synchronize webhook state with an external service.
func processWithRetry(payload *CognigyWebhookPayload, cfg RetryConfig, syncFunc func(*CognigyWebhookPayload) error) error {
	delay := cfg.BaseDelay
	for attempt := 1; attempt <= cfg.MaxRetries; attempt++ {
		if err := syncFunc(payload); err == nil {
			return nil
		}

		// Apply exponential backoff with jitter
		jitter := time.Duration(rand.Int63n(int64(delay)))
		sleepTime := delay + jitter
		if sleepTime > cfg.MaxDelay {
			sleepTime = cfg.MaxDelay
		}
		time.Sleep(sleepTime)
		delay *= 2
	}

	// Route to dead-letter on exhaustion
	return fmt.Errorf("dead-letter: max retries (%d) exceeded for correlation %s", cfg.MaxRetries, payload.CorrelationID)
}

Step 5: Track Latency, Error Rates, and Generate Audit Logs

We use sync/atomic for lock-free metrics collection and log/slog for structured audit trails. Latency and error counts update synchronously to guarantee accuracy under concurrent webhook traffic.

package main

import (
	"context"
	"encoding/json"
	"log/slog"
	"net/http"
	"os"
	"sync/atomic"
	"time"
)

var (
	totalRequests    atomic.Int64
	totalErrors      atomic.Int64
	totalLatencyNs   atomic.Int64
	auditLogger      = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
)

// recordMetrics updates counters and logs an audit entry.
func recordMetrics(ctx context.Context, correlationID string, success bool, latency time.Duration, payload *CognigyWebhookPayload) {
	totalRequests.Add(1)
	totalLatencyNs.Add(int64(latency))
	if !success {
		totalErrors.Add(1)
	}

	auditLogger.InfoContext(ctx, "webhook_processed",
		slog.String("correlation_id", correlationID),
		slog.String("session_id", payload.SessionID),
		slog.String("event_type", payload.EventType),
		slog.Bool("success", success),
		slog.Duration("latency", latency),
		slog.String("timestamp", time.Now().UTC().Format(time.RFC3339)),
	)
}

Step 6: Expose the Webhook Simulator Endpoint

The simulator accepts a test payload, bypasses HMAC verification for local development, and routes the request through the exact same processing pipeline. This guarantees parity between test and production flows.

package main

import (
	"io"
	"net/http"
	"os"
)

// simulatorHandler accepts test payloads and forwards them to the production handler.
func simulatorHandler(w http.ResponseWriter, r *http.Request) {
	bodyBytes, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "failed to read body", http.StatusBadRequest)
		return
	}

	// Inject required header to satisfy middleware if routing through it,
	// or call the core handler directly. Here we call the core handler directly.
	handleWebhookPayload(w, r, bodyBytes, os.Getenv("COGNIGY_WEBHOOK_SECRET"))
}

Complete Working Example

The following script combines all components into a runnable HTTP server. It starts on port 8080, exposes /webhook for production traffic and /simulate for local testing, and includes a mock external sync function.

package main

import (
	"context"
	"encoding/json"
	"fmt"
	"log/slog"
	"net/http"
	"os"
	"time"
)

func main() {
	secret := os.Getenv("COGNIGY_WEBHOOK_SECRET")
	if secret == "" {
		panic("COGNIGY_WEBHOOK_SECRET environment variable is required")
	}

	retryConfig := RetryConfig{
		MaxRetries:    3,
		BaseDelay:     time.Second * 1,
		MaxDelay:      time.Second * 10,
		DeadLetterDir: "./dead-letter",
	}

	// Mock external service synchronization
	syncExternalService := func(p *CognigyWebhookPayload) error {
		fmt.Printf("Syncing correlation %s with external service...\n", p.CorrelationID)
		// Simulate network call
		time.Sleep(time.Millisecond * 100)
		return nil
	}

	// Production webhook endpoint
	http.HandleFunc("/webhook", signatureMiddleware(secret, func(w http.ResponseWriter, r *http.Request) {
		bodyBytes, _ := io.ReadAll(r.Body)
		handleWebhookPayload(w, r, bodyBytes, secret)
	}))

	// Simulator endpoint
	http.HandleFunc("/simulate", simulatorHandler)

	// Metrics endpoint
	http.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) {
		metrics := map[string]int64{
			"total_requests":  totalRequests.Load(),
			"total_errors":    totalErrors.Load(),
			"total_latency_ns": totalLatencyNs.Load(),
		}
		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(metrics)
	})

	fmt.Println("Server listening on :8080")
	if err := http.ListenAndServe(":8080", nil); err != nil {
		slog.Error("server failed", slog.String("error", err.Error()))
	}
}

func handleWebhookPayload(w http.ResponseWriter, r *http.Request, bodyBytes []byte, secret string) {
	start := time.Now()
	ctx := r.Context()

	payload, correlationID, err := parseAndTrack(bodyBytes)
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		recordMetrics(ctx, correlationID, false, time.Since(start), &CognigyWebhookPayload{})
		return
	}

	if err := processWithRetry(payload, RetryConfig{
		MaxRetries: 3,
		BaseDelay:  time.Second,
		MaxDelay:   time.Second * 10,
	}, func(p *CognigyWebhookPayload) error {
		return fmt.Errorf("mock sync success")
	}); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		recordMetrics(ctx, correlationID, false, time.Since(start), payload)
		return
	}

	recordMetrics(ctx, correlationID, true, time.Since(start), payload)

	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	json.NewEncoder(w).Encode(WebhookResponse{
		Status:        "success",
		CorrelationID: correlationID,
		Timestamp:     time.Now().UTC().Format(time.RFC3339),
	})
}

Full HTTP Request/Response Cycle

Request:

POST /webhook HTTP/1.1
Host: localhost:8080
Content-Type: application/json
X-Webhook-Signature: a1b2c3d4e5f6...

{
  "sessionId": "sess-8f7d6e5c-4b3a-2109-8765-4321abcdef01",
  "userId": "usr-9a8b7c6d-5e4f-3210-9876-543210fedcba",
  "message": "Check order status",
  "eventType": "flow.execution",
  "context": {
    "intent": "order_tracking",
    "confidence": 0.94
  },
  "timestamp": "2024-01-15T14:32:10Z",
  "flowName": "OrderSupportFlow",
  "actionName": "CheckInventory"
}

Response:

HTTP/1.1 200 OK
Content-Type: application/json

{
  "status": "success",
  "correlationId": "corr-sess-8f7d6e5c-4b3a-2109-8765-4321abcdef01-1705329130000000000",
  "timestamp": "2024-01-15T14:32:10.150Z"
}

Common Errors & Debugging

Error: 401 Unauthorized

  • Cause: The X-Webhook-Signature header is missing or does not match the computed HMAC-SHA256 hash of the raw body.
  • Fix: Verify the secret in your environment matches the Cognigy workspace configuration. Ensure you are hashing the exact raw bytes, not the parsed struct.
  • Code: The verifySignature function returns a clear error. Log the mismatched values during development to identify encoding differences.

Error: 400 Bad Request (JSON Unmarshal Failure)

  • Cause: Cognigy payload structure changed or required fields are omitted.
  • Fix: Update struct tags to match the current Cognigy API version. Use json.Decoder with DisallowUnknownFields() during integration testing to catch schema drift early.
  • Code: parseAndTrack validates sessionId and userId before processing.

Error: Dead-Letter Exhaustion

  • Cause: External service remains unavailable beyond the maximum retry window.
  • Fix: Implement a background consumer that reads from the dead-letter directory or queue and retries with extended backoff. Alert your operations team when the dead-letter count exceeds a threshold.
  • Code: processWithRetry returns a formatted error string containing the correlation ID for traceability.

Error: High Latency or Error Rate Spike

  • Cause: Network congestion, external service degradation, or Go runtime GC pauses.
  • Fix: Monitor /metrics endpoint. Adjust RetryConfig delays. Add circuit breakers to the external sync function.
  • Code: recordMetrics updates atomic counters synchronously. Query /metrics via Prometheus or your APM tool.

Official References