Why Idempotency Keys Matter
Network failures happen. A client hits your payment endpoint, the request succeeds, but the response gets lost before reaching them. They retry, and suddenly you've charged their account twice. This is where idempotency keys in ASP.NET Core save the day—they make POST requests safe to retry without side effects. If you're building payment systems, order creation flows, or any critical operation that shouldn't repeat, understanding idempotency is non-negotiable.
Understanding the Client-Side Contract
Idempotency starts with the client. When making a sensitive request, the client generates a unique identifier—the Idempotency-Key—and includes it as an HTTP header. This key should be stable for that logical operation: same user, same intent, same key.
In practice, a client might send:
POST /api/orders HTTP/1.1
Host: api.example.com
Idempotency-Key: user-123-order-2024-01-15-001
Content-Type: application/json
{
"productId": 456,
"quantity": 2,
"amount": 99.99
}
The key format doesn't matter much—UUIDs, timestamps, or semantic strings all work. What matters is consistency: the same logical request always carries the same key. If the client retries due to a timeout, it sends the identical Idempotency-Key, telling your server "this is the same operation I already requested."
Storing the Key and Response
Once your server receives an idempotent request, you need to store two things: the key itself and a hash or cache of the response. This storage mechanism is critical. For most production systems, Redis offers the speed you need; for others, a database record suffices.
Here's a middleware-based approach using Redis:
public class IdempotencyMiddleware
{
private readonly RequestDelegate _next;
private readonly IConnectionMultiplexer _redis;
public IdempotencyMiddleware(RequestDelegate next, IConnectionMultiplexer redis)
{
_next = next;
_redis = redis;
}
public async Task InvokeAsync(HttpContext context)
{
// Only apply to POST, PUT, PATCH
if (!IsIdempotentMethod(context.Request.Method))
{
await _next(context);
return;
}
var idempotencyKey = context.Request.Headers["Idempotency-Key"].ToString();
if (string.IsNullOrEmpty(idempotencyKey))
{
await _next(context);
return;
}
var db = _redis.GetDatabase();
var cacheKey = $"idempotency:{idempotencyKey}";
var cachedResponse = await db.StringGetAsync(cacheKey);
if (cachedResponse.HasValue)
{
// Return the cached response
context.Response.StatusCode = 200;
context.Response.ContentType = "application/json";
await context.Response.WriteAsync(cachedResponse.ToString());
return;
}
// Capture the response
var originalBody = context.Response.Body;
using var memoryStream = new MemoryStream();
context.Response.Body = memoryStream;
await _next(context);
// Store the response for future idempotent retries
memoryStream.Position = 0;
var responseBody = await new StreamReader(memoryStream).ReadToEndAsync();
// Store with 24-hour TTL
await db.StringSetAsync(cacheKey, responseBody, TimeSpan.FromHours(24));
// Write response back to client
memoryStream.Position = 0;
await memoryStream.CopyToAsync(originalBody);
}
private bool IsIdempotentMethod(string method) =>
method is "POST" or "PUT" or "PATCH";
}
This middleware intercepts requests, checks Redis for a cached response, and if found, returns it immediately without re-executing business logic. If not found, the request proceeds normally, and the response is cached for future retries.
Handling Duplicate Requests Safely
The core principle is simple: same idempotency key equals same response. Your system must ensure that no matter how many times a client retries with the same key, they always get the identical result—and the operation only executes once.
Consider an order creation scenario. Your handler looks like this:
[HttpPost("orders")]
public async Task<IActionResult> CreateOrder([FromBody] CreateOrderRequest request)
{
var idempotencyKey = HttpContext.Request.Headers["Idempotency-Key"].ToString();
// Check database for existing order with this key
var existingOrder = await _dbContext.Orders
.FirstOrDefaultAsync(o => o.IdempotencyKey == idempotencyKey);
if (existingOrder != null)
{
return Ok(new { orderId = existingOrder.Id, status = existingOrder.Status });
}
// Create new order
var order = new Order
{
IdempotencyKey = idempotencyKey,
UserId = GetCurrentUserId(),
ProductId = request.ProductId,
Quantity = request.Quantity,
Total = request.Quantity * request.UnitPrice,
CreatedAt = DateTime.UtcNow
};
_dbContext.Orders.Add(order);
await _dbContext.SaveChangesAsync();
return Ok(new { orderId = order.Id, status = "pending" });
}
Here, we store the idempotency key in the database alongside the order. On retry, we query for an existing order with that key and return it immediately—no duplicate charge, no duplicate order record.
Real-World Scenario: Payment Processing
Imagine a payment flow where the client submits a charge for $50. The charge succeeds in your system, but the network fails before the client receives the response. The client retries with the same Idempotency-Key. Without idempotency, you'd charge $100 total.
With idempotency keys, your payment handler works like this:
[HttpPost("payments")]
public async Task<IActionResult> ProcessPayment([FromBody] PaymentRequest request)
{
var idempotencyKey = HttpContext.Request.Headers["Idempotency-Key"].ToString();
if (string.IsNullOrEmpty(idempotencyKey))
return BadRequest("Idempotency-Key header required");
// Check for duplicate
var existingPayment = await _dbContext.Payments
.FirstOrDefaultAsync(p => p.IdempotencyKey == idempotencyKey);
if (existingPayment != null)
{
return Ok(new
{
paymentId = existingPayment.Id,
status = existingPayment.Status,
amount = existingPayment.Amount
});
}
try
{
// Call payment provider (Stripe, etc.)
var chargeResult = await _stripeService.ChargeAsync(
request.Amount,
request.CardToken
);
var payment = new Payment
{
IdempotencyKey = idempotencyKey,
UserId = GetCurrentUserId(),
Amount = request.Amount,
Status = "success",
ProviderTransactionId = chargeResult.TransactionId,
CreatedAt = DateTime.UtcNow
};
_dbContext.Payments.Add(payment);
await _dbContext.SaveChangesAsync();
return Ok(new { paymentId = payment.Id, status = "success" });
}
catch (Exception ex)
{
// Don't store failed attempts; let client retry with same key
return StatusCode(500, "Payment processing failed");
}
}
Notice: we only store the idempotency key after successful processing. Failed attempts aren't cached, allowing legitimate retries.
Choosing Storage: Redis vs. Database
Redis is fast and perfect for high-throughput scenarios. Set a reasonable TTL (24 hours is typical) and you're done. Databases are more durable but slower; use them if you need a permanent audit trail or if your volume is moderate.
| Storage | Pros | Cons |
| Redis | Fast lookups, automatic expiration, minimal overhead | Requires Redis infrastructure, no persistence by default |
| Database | Durable, queryable, audit trail | Slower than Redis, requires cleanup logic |
For payment systems, I often use a hybrid: check Redis first, fall back to the database. This minimizes database queries while ensuring durability.
Common Mistakes
Caching error responses: Don't store 4xx or 5xx responses. If the first request fails due to bad data, the second request shouldn't fail silently. Only cache successful responses.
Ignoring the TTL: Idempotency keys have a window of validity. If a client retries after 30 days, you might process the request again. Define your TTL based on business logic—48 hours for orders, 24 hours for payments.
Forgetting database constraints: Use a unique index on your idempotency key column to prevent race conditions. If two concurrent requests arrive with the same key, the database will enforce uniqueness.
modelBuilder.Entity<Order>()
.HasIndex(o => o.IdempotencyKey)
.IsUnique();
Storing the entire response body: For large responses, hash the response instead. Store hash + status code, then return matching cached data based on the hash.
Key Takeaways
- Idempotency keys transform unsafe POST requests into safe, replayable operations.
- Clients generate and send the Idempotency-Key header; servers use it to deduplicate requests.
- Store the key and response in Redis (fast) or a database (durable); use unique indexes to prevent race conditions.
- On retry, return the cached response immediately without re-executing business logic.
- Only cache successful responses; let failures retry naturally.
- Set appropriate TTLs (24–48 hours) based on your use case.
- This pattern is essential for payments, order creation, and any operation with external side effects.






Comments
Loading comments…