A customer taps "Pay", the mobile connection stalls, and the app retries. Your server processed the first request, so now there are two charges. This is not a rare edge case. Any API that mutates money, inventory or orders will see duplicate requests from retries, double-clicks and proxy timeouts.
The standard fix is an idempotency key: the client sends a unique Idempotency-Key header with every write, and the server guarantees that the same key produces the same result, no matter how many times it arrives.
The rules that make it safe
- The key is scoped to a client (so two customers can't collide).
- The first request stores the key before doing any work.
- A repeat with the same key and same body returns the stored response.
- A repeat with the same key but a different body is an error (HTTP 422), because the client has a bug.
- A repeat while the first is still running returns HTTP 409.
The storage model
`C#
csharp
public sealed class IdempotencyRecord
{
public string ClientId { get; set; } = default!;
public string Key { get; set; } = default!;
public string RequestHash { get; set; } = default!;
public int? StatusCode { get; set; } // null = still in progress
public string? ResponseBody { get; set; }
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
}
`csharp
`
// In OnModelCreating:
modelBuilder.Entity<IdempotencyRecord>()
.HasKey(x => new { x.ClientId, x.Key }); // the unique constraint does the locking
The composite primary key is the important part. Two simultaneous requests cannot both insert the same row; the database picks the winner for you, with no distributed lock required.
`
The endpoint logic
C#
`
app.MapPost("/payments", async (HttpRequest req, PaymentRequest body,
AppDbContext db, PaymentService payments) =>
{
var key = req.Headers["Idempotency-Key"].ToString();
if (string.IsNullOrWhiteSpace(key)) return Results.BadRequest("Idempotency-Key required");
var clientId = "client-123"; // resolve from your auth
var hash = Convert.ToHexString(SHA256.HashData(
JsonSerializer.SerializeToUtf8Bytes(body)));
try
{
db.Add(new IdempotencyRecord { ClientId = clientId, Key = key, RequestHash = hash });
await db.SaveChangesAsync();
}
catch (DbUpdateException) // key already exists
{
db.ChangeTracker.Clear();
var existing = await db.Set<IdempotencyRecord>().AsNoTracking()
.SingleAsync(x => x.ClientId == clientId && x.Key == key);
if (existing.RequestHash != hash) return Results.UnprocessableEntity("Key reused with different body");
if (existing.StatusCode is null) return Results.Conflict("Original request still processing");
return Results.Content(existing.ResponseBody!, "application/json", statusCode: existing.StatusCode);
}
var result = await payments.ChargeAsync(body);
var json = JsonSerializer.Serialize(result);
await db.Set<IdempotencyRecord>()
.Where(x => x.ClientId == clientId && x.Key == key)
.ExecuteUpdateAsync(s => s
.SetProperty(x => x.StatusCode, 200)
.SetProperty(x => x.ResponseBody, json));
return Results.Content(json, "application/json");
});`
Pitfalls worth knowing
Crashes between insert and update. If the process dies mid-charge, the record stays "in progress" forever. Add a background job that re-checks records older than a few minutes against the payment provider's own idempotency support, and passes your key through to the provider as well.
Expiry. Keep records for 24 hours to a few days, then purge. Unbounded growth is a quiet production problem.
Don't hash volatile fields. If the body contains a timestamp the client regenerates on retry, your hash will differ and you will return 422 for legitimate retries.
Store failures too. If the first attempt failed with a validation error, replaying that same error is correct behaviour.
Test it properly
Fire 20 parallel requests with the same key using a small script or a load tool. Exactly one charge should be created, and all 20 responses should be identical (or 409 while in flight). If you ever see two charges, your key is not part of a uniqueness constraint.
Idempotency is cheap to add on day one and painful to retrofit after a double-charge incident. It is the pattern my team at QllmSoft reaches for first on payment-adjacent .NET projects, because it turns an unpredictable network into a predictable contract.
Author bio: Ali Elyas is Digital Marketing Team Lead at QllmSoft, a software company founded in 2015 that builds secure web, mobile and workflow-automation systems.
Top comments (0)