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:

Example.cs
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:

Idempotent Processing

Webhook providers retry on failure, so you will receive duplicates. Use the event ID to ensure idempotent processing:

PaymentEventHandler.cs
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:

Example.cs
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:

WebhookDeliveryService.cs
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:

OrderService.cs
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

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.