Documentation

Event-driven AI with .NET

Receive AI webhooks in ASP.NET Core minimal APIs, process with hosted services or channels, and call back to ModelRiver with HttpClient.

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 IHostedService and Channel<T>
  • HttpClient callback to ModelRiver

Quick start

Create project

Bash
dotnet new web -n MyAiApp
cd MyAiApp

Configuration

JSON
1// appsettings.json
2{
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.cs
2public class ModelRiverOptions
3{
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.cs
2using 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 channel
15var 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 body
27 using var reader = new StreamReader(context.Request.Body);
28 var rawBody = await reader.ReadToEndAsync();
29 
30 // 2. Verify signature
31 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 payload
46 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 workflow
53 if (payload.Type == "task.ai_generated" && payload.CallbackUrl is not null)
54 {
55 await jobChannel.Writer.WriteAsync(new WebhookJob
56 {
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 WebhookPayload
74{
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 AiResponseData
86{
87 public Dictionary<string, object> Data { get; init; } = new();
88}
89 
90public record WebhookJob
91{
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.cs
2using System.Text.Json;
3using System.Threading.Channels;
4using Microsoft.Extensions.Options;
5 
6public class WebhookProcessor : BackgroundService
7{
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 try
30 {
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 logic
48 if (job.Event == "content_ready")
49 {
50 enrichedData["processed"] = true;
51 enrichedData["saved_at"] = DateTime.UtcNow.ToString("O");
52 }
53 
54 // Call back to ModelRiver
55 var callback = new
56 {
57 data = enrichedData,
58 task_id = $"dotnet_{job.Event}_{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}",
59 metadata = new
60 {
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 else
78 {
79 _logger.LogError("❌ Callback failed ({Status})", response.StatusCode);
80 
81 // Send error callback
82 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.cs
2public class ModelRiverService
3{
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", new
17 {
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 WebsocketChannel
33);

Best practices

  1. Use Channel<T>: High-performance in-process queue for background processing.
  2. Use CryptographicOperations.FixedTimeEquals: Constant-time comparison for signatures.
  3. Use IHttpClientFactory: Proper connection pooling and lifecycle management.
  4. Use IHostedService: Long-running background processor that starts with the app.
  5. Read raw body: Read the stream before model binding for signature verification.

Next steps