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.
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.
- Provider → Consumer: Sends
POST /api/webhooks/payment-success(Event ID: evt_1092). - Consumer: Inserts Order into Database and decrements stock.
- Network dropped: The HTTP 200 OK packet is lost or delayed past the provider's timeout window.
- Provider Retry: Provider backs off and sends the exact same webhook payload again.
- 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.
Extract the provider's unique event ID (Stripe-Signature, X-GitHub-Delivery). If absent, compute an SHA-256 hash of the raw payload body.
Attempt to acquire an atomic lock in Redis with an in-flight status and an initial 2-minute safety timeout.
If the key exists and is marked completed, immediately return HTTP 200 OK. If in-flight, return HTTP 429 so the producer backs off.
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.