Building Webhook Systems in ASP.NET Core
Design, Security, and Delivery Guarantees. A practical guide to designing reliable, secure webhook infrastructure with ASP.NET Core
Webhooks are the backbone of modern event-driven integrations, but building a webhook system that’s reliable, secure, and production-ready is harder than it looks.
This guide walks you through everything you need to design and implement a robust webhook system in ASP.NET Core, from payload design and signature verification to retry logic and delivery guarantees.
What Makes Webhooks Different
If you’ve built REST APIs before, you’re used to the request-response pattern: a client asks, a server answers. Webhooks flip that model. Instead of your consumers polling your API for updates, your system pushes notifications to them the moment something happens. A payment clears, an order ships, a new user registers, your webhook fires and the subscriber’s endpoint gets the data immediately.
That sounds simple enough, but the devil is in the details. You’re now responsible for delivering HTTP requests to external systems you don’t control, across networks that can fail, to endpoints that might be slow, down, or returning errors. You need to handle all of that gracefully while keeping your own system performant and your consumers’ data consistent.
The good news is that ASP.NET Core is well-suited for this job. Its middleware pipeline, background service infrastructure, and rich ecosystem of libraries give you solid building blocks. Let’s put them to use.
Designing Your Webhook Payload
Before you write a single line of code, spend time on your payload structure. This is your public API contract, and changing it later is painful for everyone. A well-designed webhook payload is self-describing, versioned, and carries everything a subscriber needs to act without making follow-up API calls.
A typical webhook payload should include an event type identifier, a unique event ID, a timestamp, the API version, and the event data itself. Here’s a simple structure that works well in practice:
{
"id": "evt_01HZ8XK2QNVTRJ4BDWMC7VMSEP",
"type": "order.shipped",
"version": "2025-01-01",
"created_at": "2025-01-15T14:32:00Z",
"data": {
"order_id": "ord_9182736",
"customer_id": "cust_4453",
"tracking_number": "1Z999AA10123456784",
"carrier": "UPS"
}
}The id field is critical. A unique, stable event ID allows subscribers to implement idempotency, meaning they can safely ignore duplicate deliveries if they’ve already processed an event with that ID. Use something like ULID (Universally Unique Lexicographically Sortable Identifier) rather than a UUID. ULIDs sort chronologically, which makes database indexing and log tracing much easier.
The version field should carry the API version as a date string rather than a semantic version number. Stripe popularized this approach, and it works well because it ties the payload schema to a specific point in time rather than an abstract version number. Subscribers can request specific API versions, and you can manage multiple versions in parallel without breaking existing integrations.
Keep your event types in a consistent noun.verb format: order.shipped, payment.failed, subscription.cancelled. This naming convention makes your event catalog readable and easy to extend.
Setting Up the Webhook Sender
In your ASP.NET Core application, the webhook sender is typically a background service or a component invoked by your domain event handlers. The basic flow is: something happens in your system, you create a webhook event record, and you dispatch it to subscribers.
Start by defining a WebhookEvent model and a WebhookSubscription model. The subscription stores the subscriber’s endpoint URL, the event types they’re interested in, their secret key for HMAC signing, and their current status.
public class WebhookSubscription
{
public Guid Id { get; set; }
public string EndpointUrl { get; set; } = default!;
public IList<string> EventTypes { get; set; } = new List<string>();
public string Secret { get; set; } = default!;
public bool IsActive { get; set; }
public DateTime CreatedAt { get; set; }
}When an event occurs, you query the subscriptions table for all active subscribers interested in that event type and enqueue a delivery job for each one. The important thing here is to persist the delivery intent before you attempt delivery. Don’t fire-and-forget HTTP requests from your domain event handlers. If the request fails or your server crashes mid-flight, you’ll have no record that delivery was attempted.
Securing Webhooks with HMAC Signatures
Security is non-negotiable in a webhook system. When your webhook fires, it sends data to an external URL, and that subscriber needs a way to verify the payload actually came from you and wasn’t tampered with in transit.
The standard approach is HMAC-SHA256 signing. When you create a subscription, you generate a unique secret key for that subscriber. When you send a webhook, you compute an HMAC signature of the raw request body using that secret, then include the signature in the request headers. The subscriber recomputes the signature on their end using the same secret and compares it to yours. If they match, the payload is authentic.
Here’s a clean implementation of the signing logic:
public static class WebhookSigner
{
public static string ComputeSignature(string secret, string payload)
{
var keyBytes = Encoding.UTF8.GetBytes(secret);
var payloadBytes = Encoding.UTF8.GetBytes(payload);
using var hmac = new HMACSHA256(keyBytes);
var hashBytes = hmac.ComputeHash(payloadBytes);
return Convert.ToHexString(hashBytes).ToLower();
}
public static bool VerifySignature(string secret, string payload, string signature)
{
var expectedSignature = ComputeSignature(secret, payload);
return CryptographicOperations.FixedTimeEquals(
Convert.FromHexString(expectedSignature),
Convert.FromHexString(signature)
);
}
}Note the use of CryptographicOperations.FixedTimeEquals for signature comparison. This is important. A naive string comparison (==) is vulnerable to timing attacks, where an attacker can infer correct signature characters by measuring how long the comparison takes. Fixed-time comparison eliminates that vulnerability.
When sending the webhook, include the signature in a header like X-Webhook-Signature-256. You might also include a timestamp in a separate header (X-Webhook-Timestamp) and factor it into the signature computation. Subscribers can then reject payloads where the timestamp is more than a few minutes old, protecting against replay attacks (where an attacker captures and re-sends a legitimate webhook).
Here’s how that looks on the sending side:
public async Task SendWebhookAsync(WebhookSubscription subscription, WebhookEvent webhookEvent)
{
var payload = JsonSerializer.Serialize(webhookEvent);
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
var signedPayload = $"{timestamp}.{payload}";
var signature = WebhookSigner.ComputeSignature(subscription.Secret, signedPayload);
using var request = new HttpRequestMessage(HttpMethod.Post, subscription.EndpointUrl);
request.Content = new StringContent(payload, Encoding.UTF8, "application/json");
request.Headers.Add("X-Webhook-Signature-256", $"t={timestamp},v1={signature}");
request.Headers.Add("X-Webhook-Id", webhookEvent.Id);
request.Headers.Add("X-Webhook-Timestamp", timestamp);
var response = await _httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
}On the receiving side, subscribers verify the signature before processing the payload. If you’re building a subscriber SDK or documentation, show them exactly how to do this verification in their preferred language. Good developer experience here reduces support overhead dramatically.
Delivery Guarantees and Retry Logic
This is where most webhook systems either shine or fall apart. Networks are unreliable. Subscriber endpoints go down for maintenance. Deploys cause brief outages. Your webhook system needs to handle all of these scenarios gracefully without either losing events or overwhelming subscribers with duplicate deliveries.
The standard approach is at-least-once delivery with idempotent consumers. You commit to delivering every event at least once and rely on subscribers to handle duplicates using the event ID. This is far simpler to implement correctly than exactly-once delivery, which requires distributed coordination that adds significant complexity.
Start by persisting every outbound delivery attempt to a WebhookDeliveries table:
public class WebhookDelivery
{
public Guid Id { get; set; }
public Guid SubscriptionId { get; set; }
public string EventId { get; set; } = default!;
public string EventType { get; set; } = default!;
public string Payload { get; set; } = default!;
public DeliveryStatus Status { get; set; }
public int AttemptCount { get; set; }
public DateTime? NextAttemptAt { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? CompletedAt { get; set; }
}
public enum DeliveryStatus
{
Pending,
Succeeded,
Failed,
Abandoned
}For retries, implement exponential backoff with jitter. If the first attempt fails, retry after 1 minute, then 5 minutes, then 30 minutes, then 2 hours, then 24 hours. After a configurable number of attempts (typically 5 to 10), mark the delivery as Abandoned and optionally send an alert to the subscriber.
The jitter part is important. If you have thousands of subscribers and a brief outage causes all their webhooks to fail simultaneously, you don’t want them all retrying at exactly the same time. Adding random jitter to your retry intervals spreads the load. Here’s a simple jitter calculation:
public static TimeSpan GetRetryDelay(int attemptNumber)
{
var baseDelay = TimeSpan.FromSeconds(Math.Pow(2, attemptNumber) * 30);
var jitter = TimeSpan.FromSeconds(Random.Shared.NextDouble() * 30);
var maxDelay = TimeSpan.FromHours(24);
return TimeSpan.FromTicks(Math.Min((baseDelay + jitter).Ticks, maxDelay.Ticks));
}Use a background service (either a hosted service with IHostedService or a library like Hangfire) to process the retry queue. Hangfire is particularly well-suited here because it provides persistent job storage, retry scheduling, and a dashboard for monitoring delivery status out of the box.
public class WebhookRetryService : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var pendingDeliveries = await _repository.GetPendingDeliveriesAsync();
foreach (var delivery in pendingDeliveries)
{
await _dispatcher.DispatchAsync(delivery);
}
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
}
}
}Set a reasonable timeout for outbound webhook requests. 30 seconds is a common default. Subscribers should be designed to respond quickly (return 200 immediately, process the payload asynchronously) but you can’t always control subscriber behavior. A hard timeout prevents a slow endpoint from blocking your delivery threads indefinitely.
Handling the Receiving Side in ASP.NET Core
If you’re building an application that needs to receive webhooks from third-party services, the process mirrors what we’ve been building, but in reverse. You need an endpoint that accepts POST requests, verifies the signature, acknowledges receipt immediately, and processes the payload asynchronously.
Here’s the critical rule: always return a 200 response as quickly as possible. Senders typically have short timeout windows (often 5 to 10 seconds) and will consider a timeout a failure and retry. If your processing logic takes time (updating databases, calling other services, sending emails), do it in a background task, not in the request pipeline.
[ApiController]
[Route("webhooks")]
public class WebhookReceiverController : ControllerBase
{
[HttpPost("stripe")]
public async Task<IActionResult> ReceiveStripeWebhook()
{
var payload = await new StreamReader(Request.Body).ReadToEndAsync();
var signature = Request.Headers["Stripe-Signature"].ToString();
if (!_webhookValidator.Validate(payload, signature))
return Unauthorized();
var webhookEvent = JsonSerializer.Deserialize<WebhookEvent>(payload);
// Enqueue for async processing — don't block here
await _queue.EnqueueAsync(webhookEvent!);
return Ok();
}
}Note that when reading the raw request body for signature verification, you must read it as a raw string, not let model binding parse it first. ASP.NET Core’s model binder doesn’t preserve the exact byte sequence of the original payload, which means the HMAC you compute against the parsed model won’t match the sender’s signature. Always verify against the raw body.
Subscription Management
A production webhook system needs a management API that lets subscribers register endpoints, update their configuration, and view delivery history. At minimum, you need endpoints to create, update, delete, and list subscriptions.
Consider also providing a “ping” endpoint that sends a test webhook to a subscriber’s endpoint on demand. This lets subscribers verify their endpoint is configured correctly before they go live, which reduces support tickets dramatically.
[HttpPost("{id}/ping")]
public async Task<IActionResult> PingSubscription(Guid id)
{
var subscription = await _subscriptionRepository.GetByIdAsync(id);
if (subscription is null) return NotFound();
var pingEvent = new WebhookEvent
{
Id = Ulid.NewUlid().ToString(),
Type = "ping",
Version = "2025-01-01",
CreatedAt = DateTime.UtcNow,
Data = new { message = "Webhook endpoint verified successfully." }
};
await _dispatcher.DispatchAsync(subscription, pingEvent);
return Ok(new { message = "Ping sent." });
}For delivery history, expose an endpoint that returns recent delivery attempts for a subscription, including status, timestamps, response codes, and response bodies. This gives subscribers visibility into what’s being sent and why deliveries might be failing, which is invaluable for debugging integration issues.
Observability and Monitoring
Once your webhook system is in production, you need visibility into what’s happening. At minimum, track these metrics: delivery success rate, delivery latency (time from event creation to successful delivery), retry rate, and the number of abandoned deliveries.
Log every delivery attempt with structured logging. Include the subscription ID, event ID, event type, attempt number, response status code, and duration. This gives you the ability to trace a specific event through the system when a subscriber reports they didn’t receive it.
_logger.LogInformation(
"Webhook delivery {EventId} to {EndpointUrl} completed with status {StatusCode} in {DurationMs}ms",
delivery.EventId,
subscription.EndpointUrl,
response.StatusCode,
duration.TotalMilliseconds
);If you’re already using OpenTelemetry in your application (and you should be), add spans for webhook dispatch and record delivery outcomes as span attributes. This lets you correlate webhook delivery with the domain events that triggered them, which is extremely useful for debugging complex integration scenarios.
Set up alerts for high abandonment rates or sustained delivery failures to a particular endpoint. A subscriber’s endpoint going dark for 24 hours is worth a notification, both to you so you can investigate, and potentially to the subscriber themselves.
Rate Limiting and Fairness
If your system generates a high volume of events, you need to think about fairness across subscribers and rate limiting per endpoint. You don’t want one high-volume subscriber consuming all your delivery capacity at the expense of others.
Implement per-subscription concurrency limits. For example, no more than 5 concurrent in-flight requests to a single endpoint at once. This prevents a slow subscriber from consuming an unbounded number of worker threads. Libraries like Polly make it straightforward to add bulkhead isolation policies to your HTTP client:
services.AddHttpClient<IWebhookDispatcher, WebhookDispatcher>()
.AddPolicyHandler(Policy.BulkheadAsync<HttpResponseMessage>(
maxParallelization: 5,
maxQueuingActions: 10
));Also consider implementing adaptive throttling. If a subscriber’s endpoint returns 429 (Too Many Requests) with a Retry-After header, respect it. Back off for the specified duration before retrying. This is basic HTTP etiquette and ensures you’re a good citizen when dealing with rate-limited subscribers.
Wrapping Up
Building a webhook system touches nearly every aspect of distributed systems design: data modeling, security, reliability, observability, and developer experience. The patterns covered here (HMAC signing, at-least-once delivery, exponential backoff with jitter, async acknowledgment, and structured logging) are the same patterns used by Stripe, GitHub, and Twilio in their production webhook infrastructure. They’re battle-tested and worth the upfront investment to get right.
The key takeaway is to treat your webhook system as a first-class part of your API surface. Your subscribers are depending on it to build their own products, and a reliable, well-documented webhook system is a genuine competitive advantage. Get the fundamentals right, invest in observability, and iterate from there.
For further reading, Stripe’s webhook best practices guide is one of the best in the industry. Microsoft’s documentation on background tasks with hosted services covers the infrastructure you’ll use for retry processing. And if you’re evaluating Hangfire for job scheduling, their documentation is thorough and well-maintained.
Stay in the Loop
If this walkthrough was useful, there’s more where it came from. Subscribe to ASP Today on Substack for weekly deep dives into ASP.NET Core, practical, production-focused, no fluff.
Join the conversation in Substack Chat and connect with other .NET developers building real systems. See you there.


