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-Signatureheader 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_SECRETvalue matches exactly. Ensure you are computing the hash over the raw request body, not a decoded or truncated version. Useslog.Debugto log the expected and computed signatures during development.
Error: 413 Payload Too Large
- Cause: The incoming JSON exceeds the
MaxPayloadBytesthreshold enforced byhttp.MaxBytesReader. - Fix: Adjust
cfg.MaxPayloadBytesif 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
Timeouton 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
/fulfillmentendpoint. - 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.