Webhook Patterns in .NET: Sending and Receiving
Webhooks flip the client-server relationship. Instead of clients polling for changes, your server pushes events to registered URLs when something happens. They are the backbone of integrations between SaaS platforms, payment processors, CI/CD systems, and countless other services. This article covers both sides: receiving webhooks from external services and sending webhooks to your consumers.
Receiving Webhooks
When you integrate with services like Stripe, GitHub, or Twilio, you register a URL and they POST events to it. The key challenge is verification — proving the request genuinely came from the expected sender.
Signature Verification
Most webhook providers sign payloads using HMAC-SHA256. The signature is sent in a header, and you verify it against the raw request body using a shared secret:
app.MapPost("/webhooks/payments", async (HttpContext httpContext, IPaymentEventHandler handler) =>
{
// Read the raw body — do NOT use model binding, as it consumes the stream
var body = await new StreamReader(httpContext.Request.Body).ReadToEndAsync();
var signature = httpContext.Request.Headers["X-Webhook-Signature"].FirstOrDefault();
if (string.IsNullOrEmpty(signature))
return Results.Unauthorized();
var secret = Environment.GetEnvironmentVariable("WEBHOOK_SECRET")!;
var expectedSignature = ComputeSignature(body, secret);
if (!CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(signature),
Encoding.UTF8.GetBytes(expectedSignature)))
{
return Results.Unauthorized();
}
var webhookEvent = JsonSerializer.Deserialize<WebhookEvent>(body);
await handler.HandleAsync(webhookEvent!);
return Results.Ok();
});
static string ComputeSignature(string payload, string secret)
{
var keyBytes = Encoding.UTF8.GetBytes(secret);
var payloadBytes = Encoding.UTF8.GetBytes(payload);
var hash = HMACSHA256.HashData(keyBytes, payloadBytes);
return Convert.ToHexStringLower(hash);
}
Critical points:
- Use
FixedTimeEqualsfor signature comparison. String comparison is vulnerable to timing attacks. - Read the raw body, not a deserialised model. Deserialisation may alter the payload (reorder fields, change whitespace), invalidating the signature.
- Return 200 quickly. Process the event asynchronously if it takes time. Webhook providers will retry on timeout.
Idempotent Processing
Webhook providers retry on failure, so you will receive duplicates. Use the event ID to ensure idempotent processing:
public class PaymentEventHandler(AppDbContext db) : IPaymentEventHandler
{
public async Task HandleAsync(WebhookEvent webhookEvent)
{
// Check if already processed
if (await db.ProcessedEvents.AnyAsync(e => e.EventId == webhookEvent.Id))
return;
// Process the event
switch (webhookEvent.Type)
{
case "payment.completed":
await HandlePaymentCompleted(webhookEvent);
break;
case "payment.failed":
await HandlePaymentFailed(webhookEvent);
break;
}
// Mark as processed
db.ProcessedEvents.Add(new ProcessedEvent
{
EventId = webhookEvent.Id,
ProcessedAt = DateTime.UtcNow
});
await db.SaveChangesAsync();
}
}
Sending Webhooks
When your API is the source of events, you need to deliver webhooks reliably.
Registration
Let consumers register webhook subscriptions:
app.MapPost("/api/webhook-subscriptions", async (
CreateSubscriptionRequest request,
AppDbContext db,
CancellationToken ct) =>
{
var subscription = new WebhookSubscription
{
Url = request.Url,
Events = request.Events, // e.g., ["order.created", "order.shipped"]
Secret = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(32)),
IsActive = true,
CreatedAt = DateTime.UtcNow
};
db.WebhookSubscriptions.Add(subscription);
await db.SaveChangesAsync(ct);
return Results.Created($"/api/webhook-subscriptions/{subscription.Id}",
new { subscription.Id, subscription.Secret, subscription.Events });
});
Return the secret to the consumer at creation time — this is the only time they will see it.
Delivery
Use a background service to deliver webhooks with retries:
public class WebhookDeliveryService(
IServiceScopeFactory scopeFactory,
IHttpClientFactory httpClientFactory,
ILogger<WebhookDeliveryService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var pendingDeliveries = await db.WebhookDeliveries
.Where(d => d.Status == DeliveryStatus.Pending && d.NextAttemptAt <= DateTime.UtcNow)
.Take(50)
.ToListAsync(ct);
foreach (var delivery in pendingDeliveries)
{
await DeliverAsync(delivery, db, ct);
}
await Task.Delay(TimeSpan.FromSeconds(5), ct);
}
}
private async Task DeliverAsync(
WebhookDelivery delivery, AppDbContext db, CancellationToken ct)
{
var client = httpClientFactory.CreateClient("webhooks");
var signature = ComputeSignature(delivery.Payload, delivery.Subscription.Secret);
var request = new HttpRequestMessage(HttpMethod.Post, delivery.Subscription.Url)
{
Content = new StringContent(delivery.Payload, Encoding.UTF8, "application/json")
};
request.Headers.Add("X-Webhook-Signature", signature);
request.Headers.Add("X-Webhook-Id", delivery.Id.ToString());
try
{
var response = await client.SendAsync(request, ct);
if (response.IsSuccessStatusCode)
{
delivery.Status = DeliveryStatus.Delivered;
}
else
{
HandleFailure(delivery);
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Webhook delivery {Id} failed", delivery.Id);
HandleFailure(delivery);
}
await db.SaveChangesAsync(ct);
}
private static void HandleFailure(WebhookDelivery delivery)
{
delivery.AttemptCount++;
if (delivery.AttemptCount >= 5)
{
delivery.Status = DeliveryStatus.Failed;
return;
}
// Exponential backoff: 30s, 2m, 8m, 32m
var delay = TimeSpan.FromSeconds(30 * Math.Pow(4, delivery.AttemptCount - 1));
delivery.NextAttemptAt = DateTime.UtcNow.Add(delay);
}
}
Queuing Events
When a domain event occurs, queue the webhook delivery rather than sending it inline:
public class OrderService(AppDbContext db)
{
public async Task<Order> CreateOrderAsync(CreateOrderRequest request, CancellationToken ct)
{
var order = new Order { /* ... */ };
db.Orders.Add(order);
// Queue webhook deliveries for all relevant subscriptions
var subscriptions = await db.WebhookSubscriptions
.Where(s => s.IsActive && s.Events.Contains("order.created"))
.ToListAsync(ct);
foreach (var subscription in subscriptions)
{
db.WebhookDeliveries.Add(new WebhookDelivery
{
SubscriptionId = subscription.Id,
EventType = "order.created",
Payload = JsonSerializer.Serialize(new { type = "order.created", data = order }),
Status = DeliveryStatus.Pending,
NextAttemptAt = DateTime.UtcNow,
AttemptCount = 0
});
}
await db.SaveChangesAsync(ct);
return order;
}
}
Key Design Decisions
- Timeout aggressively — set a 5-10 second timeout on delivery attempts. Consumer endpoints should respond quickly.
- Exponential backoff — increase delay between retries to avoid overwhelming failing endpoints.
- Dead letter — after max retries, mark the delivery as failed and provide a UI or API for consumers to see missed events.
- Delivery log — store all delivery attempts with timestamps, status codes, and response bodies for debugging.
- Subscription validation — on registration, send a test event and require the consumer to respond correctly before activating the subscription.
Webhooks are simple in concept but demand careful engineering for reliability. The patterns above — signature verification, idempotent processing, background delivery with retries — form the foundation of a production-ready webhook system.