Introduction
Webhook integrations are a fact of life for modern backend systems, whether you're processing payments, handling order updates, or syncing data across services. Yet many teams learn the hard way that receiving webhooks reliably is fundamentally different from making outbound HTTP calls—the server doesn't control the timing, the payload might arrive twice, and failure handling requires a different mindset. This guide walks you through receiving webhooks reliably: a .NET guide for developers, drawing lessons from how payment platforms like Stripe handle these challenges.
We'll cover the four pillars of reliable webhook handling: cryptographic verification, rapid acknowledgment, asynchronous processing, and idempotent design. By the time you finish, you'll have a blueprint for integrating external services without losing data or creating duplicate transactions.
Verify the signature before anything else
The first rule of webhook handling is trust nothing. Any endpoint exposed to the internet can be hit by anyone, and a malicious actor might try to replay old payloads, inject fake events, or corrupt data. Payment gateways like Stripe solve this with HMAC-SHA256 signatures: they include a signature header computed from the raw request body and a shared secret. Your job is to verify that signature matches what you computed locally.
Why verification first? Because it's your only guarantee the webhook actually came from Stripe (or whoever). If you process an unverified webhook, you might credit a customer account based on a fabricated payment event, or worse, double-process a legitimate one someone replayed with a doctored timestamp.
Here's a concrete implementation for Stripe webhooks in ASP.NET Core:
using System.Text;
using System.Security.Cryptography;
public class StripeWebhookController : ControllerBase
{
private readonly string _webhookSecret;
public StripeWebhookController(IConfiguration config)
{
_webhookSecret = config["Stripe:WebhookSecret"];
}
[HttpPost("/webhooks/stripe")]
public async Task<IActionResult> StripeEvent()
{
// Read raw body as string—this is critical for signature verification
var json = await new StreamReader(Request.Body).ReadToEndAsync();
var signatureHeader = Request.Headers["Stripe-Signature"].ToString();
if (!VerifyStripeSignature(json, signatureHeader))
{
return Unauthorized("Invalid signature");
}
// Signature verified—now we can trust the payload
var evt = JsonSerializer.Deserialize<StripeEvent>(json);
return Ok();
}
private bool VerifyStripeSignature(string json, string signatureHeader)
{
// Stripe signature format: "t=timestamp,v1=signature"
var parts = signatureHeader.Split(',');
var timestamp = parts[0].Split('=')[1];
var providedSignature = parts[1].Split('=')[1];
// Reconstruct signed content: "timestamp.json_body"
var signedContent = $"{timestamp}.{json}";
using (var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(_webhookSecret)))
{
var computedSignature = Convert.ToHexString(
hmac.ComputeHash(Encoding.UTF8.GetBytes(signedContent))
).ToLower();
// Use constant-time comparison to prevent timing attacks
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(computedSignature),
Encoding.UTF8.GetBytes(providedSignature)
);
}
}
}
public class StripeEvent
{
[JsonPropertyName("id")]
public string Id { get; set; }
[JsonPropertyName("type")]
public string Type { get; set; }
[JsonPropertyName("data")]
public JsonElement Data { get; set; }
}Notice we use CryptographicOperations.FixedTimeEquals to compare signatures. A naive string comparison could leak timing information that lets attackers forge signatures. Also, we read the raw JSON string before deserializing—if you deserialize first and then try to reconstruct the body, any whitespace differences will break verification.
Store the raw event, return 200 fast
After verifying the signature, your next move should be defensive: acknowledge receipt immediately. The webhook sender (Stripe, for example) has a timeout, typically 5–10 seconds. If you don't return a 200 response quickly, the sender assumes failure and retries. If you're doing heavy work (querying databases, calling third-party APIs, triggering emails), you'll hit that timeout and trigger retries you didn't need.
The pattern is simple: store the raw webhook in a database table as-is, return 200, and let background processing handle the rest.
public class WebhookStorageController : ControllerBase
{
private readonly IWebhookRepository _repository;
private readonly ILogger<WebhookStorageController> _logger;
public WebhookStorageController(
IWebhookRepository repository,
ILogger<WebhookStorageController> logger)
{
_repository = repository;
_logger = logger;
}
[HttpPost("/webhooks/stripe")]
public async Task<IActionResult> StripeEvent()
{
var json = await new StreamReader(Request.Body).ReadToEndAsync();
var signatureHeader = Request.Headers["Stripe-Signature"].ToString();
if (!VerifyStripeSignature(json, signatureHeader))
{
return Unauthorized("Invalid signature");
}
var evt = JsonSerializer.Deserialize<StripeEvent>(json);
// Store the raw event immediately
await _repository.StoreWebhookAsync(new WebhookRecord
{
ExternalId = evt.Id,
Source = "stripe",
RawPayload = json,
ReceivedAt = DateTime.UtcNow,
Processed = false
});
_logger.LogInformation("Webhook {WebhookId} stored for processing", evt.Id);
// Return 200 now—let background job handle processing
return Ok();
}
}The database schema might look like this:
| Column | Type | Notes |
|---|---|---|
| Id | BIGINT PRIMARY KEY | Auto-increment |
| ExternalId | VARCHAR(255) UNIQUE | Webhook ID from Stripe (for deduplication) |
| Source | VARCHAR(50) | "stripe", "paypal", etc. |
| RawPayload | TEXT | Full JSON as received |
| ReceivedAt | DATETIME | When we got it |
| Processed | BOOLEAN | Has background job handled it? |
| ProcessedAt | DATETIME NULL | When we marked it processed |
This approach decouples acknowledgment from processing. If Stripe retries the same event ID, your unique constraint on ExternalId prevents duplicate storage. The webhook sender gets its 200 OK within milliseconds, and your background job processes events at its own pace.
Process asynchronously and idempotently
Once you've acknowledged receipt, a background job (via Hangfire, Azure Service Bus, or a simple timer) reads stored webhooks and processes them. The key constraint: processing must be idempotent. If the same event is processed twice, the end result must be identical to processing it once.
Consider a payment confirmation webhook. The wrong approach:
// DON'T do this
await _repository.StoreWebhookAsync(webhook);
// Immediate processing (blocks the HTTP response)
var order = await _orderRepository.GetOrderAsync(evt.Data.OrderId);
order.Status = "confirmed";
order.PaidAt = DateTime.UtcNow;
await _orderRepository.UpdateAsync(order);
return Ok();This blocks the HTTP response and fails if processing throws. Instead, schedule asynchronous work:
public class WebhookProcessingService : IHostedService
{
private readonly IWebhookRepository _webhookRepository;
private readonly IOrderRepository _orderRepository;
private readonly ILogger<WebhookProcessingService> _logger;
private Timer _timer;
public WebhookProcessingService(
IWebhookRepository webhookRepository,
IOrderRepository orderRepository,
ILogger<WebhookProcessingService> logger)
{
_webhookRepository = webhookRepository;
_orderRepository = orderRepository;
_logger = logger;
}
public Task StartAsync(CancellationToken cancellationToken)
{
// Poll every 5 seconds for unprocessed webhooks
_timer = new Timer(
async _ => await ProcessPendingWebhooks(),
null,
TimeSpan.Zero,
TimeSpan.FromSeconds(5));
return Task.CompletedTask;
}
private async Task ProcessPendingWebhooks()
{
try
{
var unprocessed = await _webhookRepository.GetUnprocessedAsync(limit: 10);
foreach (var webhook in unprocessed)
{
await ProcessSingleWebhookAsync(webhook);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in webhook processing loop");
}
}
private async Task ProcessSingleWebhookAsync(WebhookRecord webhook)
{
try
{
var evt = JsonSerializer.Deserialize<StripeEvent>(webhook.RawPayload);
switch (evt.Type)
{
case "payment_intent.succeeded":
await ProcessPaymentSucceededAsync(evt);
break;
case "charge.refunded":
await ProcessChargeRefundedAsync(evt);
break;
default:
_logger.LogWarning("Unknown event type: {EventType}", evt.Type);
break;
}
// Mark as processed only after successful handling
webhook.Processed = true;
webhook.ProcessedAt = DateTime.UtcNow;
await _webhookRepository.UpdateAsync(webhook);
_logger.LogInformation("Processed webhook {WebhookId}", webhook.ExternalId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to process webhook {WebhookId}", webhook.ExternalId);
// Don't mark as processed—retry on next cycle
}
}
private async Task ProcessPaymentSucceededAsync(StripeEvent evt)
{
// Extract order ID from event data
var orderId = evt.Data.GetProperty("metadata")
.GetProperty("order_id")
.GetString();
var order = await _orderRepository.GetOrderAsync(orderId);
// Idempotent check: if already confirmed, do nothing
if (order.Status == "confirmed")
{
return;
}
order.Status = "confirmed";
order.PaidAt = DateTime.UtcNow;
await _orderRepository.UpdateAsync(order);
// Emit domain event for side effects (emails, etc.)
await _eventPublisher.PublishAsync(new OrderConfirmedEvent { OrderId = orderId });
}
public Task StopAsync(CancellationToken cancellationToken)
{
_timer?.Dispose();
return Task.CompletedTask;
}
}The idempotency pattern: before modifying state, check if the operation has already been applied. If the same payment succeeded webhook arrives twice, the second time we see order.Status == "confirmed" and return early. This is safe and simple.
Expect duplicates and out-of-order delivery
Stripe doesn't guarantee that webhooks arrive in order or exactly once. Network hiccups, retries, and load balancing can cause:
- Duplicates: The same event ID arrives twice (handled by our unique constraint and idempotent logic)
- Out-of-order: A refund webhook arrives before the payment succeeded webhook
For out-of-order issues, the safest approach is to query authoritative state from the payment gateway rather than trusting the order of webhooks. For example, when processing a refund event, verify the current payment status directly from Stripe:
private async Task ProcessChargeRefundedAsync(StripeEvent evt)
{
var chargeId = evt.Data.GetProperty("id").GetString();
// Query Stripe directly to confirm refund status
var charge = await _stripeClient.GetChargeAsync(chargeId);
if (charge.Refunded)
{
var order = await _orderRepository.GetOrderByChargeIdAsync(chargeId);
// Only refund if we haven't already
if (order.Status != "refunded")
{
order.Status = "refunded";
order.RefundedAt = DateTime.UtcNow;
await _orderRepository.UpdateAsync(order);
await _eventPublisher.PublishAsync(
new OrderRefundedEvent { OrderId = order.Id }
);
}
}
}This approach tolerates out-of-order delivery: even if the refund webhook arrives before the payment succeeded webhook, querying Stripe's authoritative state tells us the true situation.
Common mistakes
Deserializing without verifying: Parsing JSON before checking the signature. Even if you verify immediately after, you've given the attacker a moment to execute code. Always verify first.
Doing heavy work in the webhook endpoint: Calling downstream APIs, sending emails, or running expensive queries before returning 200 guarantees timeouts and retries. Store fast, acknowledge, process later.
Ignoring ExternalId uniqueness: Without a unique constraint on the webhook's external ID, a retry creates a duplicate database record, and duplicate processing becomes your problem to solve with code instead of the database.
Forgetting the raw payload: Deserializing and storing only the fields you care about means you can't re-process if your logic changes. Store the complete raw JSON.
Assuming webhooks arrive in order: They don't. Always verify state with the source of truth (Stripe, PayPal, etc.) rather than trusting event sequence.
Key takeaways
- Verify the HMAC signature before processing any webhook payload; use constant-time comparison to prevent timing attacks
- Store the raw webhook and return 200 immediately; defer all heavy work to asynchronous processing
- Design processing logic to be idempotent—check if the operation has already been applied before modifying state
- Use a unique constraint on the external event ID to prevent duplicate storage of retried webhooks
- Query the authoritative source (Stripe, PayPal) when processing events that depend on prior state, rather than trusting webhook ordering
- Log webhook receipt and processing outcomes; audit trails are your insurance policy when things go wrong






Comments
Loading comments…