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

Idempotency keys in ASP.NET Core: lessons from production

Make POST requests safe to retry.

Expona Admin7 min read

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:

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

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

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

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

StorageProsCons
RedisFast lookups, automatic expiration, minimal overheadRequires Redis infrastructure, no persistence by default
DatabaseDurable, queryable, audit trailSlower 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.

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