Skip to content
ExponaBLOG
.NET & ASP.NET CORE · DAY 065 / 100

Receiving webhooks reliably: a .NET guide for developers

Lessons from Stripe and payment gateway integrations.

Expona Admin9 min read

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:

csharp
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.

csharp
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:

csharp
// 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:

csharp
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:

csharp
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
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles