Handling NICE Cognigy Incoming Bot Fulfillment Requests via REST API with Go

Handling NICE Cognigy Incoming Bot Fulfillment Requests via REST API with Go

What You Will Build

A production-ready Go HTTP handler that receives NICE Cognigy bot fulfillment requests, validates HMAC signatures and payload schemas, processes them atomically with idempotency guarantees, synchronizes results with external backends using exponential backoff retry logic, and emits structured audit logs with latency metrics. This tutorial uses the Go standard library and net/http. The code is written in Go 1.21+.

Prerequisites

  • NICE Cognigy Cloud workspace with Webhook Fulfillment enabled
  • Webhook Secret configured in Cognigy Studio (used for HMAC-SHA256 verification)
  • Go 1.21+ runtime environment
  • Project initialized with go mod init cognigy-fulfillment-handler
  • No external dependencies required. The implementation relies exclusively on the Go standard library for HTTP handling, cryptography, synchronization, and structured logging.

Authentication Setup

Cognigy fulfillment webhooks do not use OAuth token flows. Instead, they enforce server-to-server authentication via a shared secret that generates an HMAC-SHA256 signature. You configure this secret in Cognigy Studio under the Webhook node settings. Cognigy appends two headers to every POST request:

  • X-Cognigy-Signature: The base64-encoded HMAC-SHA256 hash of the raw request body using your secret.
  • X-Cognigy-Timestamp: Unix epoch seconds when Cognigy signed the request.

Your handler must verify the signature against the raw body and reject requests older than a defined window to prevent replay attacks. The following configuration block shows how to load the secret and set validation parameters.

package main

import (
	"os"
	"time"
)

type WebhookConfig struct {
	Secret            string
	MaxPayloadBytes   int64
	SignatureWindow   time.Duration
	ExternalSyncURL   string
	ExternalSyncToken string
}

func loadConfig() WebhookConfig {
	return WebhookConfig{
		Secret:            os.Getenv("COGNIGY_WEBHOOK_SECRET"),
		MaxPayloadBytes:   1_048_576, // 1MB limit
		SignatureWindow:   30 * time.Second,
		ExternalSyncURL:   os.Getenv("EXTERNAL_SYNC_ENDPOINT"),
		ExternalSyncToken: os.Getenv("EXTERNAL_SYNC_BEARER_TOKEN"),
	}
}

Implementation

Step 1: Define Payload Structures & Schema Validation

Cognigy sends a JSON payload containing conversation context, user data, and an array of payload items. The response must mirror the requestId and return an array of fulfillment actions. Define strict structs to enforce schema validation during JSON decoding.

package main

import (
	"encoding/json"
	"fmt"
)

type CognigyRequest struct {
	RequestID string          `json:"requestId"`
	UserID    string          `json:"userId"`
	SessionID string          `json:"sessionId"`
	Language  string          `json:"language"`
	Payload   []PayloadItem   `json:"payload"`
	Context   json.RawMessage `json:"context"`
}

type PayloadItem struct {
	Type  string `json:"type"`
	Value any    `json:"value"`
}

type CognigyResponse struct {
	RequestID string             `json:"requestId"`
	Payload   []FulfillmentAction `json:"payload"`
	Context   json.RawMessage    `json:"context"`
}

type FulfillmentAction struct {
	Type  string `json:"type"`
	Value string `json:"value"`
}

func validateCognigySchema(req *CognigyRequest) error {
	if req.RequestID == "" {
		return fmt.Errorf("missing required field: requestId")
	}
	if len(req.Payload) == 0 {
		return fmt.Errorf("payload array must contain at least one item")
	}
	for i, p := range req.Payload {
		if p.Type == "" {
			return fmt.Errorf("payload[%d] missing required field: type", i)
		}
	}
	return nil
}

Step 2: Implement HMAC Signature Verification & Size Constraints

Enforce payload size limits at the transport layer and verify the HMAC signature before processing. Use http.MaxBytesReader to prevent resource exhaustion. Compare signatures using subtle.ConstantTimeCompare to mitigate timing attacks. Reject requests outside the timestamp window.

package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"strconv"
	"time"
)

func verifyWebhookSignature(w http.ResponseWriter, r *http.Request, cfg WebhookConfig) (*CognigyRequest, error) {
	// Enforce payload size constraint
	r.Body = http.MaxBytesReader(w, r.Body, cfg.MaxPayloadBytes)
	rawBody, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "Failed to read request body", http.StatusRequestEntityTooLarge)
		return nil, fmt.Errorf("read body: %w", err)
	}

	// Verify HMAC signature
	expectedSig := r.Header.Get("X-Cognigy-Signature")
	if expectedSig == "" {
		http.Error(w, "Missing X-Cognigy-Signature header", http.StatusUnauthorized)
		return nil, fmt.Errorf("missing signature header")
	}

	mac := hmac.New(sha256.New, []byte(cfg.Secret))
	mac.Write(rawBody)
	computedSig := base64.StdEncoding.EncodeToString(mac.Sum(nil))

	if !hmac.Equal([]byte(expectedSig), []byte(computedSig)) {
		http.Error(w, "Invalid webhook signature", http.StatusForbidden)
		return nil, fmt.Errorf("signature mismatch")
	}

	// Validate timestamp to prevent replay attacks
	tsHeader := r.Header.Get("X-Cognigy-Timestamp")
	if tsHeader == "" {
		http.Error(w, "Missing X-Cognigy-Timestamp header", http.StatusUnauthorized)
		return nil, fmt.Errorf("missing timestamp header")
	}

	ts, err := strconv.ParseInt(tsHeader, 10, 64)
	if err != nil {
		http.Error(w, "Invalid timestamp format", http.StatusBadRequest)
		return nil, fmt.Errorf("parse timestamp: %w", err)
	}

	if time.Since(time.Unix(ts, 0)) > cfg.SignatureWindow {
		http.Error(w, "Webhook signature expired", http.StatusRequestTimeout)
		return nil, fmt.Errorf("timestamp outside window")
	}

	// Decode and validate schema
	var req CognigyRequest
	if err := json.Unmarshal(rawBody, &req); err != nil {
		http.Error(w, "Invalid JSON payload", http.StatusBadRequest)
		return nil, fmt.Errorf("unmarshal: %w", err)
	}

	if err := validateCognigySchema(&req); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return nil, fmt.Errorf("schema validation: %w", err)
	}

	return &req, nil
}

Step 3: Atomic Processing with Idempotency & Retry Logic

Cognigy retries failed fulfillment requests. Prevent duplicate processing by tracking requestId in a thread-safe store. Use atomic operations to mark processing states. When synchronizing with your external backend, implement exponential backoff retry logic to handle transient network interruptions.

package main

import (
	"context"
	"crypto/tls"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"sync"
	"time"
)

type IdempotencyStore struct {
	mu    sync.RWMutex
	seen  map[string]time.Time
	ttl   time.Duration
}

func NewIdempotencyStore(ttl time.Duration) *IdempotencyStore {
	s := &IdempotencyStore{seen: make(map[string]time.Time), ttl: ttl}
	go s.cleanupLoop()
	return s
}

func (s *IdempotencyStore) IsDuplicate(reqID string) bool {
	s.mu.RLock()
	defer s.mu.RUnlock()
	if t, ok := s.seen[reqID]; ok {
		return time.Since(t) < s.ttl
	}
	return false
}

func (s *IdempotencyStore) MarkProcessed(reqID string) {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.seen[reqID] = time.Now()
}

func (s *IdempotencyStore) cleanupLoop() {
	ticker := time.NewTicker(1 * time.Minute)
	for range ticker.C {
		s.mu.Lock()
		now := time.Now()
		for reqID, t := range s.seen {
			if now.Sub(t) > s.ttl {
				delete(s.seen, reqID)
			}
		}
		s.mu.Unlock()
	}
}

func syncWithExternalBackend(ctx context.Context, cfg WebhookConfig, reqID, userID string) error {
	payload := map[string]string{
		"requestId": reqID,
		"userId":    userID,
		"event":     "fulfillment.completed",
		"timestamp": time.Now().UTC().Format(time.RFC3339),
	}
	body, _ := json.Marshal(payload)

	client := &http.Client{
		Timeout: 5 * time.Second,
		Transport: &http.Transport{
			TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
		},
	}

	// Exponential backoff retry logic
	maxRetries := 3
	baseDelay := 500 * time.Millisecond

	for attempt := 0; attempt <= maxRetries; attempt++ {
		req, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.ExternalSyncURL, io.NopCloser(bytes.NewReader(body)))
		if err != nil {
			return fmt.Errorf("create sync request: %w", err)
		}
		req.Header.Set("Content-Type", "application/json")
		req.Header.Set("Authorization", "Bearer "+cfg.ExternalSyncToken)
		req.Header.Set("Idempotency-Key", reqID)

		resp, err := client.Do(req)
		if err != nil {
			if attempt < maxRetries {
				delay := baseDelay * (1 << attempt)
				time.Sleep(delay)
				continue
			}
			return fmt.Errorf("sync request failed after %d retries: %w", maxRetries, err)
		}
		resp.Body.Close()

		if resp.StatusCode >= 200 && resp.StatusCode < 300 {
			return nil
		}
		if resp.StatusCode == 429 || resp.StatusCode >= 500 {
			if attempt < maxRetries {
				delay := baseDelay * (1 << attempt)
				time.Sleep(delay)
				continue
			}
		}
		return fmt.Errorf("sync returned status %d", resp.StatusCode)
	}
	return fmt.Errorf("sync exhausted retries")
}

Step 4: Synchronization, Latency Tracking & Audit Logging

Combine validation, idempotency, and backend synchronization into a single handler. Track latency and success rates using atomic counters. Emit structured audit logs for security compliance. Prevent callback loops by responding to Cognigy synchronously and dispatching external synchronization asynchronously.

package main

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

var (
	metricLatencyTotal atomic.Int64
	metricSuccessCount atomic.Int64
	metricFailureCount atomic.Int64
)

type FulfillmentHandler struct {
	cfg      WebhookConfig
	idemp    *IdempotencyStore
	logger   *slog.Logger
}

func NewFulfillmentHandler(cfg WebhookConfig) *FulfillmentHandler {
	return &FulfillmentHandler{
		cfg:      cfg,
		idemp:    NewIdempotencyStore(24 * time.Hour),
		logger:   slog.New(slog.NewJSONHandler(os.Stdout, nil)),
	}
}

func (h *FulfillmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	start := time.Now()
	defer func() {
		elapsed := time.Since(start).Milliseconds()
		metricLatencyTotal.Add(elapsed)
	}()

	if r.Method != http.MethodPost {
		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		return
	}

	req, err := verifyWebhookSignature(w, r, h.cfg)
	if err != nil {
		metricFailureCount.Add(1)
		h.logger.Warn("webhook verification failed", "error", err, "remote_addr", r.RemoteAddr)
		return
	}

	// Idempotency check
	if h.idemp.IsDuplicate(req.RequestID) {
		h.logger.Info("duplicate request skipped", "requestId", req.RequestID)
		http.Error(w, "Request already processed", http.StatusConflict)
		return
	}
	h.idemp.MarkProcessed(req.RequestID)

	// Construct response payload
	resp := CognigyResponse{
		RequestID: req.RequestID,
		Payload: []FulfillmentAction{
			{Type: "text", Value: "Fulfillment processed successfully."},
		},
		Context: req.Context,
	}

	// Async external synchronization to prevent callback loops and reduce latency
	ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
	defer cancel()

	go func() {
		if err := syncWithExternalBackend(ctx, h.cfg, req.RequestID, req.UserID); err != nil {
			metricFailureCount.Add(1)
			h.logger.Error("external sync failed", "requestId", req.RequestID, "error", err)
		} else {
			metricSuccessCount.Add(1)
		}
	}()

	// Audit log
	h.logger.Info("fulfillment processed",
		"requestId", req.RequestID,
		"userId", req.UserID,
		"latency_ms", time.Since(start).Milliseconds(),
	)

	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	if err := json.NewEncoder(w).Encode(resp); err != nil {
		http.Error(w, "Failed to encode response", http.StatusInternalServerError)
	}
}

Complete Working Example

The following script combines all components into a runnable server. Replace the environment variables with your Cognigy webhook secret and external backend credentials.

package main

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

func main() {
	cfg := loadConfig()
	if cfg.Secret == "" {
		fmt.Println("Error: COGNIGY_WEBHOOK_SECRET environment variable is required")
		os.Exit(1)
	}

	handler := NewFulfillmentHandler(cfg)

	mux := http.NewServeMux()
	mux.HandleFunc("/fulfillment", handler.ServeHTTP)

	srv := &http.Server{
		Addr:         ":8443",
		Handler:      mux,
		ReadTimeout:  15 * time.Second,
		WriteTimeout: 15 * time.Second,
		IdleTimeout:  60 * time.Second,
	}

	slog.Info("starting fulfillment webhook handler", "addr", srv.Addr)
	if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
		slog.Error("server failed", "error", err)
		os.Exit(1)
	}
}

Common Errors & Debugging

Error: 403 Forbidden (Invalid webhook signature)

  • Cause: The X-Cognigy-Signature header does not match the computed HMAC-SHA256 of the raw body, or the shared secret in your environment differs from the one configured in Cognigy Studio.
  • Fix: Verify the COGNIGY_WEBHOOK_SECRET value matches exactly. Ensure you are computing the hash over the raw request body, not a decoded or truncated version. Use slog.Debug to log the expected and computed signatures during development.

Error: 413 Payload Too Large

  • Cause: The incoming JSON exceeds the MaxPayloadBytes threshold enforced by http.MaxBytesReader.
  • Fix: Adjust cfg.MaxPayloadBytes if Cognigy is sending large context objects. Alternatively, configure Cognigy to trim unused context fields before sending the webhook.

Error: 409 Conflict (Idempotency key collision)

  • Cause: Cognigy retried a request that was already processed successfully, or your idempotency store TTL is too long.
  • Fix: This is expected behavior. The handler correctly rejects duplicates to prevent double-processing. Verify your idempotency TTL aligns with your business logic. Reduce the TTL if Cognigy retries frequently within a short window.

Error: 502/504 External Sync Timeout

  • Cause: The external backend is unreachable or responds slowly, causing the retry loop to exhaust.
  • Fix: Increase the Timeout on the HTTP client or adjust the exponential backoff base delay. Implement a circuit breaker pattern if the backend experiences prolonged outages.

Error: Callback Loop Detected

  • Cause: The external backend or your handler inadvertently sends a POST request back to the same /fulfillment endpoint.
  • Fix: Ensure outbound synchronization calls target a distinct endpoint. The async goroutine in Step 4 prevents blocking the Cognigy response, but you must verify routing rules and firewall policies to avoid circular traffic.

Official References