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/httppackage with custom middleware for HMAC verification, correlation tracking, and exponential backoff retry logic. - The tutorial covers Go 1.21+ with
log/slogfor structured audit logging andsync/atomicfor 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:readandcognigy:writescopes.
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-Signatureheader 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
verifySignaturefunction 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.DecoderwithDisallowUnknownFields()during integration testing to catch schema drift early. - Code:
parseAndTrackvalidatessessionIdanduserIdbefore 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:
processWithRetryreturns 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
/metricsendpoint. AdjustRetryConfigdelays. Add circuit breakers to the external sync function. - Code:
recordMetricsupdates atomic counters synchronously. Query/metricsvia Prometheus or your APM tool.