Overview
ASP.NET Core's minimal APIs and System.Threading.Channels provide a performant pipeline for processing ModelRiver event-driven AI webhooks. Use middleware for signature verification, channels for background processing, and HttpClient for callbacks.
What you'll build:
- A minimal API endpoint for receiving webhooks
- HMAC signature verification middleware
- Background processing with
IHostedServiceandChannel<T> HttpClientcallback to ModelRiver
Quick start
Create project
Bash
dotnet new web -n MyAiAppcd MyAiAppConfiguration
JSON
1// appsettings.json2{3 "ModelRiver": {4 "ApiKey": "mr_live_YOUR_API_KEY",5 "WebhookSecret": "your_webhook_secret",6 "BaseUrl": "https://api.modelriver.com"7 }8}CSHARP
1// ModelRiverOptions.cs2public class ModelRiverOptions3{4 public string ApiKey { get; set; } = "";5 public string WebhookSecret { get; set; } = "";6 public string BaseUrl { get; set; } = "https://api.modelriver.com";7}Webhook endpoint
CSHARP
1// Program.cs2using System.Security.Cryptography;3using System.Text;4using System.Text.Json;5using System.Threading.Channels;6 7var builder = WebApplication.CreateBuilder(args);8 9builder.Services.Configure<ModelRiverOptions>(10 builder.Configuration.GetSection("ModelRiver"));11 12builder.Services.AddHttpClient("ModelRiver");13 14// Background processing channel15var channel = Channel.CreateUnbounded<WebhookJob>();16builder.Services.AddSingleton(channel);17builder.Services.AddHostedService<WebhookProcessor>();18 19var app = builder.Build();20 21app.MapPost("/webhooks/modelriver", async (22 HttpContext context,23 Channel<WebhookJob> jobChannel,24 IConfiguration config) =>25{26 // 1. Read raw body27 using var reader = new StreamReader(context.Request.Body);28 var rawBody = await reader.ReadToEndAsync();29 30 // 2. Verify signature31 var signature = context.Request.Headers["mr-signature"].FirstOrDefault() ?? "";32 var secret = config["ModelRiver:WebhookSecret"] ?? "";33 34 using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));35 var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(rawBody));36 var expected = Convert.ToHexString(hash).ToLower();37 38 if (!CryptographicOperations.FixedTimeEquals(39 Encoding.UTF8.GetBytes(expected),40 Encoding.UTF8.GetBytes(signature)))41 {42 return Results.Unauthorized();43 }44 45 // 3. Parse payload46 var payload = JsonSerializer.Deserialize<WebhookPayload>(rawBody,47 new JsonSerializerOptions { PropertyNameCaseInsensitive = true });48 49 if (payload is null)50 return Results.BadRequest();51 52 // 4. Handle event-driven workflow53 if (payload.Type == "task.ai_generated" && payload.CallbackUrl is not null)54 {55 await jobChannel.Writer.WriteAsync(new WebhookJob56 {57 Event = payload.Event ?? "",58 AiResponse = payload.AiResponse,59 CallbackUrl = payload.CallbackUrl,60 CustomerData = payload.CustomerData ?? new(),61 });62 63 return Results.Ok(new { received = true });64 }65 66 return Results.Ok(new { received = true });67});68 69app.Run();70 71// --- Models ---72 73public record WebhookPayload74{75 public string Type { get; init; } = "";76 public string? Event { get; init; }77 public string ChannelId { get; init; } = "";78 public AiResponseData? AiResponse { get; init; }79 public string? CallbackUrl { get; init; }80 public bool? CallbackRequired { get; init; }81 public Dictionary<string, object>? CustomerData { get; init; }82 public string? Timestamp { get; init; }83}84 85public record AiResponseData86{87 public Dictionary<string, object> Data { get; init; } = new();88}89 90public record WebhookJob91{92 public string Event { get; init; } = "";93 public AiResponseData? AiResponse { get; init; }94 public string CallbackUrl { get; init; } = "";95 public Dictionary<string, object> CustomerData { get; init; } = new();96}Background processor
CSHARP
1// WebhookProcessor.cs2using System.Text.Json;3using System.Threading.Channels;4using Microsoft.Extensions.Options;5 6public class WebhookProcessor : BackgroundService7{8 private readonly Channel<WebhookJob> _channel;9 private readonly IHttpClientFactory _httpClientFactory;10 private readonly ModelRiverOptions _options;11 private readonly ILogger<WebhookProcessor> _logger;12 13 public WebhookProcessor(14 Channel<WebhookJob> channel,15 IHttpClientFactory httpClientFactory,16 IOptions<ModelRiverOptions> options,17 ILogger<WebhookProcessor> logger)18 {19 _channel = channel;20 _httpClientFactory = httpClientFactory;21 _options = options.Value;22 _logger = logger;23 }24 25 protected override async Task ExecuteAsync(CancellationToken stoppingToken)26 {27 await foreach (var job in _channel.Reader.ReadAllAsync(stoppingToken))28 {29 try30 {31 await ProcessJobAsync(job, stoppingToken);32 }33 catch (Exception ex)34 {35 _logger.LogError(ex, "Failed to process webhook job for event: {Event}", job.Event);36 }37 }38 }39 40 private async Task ProcessJobAsync(WebhookJob job, CancellationToken ct)41 {42 var client = _httpClientFactory.CreateClient("ModelRiver");43 client.DefaultRequestHeaders.Add("Authorization", $"Bearer {_options.ApiKey}");44 45 var enrichedData = job.AiResponse?.Data ?? new Dictionary<string, object>();46 47 // Your custom business logic48 if (job.Event == "content_ready")49 {50 enrichedData["processed"] = true;51 enrichedData["saved_at"] = DateTime.UtcNow.ToString("O");52 }53 54 // Call back to ModelRiver55 var callback = new56 {57 data = enrichedData,58 task_id = $"dotnet_{job.Event}_{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}",59 metadata = new60 {61 processed_by = "dotnet",62 processed_at = DateTime.UtcNow.ToString("O"),63 },64 };65 66 var content = new StringContent(67 JsonSerializer.Serialize(callback),68 System.Text.Encoding.UTF8,69 "application/json");70 71 var response = await client.PostAsync(job.CallbackUrl, content, ct);72 73 if (response.IsSuccessStatusCode)74 {75 _logger.LogInformation("✅ Callback sent for event: {Event}", job.Event);76 }77 else78 {79 _logger.LogError("❌ Callback failed ({Status})", response.StatusCode);80 81 // Send error callback82 var errorContent = new StringContent(83 JsonSerializer.Serialize(new { error = "processing_failed", message = $"HTTP {response.StatusCode}" }),84 System.Text.Encoding.UTF8,85 "application/json");86 87 await client.PostAsync(job.CallbackUrl, errorContent, ct);88 }89 }90}Triggering async requests
CSHARP
1// ModelRiverService.cs2public class ModelRiverService3{4 private readonly HttpClient _client;5 private readonly ModelRiverOptions _options;6 7 public ModelRiverService(IHttpClientFactory factory, IOptions<ModelRiverOptions> options)8 {9 _client = factory.CreateClient("ModelRiver");10 _options = options.Value;11 _client.DefaultRequestHeaders.Add("Authorization", $"Bearer {_options.ApiKey}");12 }13 14 public async Task<AsyncAiResponse> TriggerAsync(string workflow, string prompt)15 {16 var response = await _client.PostAsJsonAsync($"{_options.BaseUrl}/v1/ai/async", new17 {18 workflow,19 messages = new[] { new { role = "user", content = prompt } },20 });21 22 response.EnsureSuccessStatusCode();23 return await response.Content.ReadFromJsonAsync<AsyncAiResponse>()24 ?? throw new Exception("Failed to parse response");25 }26}27 28public record AsyncAiResponse(29 string ChannelId,30 string WsToken,31 string WebsocketUrl,32 string WebsocketChannel33);Best practices
- Use
Channel<T>: High-performance in-process queue for background processing. - Use
CryptographicOperations.FixedTimeEquals: Constant-time comparison for signatures. - Use
IHttpClientFactory: Proper connection pooling and lifecycle management. - Use
IHostedService: Long-running background processor that starts with the app. - Read raw body: Read the stream before model binding for signature verification.
Next steps
- Back to Backend Frameworks: All framework guides
- Webhooks reference: Retry policies and delivery monitoring
- Event-driven AI overview: Architecture and flow