Authenticating NICE Cognigy.AI Webhook Requests with Python

Authenticating NICE Cognigy.AI Webhook Requests with Python

What You Will Build

You will build a production-grade Python FastAPI service that receives NICE Cognigy.AI webhook events, verifies cryptographic signatures, enforces timestamp drift limits, validates IP allowlists, and routes verified payloads to downstream systems while emitting audit logs and SIEM callbacks. This implementation uses the Python httpx library and standard cryptographic primitives to handle the complete authentication pipeline. The code covers Python 3.10 and above.

Prerequisites

  • NICE Cognigy.AI workspace with webhook endpoint configured
  • Shared secret key generated in Cognigy.AI for webhook signing
  • Python 3.10+ runtime
  • Dependencies: fastapi, uvicorn, httpx, cryptography, pydantic, structlog
  • Required permissions: Cognigy.AI Admin or Developer role to configure webhooks and secrets
  • Note: Webhook ingestion does not use OAuth. Authentication relies on HMAC-SHA256 signature verification with a workspace shared secret.

Authentication Setup

Cognigy.AI webhooks sign every outgoing request using a workspace-specific shared secret. The platform appends three security headers to every POST request: X-Cognigy-Webhook-Id, X-Cognigy-Timestamp, and X-Cognigy-Signature. Your service must reconstruct the exact payload string that Cognigy hashed, apply the same HMAC-SHA256 algorithm, and compare the result against the provided signature header.

Store the shared secret in an environment variable. Never embed secrets in source code. The following environment variables are required:

  • COGNIGY_WEBHOOK_SECRET: Base64-encoded or raw shared secret
  • COGNIGY_ALLOWED_IPS: Comma-separated IPv4 addresses or CIDR ranges
  • MAX_TIMESTAMP_DRIFT_SECONDS: Maximum acceptable clock skew
  • SIEM_ENDPOINT_URL: HTTPS URL for your external security event collector

Implementation

Step 1: Atomic POST Receive and FastAPI Setup

The ingestion endpoint must accept POST requests atomically. FastAPI handles concurrent requests efficiently, but you must ensure the request body is read exactly once before validation. The endpoint returns a structured JSON response indicating success or failure.

from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse
import time
import logging

app = FastAPI(title="Cognigy Webhook Authenticator")
logger = logging.getLogger("cognigy_auth")

@app.post("/webhooks/cognigy")
async def receive_webhook(request: Request) -> JSONResponse:
    start_time = time.perf_counter()
    try:
        body_bytes = await request.body()
        if not body_bytes:
            raise HTTPException(status_code=400, detail="Empty request body")
            
        # Pass to validation pipeline
        auth_result = await validate_cognigy_request(request, body_bytes)
        
        latency_ms = (time.perf_counter() - start_time) * 1000
        logger.info("Webhook authenticated", latency_ms=latency_ms, webhook_id=auth_result.webhook_id)
        
        return JSONResponse(
            status_code=200,
            content={"status": "verified", "message": "Payload accepted"}
        )
    except HTTPException:
        raise
    except Exception as e:
        logger.error("Authentication pipeline failed", error=str(e))
        raise HTTPException(status_code=500, detail="Internal validation error")

Expected request structure:

POST /webhooks/cognigy HTTP/1.1
Host: your-domain.com
Content-Type: application/json
X-Cognigy-Webhook-Id: wh_8f9a2b3c4d5e
X-Cognigy-Timestamp: 1715429871
X-Cognigy-Signature: a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6
X-Cognigy-Nonce: n_9x8y7z6w5v4u3t2s1r0q

{
  "type": "conversation.started",
  "data": {
    "conversationId": "conv_123456",
    "userId": "user_789012",
    "timestamp": "2024-05-11T14:31:11Z"
  }
}

Step 2: Header Extraction and Signature Matrix Parsing

Cognigy sends signature metadata in a fixed header matrix. You must extract these headers atomically and validate their presence before proceeding. Missing headers indicate either a misconfigured webhook or a spoofed request.

from dataclasses import dataclass
from typing import Optional

@dataclass
class AuthHeaders:
    webhook_id: str
    timestamp: str
    signature: str
    nonce: Optional[str]
    source_ip: str

def extract_security_headers(request: Request) -> AuthHeaders:
    headers = request.headers
    webhook_id = headers.get("X-Cognigy-Webhook-Id")
    timestamp = headers.get("X-Cognigy-Timestamp")
    signature = headers.get("X-Cognigy-Signature")
    nonce = headers.get("X-Cognigy-Nonce")
    source_ip = request.client.host if request.client else "unknown"
    
    if not all([webhook_id, timestamp, signature]):
        raise HTTPException(status_code=400, detail="Missing required security headers")
        
    return AuthHeaders(
        webhook_id=webhook_id,
        timestamp=timestamp,
        signature=signature,
        nonce=nonce,
        source_ip=source_ip
    )

Step 3: Timestamp Drift Enforcement and Nonce Validation

Cognigy appends a Unix epoch timestamp to prevent replay attacks. Your service must enforce a maximum drift window. Nonce validation prevents duplicate processing of the same webhook event. You will track processed nonces in an in-memory sliding window for this example.

import os
import time
from collections import deque

MAX_DRIFT = int(os.getenv("MAX_TIMESTAMP_DRIFT_SECONDS", "300"))
NONCE_WINDOW = deque(maxlen=10000)

def enforce_timestamp_drift(timestamp_str: str) -> None:
    try:
        webhook_ts = int(timestamp_str)
    except ValueError:
        raise HTTPException(status_code=400, detail="Invalid timestamp format")
        
    current_ts = int(time.time())
    drift = abs(current_ts - webhook_ts)
    
    if drift > MAX_DRIFT:
        raise HTTPException(
            status_code=403, 
            detail=f"Timestamp drift {drift}s exceeds maximum allowed {MAX_DRIFT}s"
        )

def validate_nonce(nonce: Optional[str]) -> None:
    if not nonce:
        logger.warning("Nonce missing in webhook request")
        return
        
    if nonce in NONCE_WINDOW:
        raise HTTPException(status_code=429, detail="Duplicate nonce detected")
        
    NONCE_WINDOW.append(nonce)

Step 4: HMAC Verification and IP Allowlist Pipeline

The core security check reconstructs the signed payload exactly as Cognigy does. The platform concatenates the timestamp and raw request body, then applies HMAC-SHA256 using the shared secret. You must also verify the source IP against an allowlist before accepting the signature.

import hmac
import hashlib
import ipaddress
import os

def load_ip_allowlist() -> list:
    allowed = os.getenv("COGNIGY_ALLOWED_IPS", "0.0.0.0/0").split(",")
    networks = []
    for ip in allowed:
        networks.append(ipaddress.ip_network(ip.strip(), strict=False))
    return networks

ALLOWED_NETWORKS = load_ip_allowlist()

def verify_ip(source_ip: str) -> None:
    try:
        client_addr = ipaddress.ip_address(source_ip)
        if not any(client_addr in net for net in ALLOWED_NETWORKS):
            raise HTTPException(status_code=403, detail="Source IP not in allowlist")
    except ValueError:
        raise HTTPException(status_code=400, detail="Invalid IP address format")

def verify_hmac_signature(body_bytes: bytes, headers: AuthHeaders) -> None:
    secret = os.getenv("COGNIGY_WEBHOOK_SECRET")
    if not secret:
        raise HTTPException(status_code=500, detail="Webhook secret not configured")
        
    # Cognigy signs: timestamp + raw_body
    payload_to_sign = f"{headers.timestamp}{body_bytes.decode('utf-8')}".encode('utf-8')
    computed_signature = hmac.new(
        secret.encode('utf-8'),
        payload_to_sign,
        hashlib.sha256
    ).hexdigest()
    
    if not hmac.compare_digest(computed_signature, headers.signature):
        raise HTTPException(status_code=401, detail="Signature verification failed")

Step 5: SIEM Callback, Metrics, and Audit Logging

Verified webhooks trigger downstream security logging. You will emit structured audit events, track validation latency and success rates, and forward critical events to an external SIEM endpoint using httpx.

import httpx
import json
from datetime import datetime, timezone
from structlog import get_logger

structlog_logger = get_logger()
metrics = {"total_requests": 0, "success_count": 0, "failure_count": 0}

async def emit_siem_event(webhook_id: str, source_ip: str, status: str, latency_ms: float) -> None:
    siem_url = os.getenv("SIEM_ENDPOINT_URL")
    if not siem_url:
        return
        
    event_payload = {
        "event_type": "cognigy_webhook_auth",
        "webhook_id": webhook_id,
        "source_ip": source_ip,
        "status": status,
        "latency_ms": round(latency_ms, 2),
        "timestamp": datetime.now(timezone.utc).isoformat()
    }
    
    try:
        async with httpx.AsyncClient(timeout=5.0) as client:
            await client.post(siem_url, json=event_payload, headers={"Content-Type": "application/json"})
    except httpx.RequestError as e:
        structlog_logger.error("SIEM callback failed", error=str(e))

async def validate_cognigy_request(request: Request, body_bytes: bytes) -> AuthHeaders:
    metrics["total_requests"] += 1
    headers = extract_security_headers(request)
    verify_ip(headers.source_ip)
    enforce_timestamp_drift(headers.timestamp)
    validate_nonce(headers.nonce)
    verify_hmac_signature(body_bytes, headers)
    
    metrics["success_count"] += 1
    structlog_logger.info("Audit: Webhook authenticated", 
                          webhook_id=headers.webhook_id, 
                          source_ip=headers.source_ip,
                          nonce=headers.nonce)
                          
    await emit_siem_event(headers.webhook_id, headers.source_ip, "success", 0.0)
    return headers

Complete Working Example

The following module combines all validation stages, metrics tracking, and audit logging into a single runnable FastAPI application. Run it with uvicorn cognigy_authenticator:app --port 8000.

import os
import time
import logging
import hashlib
import hmac
import ipaddress
from collections import deque
from datetime import datetime, timezone
from dataclasses import dataclass
from typing import Optional

import httpx
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse
from structlog import get_logger

# Configuration
app = FastAPI(title="Cognigy Webhook Authenticator")
logger = logging.getLogger("cognigy_auth")
structlog_logger = get_logger()

MAX_DRIFT = int(os.getenv("MAX_TIMESTAMP_DRIFT_SECONDS", "300"))
NONCE_WINDOW = deque(maxlen=10000)
metrics = {"total_requests": 0, "success_count": 0, "failure_count": 0}

@dataclass
class AuthHeaders:
    webhook_id: str
    timestamp: str
    signature: str
    nonce: Optional[str]
    source_ip: str

def load_ip_allowlist() -> list:
    allowed = os.getenv("COGNIGY_ALLOWED_IPS", "0.0.0.0/0").split(",")
    return [ipaddress.ip_network(ip.strip(), strict=False) for ip in allowed]

ALLOWED_NETWORKS = load_ip_allowlist()

def extract_security_headers(request: Request) -> AuthHeaders:
    headers = request.headers
    webhook_id = headers.get("X-Cognigy-Webhook-Id")
    timestamp = headers.get("X-Cognigy-Timestamp")
    signature = headers.get("X-Cognigy-Signature")
    nonce = headers.get("X-Cognigy-Nonce")
    source_ip = request.client.host if request.client else "unknown"
    
    if not all([webhook_id, timestamp, signature]):
        raise HTTPException(status_code=400, detail="Missing required security headers")
    return AuthHeaders(webhook_id, timestamp, signature, nonce, source_ip)

def enforce_timestamp_drift(timestamp_str: str) -> None:
    try:
        webhook_ts = int(timestamp_str)
    except ValueError:
        raise HTTPException(status_code=400, detail="Invalid timestamp format")
    current_ts = int(time.time())
    drift = abs(current_ts - webhook_ts)
    if drift > MAX_DRIFT:
        raise HTTPException(status_code=403, detail=f"Timestamp drift {drift}s exceeds maximum allowed {MAX_DRIFT}s")

def validate_nonce(nonce: Optional[str]) -> None:
    if not nonce:
        logger.warning("Nonce missing in webhook request")
        return
    if nonce in NONCE_WINDOW:
        raise HTTPException(status_code=429, detail="Duplicate nonce detected")
    NONCE_WINDOW.append(nonce)

def verify_ip(source_ip: str) -> None:
    try:
        client_addr = ipaddress.ip_address(source_ip)
        if not any(client_addr in net for net in ALLOWED_NETWORKS):
            raise HTTPException(status_code=403, detail="Source IP not in allowlist")
    except ValueError:
        raise HTTPException(status_code=400, detail="Invalid IP address format")

def verify_hmac_signature(body_bytes: bytes, headers: AuthHeaders) -> None:
    secret = os.getenv("COGNIGY_WEBHOOK_SECRET")
    if not secret:
        raise HTTPException(status_code=500, detail="Webhook secret not configured")
    payload_to_sign = f"{headers.timestamp}{body_bytes.decode('utf-8')}".encode('utf-8')
    computed_signature = hmac.new(secret.encode('utf-8'), payload_to_sign, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(computed_signature, headers.signature):
        raise HTTPException(status_code=401, detail="Signature verification failed")

async def emit_siem_event(webhook_id: str, source_ip: str, status: str, latency_ms: float) -> None:
    siem_url = os.getenv("SIEM_ENDPOINT_URL")
    if not siem_url:
        return
    event_payload = {
        "event_type": "cognigy_webhook_auth",
        "webhook_id": webhook_id,
        "source_ip": source_ip,
        "status": status,
        "latency_ms": round(latency_ms, 2),
        "timestamp": datetime.now(timezone.utc).isoformat()
    }
    try:
        async with httpx.AsyncClient(timeout=5.0) as client:
            await client.post(siem_url, json=event_payload, headers={"Content-Type": "application/json"})
    except httpx.RequestError as e:
        structlog_logger.error("SIEM callback failed", error=str(e))

async def validate_cognigy_request(request: Request, body_bytes: bytes) -> AuthHeaders:
    metrics["total_requests"] += 1
    headers = extract_security_headers(request)
    verify_ip(headers.source_ip)
    enforce_timestamp_drift(headers.timestamp)
    validate_nonce(headers.nonce)
    verify_hmac_signature(body_bytes, headers)
    metrics["success_count"] += 1
    structlog_logger.info("Audit: Webhook authenticated", webhook_id=headers.webhook_id, source_ip=headers.source_ip, nonce=headers.nonce)
    await emit_siem_event(headers.webhook_id, headers.source_ip, "success", 0.0)
    return headers

@app.post("/webhooks/cognigy")
async def receive_webhook(request: Request) -> JSONResponse:
    start_time = time.perf_counter()
    try:
        body_bytes = await request.body()
        if not body_bytes:
            raise HTTPException(status_code=400, detail="Empty request body")
        auth_result = await validate_cognigy_request(request, body_bytes)
        latency_ms = (time.perf_counter() - start_time) * 1000
        logger.info("Webhook authenticated", latency_ms=latency_ms, webhook_id=auth_result.webhook_id)
        return JSONResponse(status_code=200, content={"status": "verified", "message": "Payload accepted"})
    except HTTPException:
        metrics["failure_count"] += 1
        raise
    except Exception as e:
        metrics["failure_count"] += 1
        logger.error("Authentication pipeline failed", error=str(e))
        raise HTTPException(status_code=500, detail="Internal validation error")

@app.get("/metrics")
def get_metrics():
    return metrics

Common Errors & Debugging

Error: 401 Unauthorized

  • Cause: The HMAC signature does not match the computed hash. This occurs when the shared secret differs between Cognigy.AI and your environment, or when the payload string concatenation order is incorrect.
  • Fix: Verify that COGNIGY_WEBHOOK_SECRET matches exactly. Ensure the signed payload follows the pattern timestamp + raw_body. Print the computed signature temporarily for comparison.
  • Code Fix: Ensure payload_to_sign construction matches Cognigy specification exactly. Use hmac.compare_digest to prevent timing attacks.

Error: 403 Forbidden

  • Cause: Source IP is blocked by the allowlist, or timestamp drift exceeds the configured threshold.
  • Fix: Add the Cognigy.AI outbound IP range to COGNIGY_ALLOWED_IPS. If using a reverse proxy, ensure X-Forwarded-For is correctly parsed. Adjust MAX_TIMESTAMP_DRIFT_SECONDS if your server clock is unsynchronized.
  • Code Fix: Enable NTP synchronization on the host. Expand the allowlist CIDR ranges if Cognigy uses dynamic egress IPs.

Error: 429 Too Many Requests

  • Cause: Duplicate nonce detected within the sliding window. Cognigy retrains failed deliveries, which can trigger duplicate validation attempts.
  • Fix: Increase the nonce window size or implement a persistent backing store for nonces in high-throughput environments. Configure Cognigy retry intervals to exceed your deduplication window.
  • Code Fix: Replace deque with Redis or PostgreSQL-backed nonce tracking for production scaling.

Error: 500 Internal Server Error

  • Cause: Missing environment variables, SIEM endpoint unreachable, or unhandled exception in the validation pipeline.
  • Fix: Validate all required environment variables at startup. Wrap SIEM callbacks in try-except blocks to prevent webhook rejection due to downstream failures.
  • Code Fix: Add startup health checks that verify secret configuration and network connectivity before accepting traffic.

Official References