<- Back to Blogs
Webhook Idempotency

Why Webhooks Need Idempotency Keys (And How to Implement Them)

- By Sam Rajarathinam Integrations Webhooks
Why Webhooks Need Idempotency Keys (And How to Implement Them)

In distributed systems, network timeouts don't mean failure—they usually mean duplicate executions. Learn why webhook consumers must implement idempotency keys, explore the anatomy of race conditions, and implement a production-ready ASP.NET Core & Redis pattern.

In distributed systems, the network is fallible. When an upstream provider like Stripe, Shopify, GitHub, or HubSpot pushes a webhook to your server, they operate on an at-least-once delivery guarantee. If a temporary network blip occurs after your server successfully completes the business transaction but before the upstream provider receives your HTTP 200 OK, the provider's retry policy automatically kicks in.

Without an idempotency key, that retry will re-run your payment confirmation, generate a duplicate order, create duplicate CRM contacts, or dispatch a second notification to the customer.

1. The Core Problem: The Illusion of "Failed" Requests

Every developer learns early that an HTTP 500 or timeout means a request failed. In webhook architectures, this assumption causes catastrophic bugs when consumers treat retries as new events.

Webhook Duplicate Delivery Failure Sequence
Figure 1: Sequence diagram of duplicate order creation during transient upstream network timeouts.
Failure Sequence Breakdown
  1. Provider → Consumer: Sends POST /api/webhooks/payment-success (Event ID: evt_1092).
  2. Consumer: Inserts Order into Database and decrements stock.
  3. Network dropped: The HTTP 200 OK packet is lost or delayed past the provider's timeout window.
  4. Provider Retry: Provider backs off and sends the exact same webhook payload again.
  5. Duplicate Execution: Naive consumer inserts Order a second time, charging or fulfilling twice.

Why Naive Database Lookups Fail (The Race Condition Trap)

A common developer impulse is to write a pre-check query like:

if (await _dbContext.Orders.AnyAsync(o => o.StripeEventId == eventId))
{
    return Ok(); // Already handled
}

This works during single-threaded manual testing. However, in production, providers often fire concurrent retries when their HTTP connection experiences socket resets. Both requests hit the database query before either one has committed its transaction. Both checks return false, and both threads proceed to insert duplicate records.

2. The Solution: Distributed Atomic Idempotency Filter

To ensure an operation is strictly idempotent (producing identical state regardless of whether it is invoked once or ten times), we decouple duplicate detection from our primary database using a fast, atomic memory store such as Redis.

Distributed Idempotency Filter Flowchart with Redis
Figure 2: Architectural flowchart of the atomic Redis SETNX idempotency filter short-circuiting duplicates.
1. Fingerprinting / Key Extraction

Extract the provider's unique event ID (Stripe-Signature, X-GitHub-Delivery). If absent, compute an SHA-256 hash of the raw payload body.

2. Atomic Ingestion Lock (SETNX)

Attempt to acquire an atomic lock in Redis with an in-flight status and an initial 2-minute safety timeout.

3. Duplicate Short-Circuit

If the key exists and is marked completed, immediately return HTTP 200 OK. If in-flight, return HTTP 429 so the producer backs off.

4. Final State Commitment

Once the business logic commits successfully, transition the key to completed with a 24-to-72-hour operational TTL.

3. Production ASP.NET Core Implementation

Here is a production-hardened action filter attribute in C# leveraging IDistributedCache:

using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Caching.Distributed;

namespace Celes7a.Web.Infrastructure.Filters;

[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
public class IdempotentWebhookAttribute : Attribute, IAsyncActionFilter
{
    private readonly string _headerName;
    private readonly int _ttlHours;

    public IdempotentWebhookAttribute(string headerName = "X-Webhook-Event-Id", int ttlHours = 24)
    {
        _headerName = headerName;
        _ttlHours = ttlHours;
    }

    public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
    {
        var cache = context.HttpContext.RequestServices.GetRequiredService<IDistributedCache>();
        var request = context.HttpContext.Request;

        // 1. Resolve Idempotency Identifier
        string idempotencyKey;
        if (request.Headers.TryGetValue(_headerName, out var headerValue) && !string.IsNullOrWhiteSpace(headerValue))
        {
            idempotencyKey = headerValue.ToString();
        }
        else
        {
            // Fallback: Hash raw request body
            request.EnableBuffering();
            using var reader = new StreamReader(request.Body, Encoding.UTF8, leaveOpen: true);
            var body = await reader.ReadToEndAsync();
            request.Body.Position = 0;
            idempotencyKey = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(body)));
        }

        var cacheKey = $"idempotency:webhook:{idempotencyKey}";

        // 2. Check Cache
        var existingStatus = await cache.GetStringAsync(cacheKey);
        if (existingStatus == "completed")
        {
            context.Result = new OkObjectResult(new { status = "duplicate_ignored", key = idempotencyKey });
            return;
        }

        if (existingStatus == "in-flight")
        {
            context.Result = new StatusCodeResult(StatusCodes.Status429TooManyRequests);
            return;
        }

        // 3. Mark in-flight with short safety TTL
        await cache.SetStringAsync(cacheKey, "in-flight", new DistributedCacheEntryOptions
        {
            AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(2)
        });

        // 4. Execute downstream business logic
        var executedContext = await next();

        // 5. Finalize state
        if (executedContext.Exception == null || executedContext.ExceptionHandled)
        {
            await cache.SetStringAsync(cacheKey, "completed", new DistributedCacheEntryOptions
            {
                AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(_ttlHours)
            });
        }
        else
        {
            await cache.RemoveAsync(cacheKey);
        }
    }
}

4. Operational Hardening Checklist

Safeguard Risk if Ignored Recommended Best Practice
Bounded TTL Window Memory exhaustion in Redis Expire keys after 24–72 hours. Retries older than 3 days require human review.
Atomic Lock Race condition during burst retries Use atomic Redis operations (SETNX or Lua script) to prevent read-then-write race gaps.
Failure Eviction Legitimate retries blocked after transient error If downstream database or ERP throws, purge the cache key so retries succeed.
Fast Ack Pattern Provider timeout (HTTP 504) If processing takes >2s, push payload into a background queue and return 200 OK immediately.

Key Takeaway

Network timeouts don't mean failed requests—they usually mean duplicate executions. Decoupling deduplication into a fast distributed lock prevents silent state corruption.

Architectural Rule

Always enforce bounded TTLs (24 to 72 hours) and atomic acquisition (SETNX) to guard against burst concurrent retries from webhook providers.

Conclusion

Rule of Thumb: Never trust the client, and never trust the network. Every public webhook endpoint must assume that every incoming HTTP request has already been delivered, or will be delivered again in 10 seconds.

Implementing an atomic idempotency filter eliminates duplicate financial transactions, prevents corrupted inventory counts, and builds resilient enterprise integration pipelines that sleep soundly through upstream network hiccups.