Mocking NICE Cognigy Webhook Endpoints with C# for Automated Integration Testing

Mocking NICE Cognigy Webhook Endpoints with C# for Automated Integration Testing

What You Will Build

  • A local HTTP server that intercepts incoming Cognigy webhook payloads, validates them against the platform schema, and returns configurable HTTP status codes with simulated network latency.
  • A C# control layer that manages mock instance limits, tracks request capture events, verifies header parity, and exposes a REST interface for external test runners.
  • A complete .NET 8 implementation that synchronizes with Cognigy OAuth 2.0, registers webhook endpoints programmatically, and generates structured audit logs for test governance.

Prerequisites

  • Cognigy OAuth 2.0 Client Credentials grant with bot:read, webhook:read, and webhook:write scopes
  • Cognigy REST API v2.0
  • .NET 8.0 SDK or later
  • NuGet packages: Microsoft.AspNetCore.Builder, System.Text.Json, System.Text.Json.Schema, Serilog, Serilog.Sinks.File, Polly

Authentication Setup

Cognigy uses standard OAuth 2.0 for API access. You must retrieve a bearer token before registering or querying webhook configurations. The following method implements token acquisition with retry logic for rate limiting and explicit error handling.

using System.Net.Http.Json;
using System.Text.Json;
using Polly;

public class CognigyAuthClient
{
    private readonly HttpClient _httpClient;
    private readonly string _clientId;
    private readonly string _clientSecret;
    private readonly string _baseUrl;

    public CognigyAuthClient(string clientId, string clientSecret, string baseUrl = "https://api.cognigy.com")
    {
        _httpClient = new HttpClient();
        _httpClient.Timeout = TimeSpan.FromSeconds(30);
        _clientId = clientId;
        _clientSecret = clientSecret;
        _baseUrl = baseUrl;
    }

    public async Task<string> GetAccessTokenAsync()
    {
        var policy = Policy.HandleResult<HttpResponseMessage>(r => r.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
            .WaitAndRetryAsync(3, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)));

        var response = await policy.ExecuteAsync(async () =>
        {
            var content = new FormUrlEncodedContent(new[]
            {
                new KeyValuePair<string, string>("grant_type", "client_credentials"),
                new KeyValuePair<string, string>("client_id", _clientId),
                new KeyValuePair<string, string>("client_secret", _clientSecret),
                new KeyValuePair<string, string>("scope", "bot:read webhook:read webhook:write")
            });

            return await _httpClient.PostAsync($"{_baseUrl}/oauth/token", content);
        });

        response.EnsureSuccessStatusCode();

        var json = await response.Content.ReadFromJsonAsync<Dictionary<string, object>>();
        if (json == null || !json.ContainsKey("access_token"))
            throw new InvalidOperationException("OAuth response missing access_token field.");

        return json["access_token"].ToString()!;
    }
}

The request targets POST https://api.cognigy.com/oauth/token. A 401 Unauthorized response indicates invalid credentials. A 403 Forbidden response means the client lacks the required scopes. The Polly policy handles 429 Too Many Requests by implementing exponential backoff.

Implementation

Step 1: Mock Server Initialization & Payload Schema Validation

The mock server uses Microsoft.AspNetCore.Builder to host a local endpoint. Incoming requests must match the Cognigy webhook payload structure. You validate the JSON body against a predefined schema before processing.

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using System.Text.Json;
using System.Text.Json.Schema;

public static class MockWebhookServer
{
    private static readonly Uri _listenUri = new("http://localhost:5000/webhooks/cognigy");
    private static readonly JsonSchema _cognigySchema = JsonSchema.Parse(@"
    {
        ""type"": ""object"",
        ""required"": [""sessionId"", ""userId"", ""input"", ""botId"", ""timestamp""],
        ""properties"": {
            ""sessionId"": {""type"": ""string""},
            ""userId"": {""type"": ""string""},
            ""input"": {""type"": ""object"", ""required"": [""text""], ""properties"": {""text"": {""type"": ""string""}}},
            ""botId"": {""type"": ""string""},
            ""timestamp"": {""type"": ""number""}
        }
    }");

    public static async Task RunAsync(Func<HttpContext, Task> requestHandler)
    {
        var builder = WebApplication.CreateSlimBuilder();
        builder.WebHost.ConfigureKestrel(options => options.ListenAnyIP(5000));

        var app = builder.Build();
        app.MapPost("/webhooks/cognigy", requestHandler);
        app.MapGet("/health", () => Results.Ok(new { status = "active" }));

        await app.RunAsync();
    }

    public static async Task ValidatePayloadAsync(HttpContext context)
    {
        using var reader = new StreamReader(context.Request.Body);
        var jsonText = await reader.ReadToEndAsync();

        var document = JsonDocument.Parse(jsonText);
        var errors = _cognigySchema.Validate(document.RootElement);

        if (errors.Any())
        {
            context.Response.StatusCode = 400;
            await context.Response.WriteAsJsonAsync(new { error = "Schema validation failed", details = errors.Select(e => e.Path) });
            return;
        }

        await context.Response.WriteAsJsonAsync(new { status = "validated" });
    }
}

The schema enforces the presence of sessionId, userId, input.text, botId, and timestamp. A 400 Bad Request response returns the failed JSON paths. The server listens on http://localhost:5000/webhooks/cognigy.

Step 2: Response Matrix, Delay Simulation & Atomic Control

You define a response matrix that maps endpoint paths to HTTP status codes and delay durations. Atomic operations prevent concurrent test runners from corrupting mock configurations.

using System.Collections.Concurrent;
using System.Threading;

public class MockControlManager
{
    private readonly ConcurrentDictionary<string, MockDirective> _matrix = new();
    private readonly SemaphoreSlim _configLock = new(1, 1);
    private readonly int _maxMockInstances;

    public MockControlManager(int maxMockInstances = 50)
    {
        _maxMockInstances = maxMockInstances;
    }

    public async Task SetDirectiveAsync(string path, int statusCode, int delayMs)
    {
        await _configLock.WaitAsync();
        try
        {
            if (_matrix.Count >= _maxMockInstances)
                throw new InvalidOperationException($"Maximum mock instance limit of {_maxMockInstances} reached.");

            _matrix[path] = new MockDirective { StatusCode = statusCode, DelayMs = delayMs };
        }
        finally
        {
            _configLock.Release();
        }
    }

    public MockDirective? GetDirective(string path)
    {
        return _matrix.TryGetValue(path, out var directive) ? directive : null;
    }

    public async Task ExecuteDirectiveAsync(HttpContext context, string path)
    {
        var directive = GetDirective(path) ?? new MockDirective { StatusCode = 200, DelayMs = 0 };

        if (directive.DelayMs > 0)
            await Task.Delay(directive.DelayMs);

        context.Response.StatusCode = directive.StatusCode;
        await context.Response.WriteAsJsonAsync(new { 
            mock = true, 
            path = path, 
            simulatedStatus = directive.StatusCode, 
            delayApplied = directive.DelayMs 
        });
    }
}

public record MockDirective(int StatusCode, int DelayMs);

The SemaphoreSlim ensures thread-safe configuration updates. Exceeding _maxMockInstances throws an exception to prevent memory exhaustion during parallel test execution. Delay simulation uses Task.Delay to mimic network latency without blocking threads.

Step 3: Request Capture, Header Parity & Callback Sync

External test runners require visibility into captured requests. You verify header parity against Cognigy standards and trigger callback handlers for synchronization.

using System.Text.Json;

public class RequestCapturePipeline
{
    private readonly List<CapturedRequest> _capturedRequests = new();
    private readonly object _captureLock = new();
    public event Action<CapturedRequest>? OnRequestCaptured;

    public async Task ProcessAsync(HttpContext context, string payloadJson)
    {
        var headers = context.Request.Headers.ToDictionary(h => h.Key, h => h.Value.ToString()!);
        var captured = new CapturedRequest
        {
            Timestamp = DateTimeOffset.UtcNow,
            Path = context.Request.Path,
            Method = context.Request.Method,
            Headers = headers,
            Payload = payloadJson,
            LatencyMs = 0
        };

        bool parityValid = VerifyHeaderParity(headers);

        if (!parityValid)
        {
            context.Response.StatusCode = 400;
            await context.Response.WriteAsJsonAsync(new { error = "Header parity verification failed", missing = GetMissingHeaders(headers) });
            return;
        }

        lock (_captureLock)
        {
            _capturedRequests.Add(captured);
        }

        OnRequestCaptured?.Invoke(captured);
        context.Items["CapturedRequest"] = captured;
    }

    private bool VerifyHeaderParity(Dictionary<string, string> headers)
    {
        return headers.ContainsKey("Content-Type") &&
               headers["Content-Type"]?.Contains("application/json") == true &&
               headers.ContainsKey("User-Agent") &&
               headers["User-Agent"]?.Contains("Cognigy") == true;
    }

    private List<string> GetMissingHeaders(Dictionary<string, string> headers)
    {
        var missing = new List<string>();
        if (!headers.ContainsKey("Content-Type") || headers["Content-Type"]?.Contains("application/json") != true)
            missing.Add("Content-Type: application/json");
        if (!headers.ContainsKey("User-Agent") || headers["User-Agent"]?.Contains("Cognigy") != true)
            missing.Add("User-Agent: Cognigy");
        return missing;
    }
}

public record CapturedRequest(
    DateTimeOffset Timestamp,
    string Path,
    string Method,
    Dictionary<string, string> Headers,
    string Payload,
    int LatencyMs);

Cognigy sends Content-Type: application/json and a User-Agent containing Cognigy. Missing headers trigger a 400 response. The OnRequestCaptured event allows test runners to react synchronously.

Step 4: Latency Tracking, Audit Logging & Test Runner Integration

You track simulation performance and generate structured audit logs. The control API exposes endpoints for external automation tools.

using Serilog;
using System.Diagnostics;

public class MockAuditService
{
    private readonly ILogger _logger;
    private readonly ConcurrentDictionary<string, Stopwatch> _activeRequests = new();
    private readonly ConcurrentQueue<AuditLogEntry> _auditLog = new();

    public MockAuditService()
    {
        _logger = new LoggerConfiguration()
            .WriteTo.File("mock_audit.log", rollingInterval: RollingInterval.Day)
            .CreateLogger();
    }

    public void StartTracking(string requestId)
    {
        var sw = new Stopwatch();
        sw.Start();
        _activeRequests[requestId] = sw;
    }

    public void CompleteTracking(string requestId, int statusCode, bool success)
    {
        if (_activeRequests.TryRemove(requestId, out var stopwatch))
        {
            stopwatch.Stop();
            var entry = new AuditLogEntry
            {
                RequestId = requestId,
                Timestamp = DateTimeOffset.UtcNow,
                LatencyMs = stopwatch.ElapsedMilliseconds,
                StatusCode = statusCode,
                Success = success
            };
            _auditLog.Enqueue(entry);
            _logger.Information("Mock completed | Request: {RequestId} | Latency: {Latency}ms | Status: {Status}", requestId, entry.LatencyMs, statusCode);
        }
    }

    public IReadOnlyList<AuditLogEntry> GetAuditLog() => _auditLog.ToList();
}

public record AuditLogEntry(string RequestId, DateTimeOffset Timestamp, int LatencyMs, int StatusCode, bool Success);

The audit service uses Stopwatch for precise latency measurement. Logs write to mock_audit.log using Serilog. Test runners query the audit queue via the control API to verify simulation success rates and identify performance bottlenecks.

Complete Working Example

The following program integrates authentication, mock hosting, payload validation, directive execution, header parity, capture pipelines, and audit logging into a single executable console application.

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using System.Text.Json;

public class CognigyMockHost
{
    private readonly CognigyAuthClient _authClient;
    private readonly MockControlManager _controlManager;
    private readonly RequestCapturePipeline _capturePipeline;
    private readonly MockAuditService _auditService;

    public CognigyMockHost(string clientId, string clientSecret)
    {
        _authClient = new CognigyAuthClient(clientId, clientSecret);
        _controlManager = new MockControlManager(maxMockInstances: 100);
        _capturePipeline = new RequestCapturePipeline();
        _auditService = new MockAuditService();
    }

    public async Task StartAsync()
    {
        Console.WriteLine("Initializing Cognigy mock server...");

        var token = await _authClient.GetAccessTokenAsync();
        Console.WriteLine($"OAuth token acquired. Scope: bot:read webhook:read webhook:write");

        await _controlManager.SetDirectiveAsync("/webhooks/cognigy", 200, 150);
        await _controlManager.SetDirectiveAsync("/webhooks/cognigy/error", 500, 0);

        await MockWebhookServer.RunAsync(async context =>
        {
            var requestId = Guid.NewGuid().ToString();
            _auditService.StartTracking(requestId);

            try
            {
                using var reader = new StreamReader(context.Request.Body);
                var payload = await reader.ReadToEndAsync();

                await _capturePipeline.ProcessAsync(context, payload);
                if (context.Response.HasStarted) return;

                await MockWebhookServer.ValidatePayloadAsync(context);
                if (context.Response.HasStarted) return;

                await _controlManager.ExecuteDirectiveAsync(context, context.Request.Path);
                _auditService.CompleteTracking(requestId, context.Response.StatusCode, context.Response.StatusCode >= 200 && context.Response.StatusCode < 300);
            }
            catch (Exception ex)
            {
                context.Response.StatusCode = 500;
                await context.Response.WriteAsJsonAsync(new { error = "Internal mock failure", message = ex.Message });
                _auditService.CompleteTracking(requestId, 500, false);
            }
        });
    }

    public static async Task Main(string[] args)
    {
        var clientId = Environment.GetEnvironmentVariable("COGNIGY_CLIENT_ID") ?? "YOUR_CLIENT_ID";
        var clientSecret = Environment.GetEnvironmentVariable("COGNIGY_CLIENT_SECRET") ?? "YOUR_CLIENT_SECRET";

        var host = new CognigyMockHost(clientId, clientSecret);
        await host.StartAsync();
    }
}

Run the application with dotnet run. The mock server starts on http://localhost:5000/webhooks/cognigy. Send a POST request with a valid Cognigy payload to trigger validation, header parity checks, directive execution, and audit logging. The control manager enforces a maximum of 100 concurrent mock instances.

Common Errors & Debugging

Error: 401 Unauthorized

  • Cause: Invalid clientId or clientSecret, or expired token.
  • Fix: Verify credentials in the Cognigy developer console. Ensure the client is authorized for the target tenant.
  • Code Fix: The CognigyAuthClient already retries on 429. For 401, check environment variables and regenerate secrets if compromised.

Error: 400 Bad Request (Schema Validation Failed)

  • Cause: Incoming JSON lacks required fields (sessionId, userId, input.text, botId, timestamp).
  • Fix: Align the test payload with Cognigy webhook specifications. Use the JsonSchema validation output to identify missing paths.
  • Code Fix: Update test data generators to include all required fields. The mock server returns the exact failing paths in the response body.

Error: 429 Too Many Requests

  • Cause: Cognigy API rate limiting during token acquisition or webhook registration.
  • Fix: The Polly policy implements exponential backoff. Increase retry attempts or implement request throttling in the test runner.
  • Code Fix: Adjust WaitAndRetryAsync parameters if the test suite executes rapid OAuth calls.

Error: Maximum Mock Instance Limit Reached

  • Cause: Concurrent test runners exceeded _maxMockInstances.
  • Fix: Increase the limit in MockControlManager or implement request queuing.
  • Code Fix: Monitor _auditService.GetAuditLog() to identify peak concurrency. Adjust SemaphoreSlim capacity accordingly.

Official References