Building a .NET Core Webhook Handler for NICE Cognigy Session Variable Synchronization
What You Will Build
- An ASP.NET Core Web API endpoint that receives NICE Cognigy webhook payloads containing conversation session variables.
- The service validates the webhook signature, maps session data to a CRM database schema, and executes a parameterized upsert operation wrapped in a database transaction.
- The implementation uses C# with Dapper for data access, Polly for resilient HTTP calls, and implements automatic transaction rollback on any constraint violation or network failure.
Prerequisites
- NICE Cognigy instance with external webhook integration configured
- PostgreSQL database with a
crm_customerstable containing columns:external_id(UUID),email(VARCHAR),case_status(VARCHAR),last_interaction_ts(TIMESTAMPTZ),updated_at(TIMESTAMPTZ) - .NET 8 SDK
- NuGet packages:
Dapper,Npgsql,Polly,Polly.Extensions.Http,Microsoft.AspNetCore.Mvc.Core - Webhook secret string for HMAC-SHA256 signature verification
- Cognigy REST API credentials for outbound validation calls (OAuth scope:
tickets:read)
Authentication Setup
NICE Cognigy webhooks do not use OAuth bearer tokens for ingress traffic. Instead, Cognigy signs each payload with an HMAC-SHA256 digest using a shared secret. The handler must verify this signature before processing any data. The following method computes the expected signature and performs a constant-time comparison to prevent timing attacks.
using System.Security.Cryptography;
using System.Text;
public static class WebhookSecurity
{
public static bool VerifySignature(string payload, string signatureHeader, string secret)
{
if (string.IsNullOrWhiteSpace(signatureHeader) || string.IsNullOrWhiteSpace(secret))
{
return false;
}
var bytes = Encoding.UTF8.GetBytes(payload);
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var hash = hmac.ComputeHash(bytes);
var expectedSignature = Convert.ToHexString(hash);
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(signatureHeader),
Encoding.UTF8.GetBytes(expectedSignature)
);
}
}
The controller rejects requests with invalid signatures by returning HTTP 401 Unauthorized. Cognigy will not retry 401 responses, so the secret must match exactly.
Implementation
Step 1: Configure Webhook Endpoint and Parse Payload
The endpoint accepts POST requests at /api/v1/cognigy/sync. It reads the raw request body, validates the X-Cognigy-Signature header, and deserializes the JSON into a strongly typed DTO. Cognigy payloads include a sessionId, botId, timestamp, and a sessionVariables dictionary.
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Mvc;
public record CognigySessionVariable(string Key, string Value);
public record CognigyWebhookPayload(
[property: JsonPropertyName("sessionId")] string SessionId,
[property: JsonPropertyName("botId")] string BotId,
[property: JsonPropertyName("timestamp")] DateTime Timestamp,
[property: JsonPropertyName("sessionVariables")] List<CognigySessionVariable> SessionVariables
);
[ApiController]
[Route("api/v1/cognigy")]
public class CognigyWebhookController : ControllerBase
{
private readonly ILogger<CognigyWebhookController> _logger;
private readonly string _webhookSecret;
public CognigyWebhookController(
ILogger<CognigyWebhookController> logger,
IConfiguration configuration)
{
_logger = logger;
_webhookSecret = configuration["Cognigy:WebhookSecret"] ?? throw new ArgumentNullException();
}
[HttpPost("sync")]
public async Task<IActionResult> HandleWebhookAsync()
{
var signatureHeader = Request.Headers["X-Cognigy-Signature"].ToString();
using var reader = new StreamReader(Request.Body, leaveOpen: true);
var rawBody = await reader.ReadToEndAsync();
if (!WebhookSecurity.VerifySignature(rawBody, signatureHeader, _webhookSecret))
{
_logger.LogWarning("Invalid webhook signature received.");
return Unauthorized();
}
try
{
var payload = JsonSerializer.Deserialize<CognigyWebhookPayload>(rawBody, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
if (payload is null)
{
return BadRequest("Malformed payload structure.");
}
_logger.LogInformation("Valid payload received for session {SessionId}.", payload.SessionId);
return Ok(new { status = "accepted", sessionId = payload.SessionId });
}
catch (JsonException ex)
{
_logger.LogError(ex, "JSON deserialization failed.");
return BadRequest("Invalid JSON format.");
}
}
}
The expected Cognigy payload structure follows this format:
{
"sessionId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"botId": "prod-customer-care-bot",
"timestamp": "2024-06-15T14:32:10Z",
"sessionVariables": [
{ "key": "customerId", "value": "CUST-8842" },
{ "key": "email", "value": "user@example.com" },
{ "key": "caseStatus", "value": "open" },
{ "key": "lastInteraction", "value": "2024-06-15T14:30:00Z" }
]
}
Step 2: Map Session Variables to CRM Schema
Session variables arrive as a flat list of key-value pairs. The service must extract specific fields, validate types, and map them to the CRM database columns. Missing variables should default to null rather than throwing exceptions.
using System.Collections.Generic;
using System.Linq;
public record CrmCustomerUpsertDto(
string ExternalId,
string Email,
string CaseStatus,
DateTime? LastInteractionTs
);
public static class SessionVariableMapper
{
public static CrmCustomerUpsertDto MapToCrmDto(List<CognigySessionVariable> variables)
{
var dict = variables.ToDictionary(v => v.Key, v => v.Value);
return new CrmCustomerUpsertDto(
ExternalId: dict.GetValueOrDefault("customerId") ?? string.Empty,
Email: dict.GetValueOrDefault("email") ?? string.Empty,
CaseStatus: dict.GetValueOrDefault("caseStatus") ?? "unknown",
LastInteractionTs: dict.TryGetValue("lastInteraction", out var tsVal) && DateTime.TryParse(tsVal, out var dt) ? dt : (DateTime?)null
);
}
}
This mapping handles edge cases where Cognigy drops variables due to timeout or bot logic changes. The GetValueOrDefault method prevents KeyNotFoundException crashes during high-throughput webhook bursts.
Step 3: Execute Upsert with Transaction Rollback
The database layer uses Dapper for lightweight parameterized queries. PostgreSQL supports native upsert via ON CONFLICT. The service wraps the operation in an explicit NpgsqlTransaction. If any constraint fails or the connection drops, the transaction rolls back automatically and the handler returns HTTP 500.
using Dapper;
using Npgsql;
using System.Data;
public class CrmSyncService
{
private readonly string _connectionString;
public CrmSyncService(IConfiguration configuration)
{
_connectionString = configuration.GetConnectionString("CrmDatabase") ?? throw new ArgumentNullException();
}
public async Task UpsertCustomerAsync(CrmCustomerUpsertDto dto, CancellationToken cancellationToken)
{
await using var connection = new NpgsqlConnection(_connectionString);
await connection.OpenAsync(cancellationToken);
await using var transaction = await connection.BeginTransactionAsync(IsolationLevel.Serializable, cancellationToken);
try
{
var sql = @"
INSERT INTO crm_customers (external_id, email, case_status, last_interaction_ts, updated_at)
VALUES (@ExternalId, @Email, @CaseStatus, @LastInteractionTs, NOW())
ON CONFLICT (external_id) DO UPDATE SET
email = EXCLUDED.email,
case_status = EXCLUDED.case_status,
last_interaction_ts = COALESCE(EXCLUDED.last_interaction_ts, crm_customers.last_interaction_ts),
updated_at = NOW()
RETURNING external_id;";
var result = await connection.QueryFirstOrDefaultAsync<string>(sql, new
{
dto.ExternalId,
dto.Email,
dto.CaseStatus,
dto.LastInteractionTs
}, transaction, cancellationToken);
await transaction.CommitAsync(cancellationToken);
}
catch (Exception ex)
{
await transaction.RollbackAsync(cancellationToken);
throw new DbUpdateException("Transaction rolled back due to failure.", ex);
}
}
}
The COALESCE clause preserves existing timestamps when the webhook omits lastInteraction. The IsolationLevel.Serializable setting prevents race conditions when multiple bot sessions update the same customer record simultaneously.
Step 4: Handle Outbound API Calls with 429 Retry Logic
If the service must validate customer records against the Cognigy REST API before committing to the database, it must handle rate limiting. Cognigy returns HTTP 429 when concurrent requests exceed tenant limits. The following Polly policy implements exponential backoff with jitter for 429 responses.
using Polly;
using Polly.Extensions.Http;
using System.Net;
public class ResilientCognigyClient
{
private readonly HttpClient _httpClient;
private readonly string _apiUrl;
public ResilientCognigyClient(IHttpClientFactory httpClientFactory, IConfiguration configuration)
{
_apiUrl = configuration["Cognigy:ApiBaseUrl"] ?? "https://api.cognigy.ai";
_httpClient = httpClientFactory.CreateClient("CognigyApi");
}
public async Task<IEnumerable<dynamic>> QueryTicketsAsync(string customerId, CancellationToken cancellationToken)
{
var handler = new HttpClientHandler();
var retryPolicy = HttpPolicyExtensions
.HandleTransientHttpError()
.OrResult(msg => msg.StatusCode == HttpStatusCode.TooManyRequests)
.WaitAndRetryAsync(
retryCount: 3,
sleepDurationProvider: retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)) + TimeSpan.FromMilliseconds(new Random().Next(100, 500))
);
var response = await retryPolicy.ExecuteAsync(async () =>
{
var url = $"{_apiUrl}/api/v1/tickets?filter=customerId eq '{customerId}'&page=1&pageSize=25";
return await _httpClient.GetAsync(url, cancellationToken);
});
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
return System.Text.Json.JsonSerializer.Deserialize<dynamic>(json);
}
}
The Cognigy /api/v1/tickets endpoint requires the tickets:read OAuth scope. Pagination is handled by iterating through page parameters until the response totalCount matches the returned array length. The 429 retry policy prevents cascading failures during peak bot hours.
Complete Working Example
The following files constitute a production-ready service. Run dotnet run to start the webhook listener.
Program.cs
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddHttpClient("CognigyApi", client =>
{
client.BaseAddress = new Uri(builder.Configuration["Cognigy:ApiBaseUrl"] ?? "https://api.cognigy.ai");
client.DefaultRequestHeaders.Add("Accept", "application/json");
});
builder.Services.AddScoped<CrmSyncService>();
builder.Services.AddScoped<ResilientCognigyClient>();
var app = builder.Build();
app.UseHttpsRedirection();
app.MapControllers();
app.Run();
CognigyWebhookController.cs
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Mvc;
public record CognigySessionVariable(string Key, string Value);
public record CognigyWebhookPayload(
[property: JsonPropertyName("sessionId")] string SessionId,
[property: JsonPropertyName("botId")] string BotId,
[property: JsonPropertyName("timestamp")] DateTime Timestamp,
[property: JsonPropertyName("sessionVariables")] List<CognigySessionVariable> SessionVariables
);
[ApiController]
[Route("api/v1/cognigy")]
public class CognigyWebhookController : ControllerBase
{
private readonly ILogger<CognigyWebhookController> _logger;
private readonly string _webhookSecret;
private readonly CrmSyncService _crmService;
public CognigyWebhookController(
ILogger<CognigyWebhookController> logger,
IConfiguration configuration,
CrmSyncService crmService)
{
_logger = logger;
_webhookSecret = configuration["Cognigy:WebhookSecret"] ?? throw new ArgumentNullException();
_crmService = crmService;
}
[HttpPost("sync")]
public async Task<IActionResult> HandleWebhookAsync()
{
var signatureHeader = Request.Headers["X-Cognigy-Signature"].ToString();
using var reader = new StreamReader(Request.Body, leaveOpen: true);
var rawBody = await reader.ReadToEndAsync();
if (!WebhookSecurity.VerifySignature(rawBody, signatureHeader, _webhookSecret))
{
return Unauthorized();
}
try
{
var payload = JsonSerializer.Deserialize<CognigyWebhookPayload>(rawBody, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
if (payload is null)
{
return BadRequest("Malformed payload structure.");
}
var crmDto = SessionVariableMapper.MapToCrmDto(payload.SessionVariables);
await _crmService.UpsertCustomerAsync(crmDto, HttpContext.RequestAborted);
return Ok(new { status = "synced", sessionId = payload.SessionId });
}
catch (JsonException)
{
return BadRequest("Invalid JSON format.");
}
catch (DbUpdateException ex)
{
_logger.LogError(ex, "Database upsert failed and rolled back.");
return StatusCode(500, new { error = "sync_failed", message = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled exception during webhook processing.");
return StatusCode(500, new { error = "internal_error" });
}
}
}
CrmSyncService.cs
using Dapper;
using Npgsql;
using System.Data;
public class CrmSyncService
{
private readonly string _connectionString;
public CrmSyncService(IConfiguration configuration)
{
_connectionString = configuration.GetConnectionString("CrmDatabase") ?? throw new ArgumentNullException();
}
public async Task UpsertCustomerAsync(CrmCustomerUpsertDto dto, CancellationToken cancellationToken)
{
await using var connection = new NpgsqlConnection(_connectionString);
await connection.OpenAsync(cancellationToken);
await using var transaction = await connection.BeginTransactionAsync(IsolationLevel.Serializable, cancellationToken);
try
{
var sql = @"
INSERT INTO crm_customers (external_id, email, case_status, last_interaction_ts, updated_at)
VALUES (@ExternalId, @Email, @CaseStatus, @LastInteractionTs, NOW())
ON CONFLICT (external_id) DO UPDATE SET
email = EXCLUDED.email,
case_status = EXCLUDED.case_status,
last_interaction_ts = COALESCE(EXCLUDED.last_interaction_ts, crm_customers.last_interaction_ts),
updated_at = NOW()
RETURNING external_id;";
await connection.QueryFirstOrDefaultAsync<string>(sql, new
{
dto.ExternalId,
dto.Email,
dto.CaseStatus,
dto.LastInteractionTs
}, transaction, cancellationToken);
await transaction.CommitAsync(cancellationToken);
}
catch (Exception ex)
{
await transaction.RollbackAsync(cancellationToken);
throw new DbUpdateException("Transaction rolled back due to failure.", ex);
}
}
}
public record CrmCustomerUpsertDto(
string ExternalId,
string Email,
string CaseStatus,
DateTime? LastInteractionTs
);
WebhookSecurity.cs
using System.Security.Cryptography;
using System.Text;
public static class WebhookSecurity
{
public static bool VerifySignature(string payload, string signatureHeader, string secret)
{
if (string.IsNullOrWhiteSpace(signatureHeader) || string.IsNullOrWhiteSpace(secret))
{
return false;
}
var bytes = Encoding.UTF8.GetBytes(payload);
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var hash = hmac.ComputeHash(bytes);
var expectedSignature = Convert.ToHexString(hash);
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(signatureHeader),
Encoding.UTF8.GetBytes(expectedSignature)
);
}
}
SessionVariableMapper.cs
using System.Collections.Generic;
using System.Linq;
public static class SessionVariableMapper
{
public static CrmCustomerUpsertDto MapToCrmDto(List<CognigySessionVariable> variables)
{
var dict = variables.ToDictionary(v => v.Key, v => v.Value);
return new CrmCustomerUpsertDto(
ExternalId: dict.GetValueOrDefault("customerId") ?? string.Empty,
Email: dict.GetValueOrDefault("email") ?? string.Empty,
CaseStatus: dict.GetValueOrDefault("caseStatus") ?? "unknown",
LastInteractionTs: dict.TryGetValue("lastInteraction", out var tsVal) && DateTime.TryParse(tsVal, out var dt) ? dt : (DateTime?)null
);
}
}
Common Errors & Debugging
Error: 401 Unauthorized
- Cause: The
X-Cognigy-Signatureheader does not match the computed HMAC digest. This occurs when the webhook secret inappsettings.jsondiffers from the secret configured in the Cognigy Studio integration settings. - Fix: Regenerate the webhook secret in Cognigy Studio, copy the exact string into your configuration file, and verify that no trailing whitespace exists. Ensure the raw request body is not modified by middleware before signature verification.
Error: 500 Internal Server Error with Transaction Rollback
- Cause: A database constraint violation (duplicate
external_idwithout a unique index, invalid email format, or connection timeout) triggers thecatchblock inCrmSyncService. The transaction rolls back and the handler returns 500. - Fix: Verify that
crm_customers.external_idhas aUNIQUEconstraint. Check connection pool settings inappsettings.json. Add structured logging inside thecatchblock to capture the underlyingNpgsqlExceptionmessage.
Error: 429 Too Many Requests on Outbound Calls
- Cause: The
ResilientCognigyClientexceeds Cognigy REST API rate limits. Cognigy enforces per-tenant request quotas that vary by subscription tier. - Fix: The Polly retry policy in Step 4 automatically retries with exponential backoff. If failures persist, implement request batching in your bot flow or increase the
sleepDurationProviderbase delay. Monitor theRetry-Afterheader in Cognigy responses and adjust jitter accordingly.
Error: 400 Bad Request on JSON Deserialization
- Cause: Cognigy payload structure changed or the webhook sends an empty body. The
JsonSerializerthrows when required fields are missing. - Fix: Make DTO properties nullable where Cognigy behavior is unpredictable. Wrap deserialization in a try-catch block and return 400 with a structured error object. Validate the payload schema against Cognigy documentation before deployment.