Implementing Idempotent REST APIs in ASP.NET Core
Learn to build idempotent REST APIs in ASP.NET Core with idempotency keys, an endpoint filter, and a safe store so retried requests never run twice.
The two-buttons-but-one-pizza problem
Imagine you are ordering a pizza online. You tap the big "Place Order" button. The little spinner turns and turns. The phone network is weak, so nothing happens on screen. You get worried, so you tap the button again.
Now here is the scary part. What if the first tap actually reached the restaurant, but the reply got lost on the way back to your phone? And your second tap also reached the restaurant? You wanted one pizza. You might get charged for two.
A good pizza website does not let this happen. Even if you tap five times because you are nervous, you still get one pizza and one charge. The extra taps are simply ignored, and the website calmly shows you the same order each time.
That safety is called idempotency. An idempotent action can be repeated many times, and the result stays the same as doing it once. In this post we will build exactly that into an ASP.NET Core API, step by step, in simple words.
Why retries happen in the first place
You might think, "If my code is correct, why would the same request come twice?" The honest answer is that the network is not perfect. Things go wrong between the client and the server all the time.
Here are the usual reasons a client sends the same request twice:
- The user got impatient and clicked the button again.
- The mobile app timed out and automatically retried.
- A load balancer or proxy retried a request it thought had failed.
- A background job re-ran after a crash.
In every one of these cases, the first request may have already done its job on the server. The retry is a duplicate. Without protection, that duplicate creates a second order, a second payment, or a second email.
The client did nothing wrong. It just never heard back, so it tried again. Our server must be smart enough to notice "I have seen this exact request before" and not do the work twice.
Which HTTP methods are already safe
Good news: HTTP already promises that some methods are idempotent. Knowing which ones saves you a lot of work.
| Method | Idempotent? | Why |
|---|---|---|
GET | Yes | It only reads data. Reading a page ten times changes nothing. |
PUT | Yes | It replaces a resource with a full new version. Repeating lands on the same final state. |
DELETE | Yes | Deleting an already-deleted item leaves it deleted. The end state is the same. |
HEAD | Yes | Like GET but headers only. Pure read. |
POST | No | Each POST usually creates a brand-new thing, so a retry creates a duplicate. |
PATCH | Not guaranteed | A partial change may or may not be safe to repeat, depending on the body. |
So the troublemakers are POST and sometimes PATCH. Those are the ones we protect with an idempotency key. The other methods are already safe by design, as long as your handlers respect the rules (for example, a PUT should set the whole resource, not append to it).
The idempotency key idea
The trick is simple. The client creates a unique value — a key — for each real operation it wants to perform. It sends that key with the request in a header, usually called Idempotency-Key. A good key is a fresh GUID.
The server keeps a small notebook. For every key it has seen, it writes down the response it sent back. When a request arrives:
- The server looks up the key in its notebook.
- If the key is new, the server does the real work, saves the response under that key, and returns it.
- If the key is already there, the server skips the work and returns the saved response.
That is the whole pattern. The same key always gets the same answer, and the real work runs only once.
How an Idempotency Key Protects a POST
Steps
Read key
Pull the Idempotency-Key header off the request
Look up
Check the shared store for that key
First time
Run the real handler and save its response
Repeat
Skip the handler, replay the saved response
Notice that the key comes from the client, not the server. This matters. Only the client knows that two requests are "the same operation". A retry of the same button click reuses the same key. A fresh new order uses a fresh key.
What we will store for each key
To replay a response correctly, we need to remember a few things, not just the key. Here is a small table of what a stored idempotency record usually holds.
| Field | Purpose |
|---|---|
Key | The client's idempotency key. This is what we look up. |
StatusCode | The HTTP status we sent, such as 201. |
Body | The response body we returned, so we can replay it exactly. |
RequestHash | A hash of the request body, to catch a reused key with a different body. |
CreatedAt | When we saved it, used to expire old records. |
The RequestHash is a quiet hero. If the same key shows up but the body is different, the client has made a mistake — maybe a bug reused a key. We do not want to reply with the old order's details for a new order. So we reject that case clearly.
Building it in ASP.NET Core
Let us write real code. We will use a minimal API endpoint filter, which is the clean, modern way in .NET 10. A filter runs around your endpoint, so it can check the key before your handler and save the result after. This keeps your business code clean.
First, a small attribute and a store. We will use IDistributedCache so the same code works with Redis in production and an in-memory cache while learning.
// The shape of what we remember for each key.
public sealed record IdempotencyRecord(
int StatusCode,
string Body,
string RequestHash);
// A tiny wrapper over IDistributedCache for clarity.
public sealed class IdempotencyStore(IDistributedCache cache)
{
private static readonly DistributedCacheEntryOptions Options = new()
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(24)
};
public async Task<IdempotencyRecord?> GetAsync(string key, CancellationToken ct)
{
var json = await cache.GetStringAsync(Prefix(key), ct);
return json is null
? null
: JsonSerializer.Deserialize<IdempotencyRecord>(json);
}
public Task SaveAsync(string key, IdempotencyRecord record, CancellationToken ct)
{
var json = JsonSerializer.Serialize(record);
return cache.SetStringAsync(Prefix(key), json, Options, ct);
}
private static string Prefix(string key) => $"idempotency:{key}";
}We give every saved key a 24-hour time-to-live. After a day, the record disappears on its own. This stops the store from growing forever, and a day is far longer than any sensible retry window.
The endpoint filter
Now the filter itself. It reads the header, checks the store, and either replays or runs the work.
public sealed class IdempotencyFilter(IdempotencyStore store) : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
var http = context.HttpContext;
// 1. The client must send a key. No key, no protection.
if (!http.Request.Headers.TryGetValue("Idempotency-Key", out var values)
|| string.IsNullOrWhiteSpace(values))
{
return Results.BadRequest("Missing Idempotency-Key header.");
}
var key = values.ToString();
var ct = http.RequestAborted;
var bodyHash = await HashRequestBodyAsync(http.Request);
// 2. Have we seen this key before?
var existing = await store.GetAsync(key, ct);
if (existing is not null)
{
// Same key, different body = client mistake.
if (existing.RequestHash != bodyHash)
{
return Results.UnprocessableEntity(
"This Idempotency-Key was used with a different request body.");
}
// Replay the saved response. No work is done again.
return Results.Content(
existing.Body, "application/json", statusCode: existing.StatusCode);
}
// 3. First time we see this key: run the real handler.
var result = await next(context);
// 4. Save what we returned so the next retry can replay it.
var (statusCode, body) = await ReadResultAsync(result, http);
await store.SaveAsync(key, new IdempotencyRecord(statusCode, body, bodyHash), ct);
return result;
}
}To attach it to an endpoint, you simply chain AddEndpointFilter. Only the routes that need protection get it, so a plain GET stays untouched.
app.MapPost("/orders", async (CreateOrderRequest request, IOrderService orders) =>
{
var order = await orders.CreateAsync(request);
return Results.Created($"/orders/{order.Id}", order);
})
.AddEndpointFilter<IdempotencyFilter>()
.WithName("CreateOrder");That is the core. The handler does not know anything about idempotency. The filter does all the careful work around it.
Walking through a real retry
Let us trace what happens when a nervous client clicks twice. Both clicks carry the same Idempotency-Key, because they are the same logical action.
The first request does the real work and saves its answer. The second request finds the key already saved, so it never touches the handler. The client gets the same 201 Created both times and ends up with exactly one order. That is the whole promise kept.
Handling two requests at the very same moment
There is one tricky case. What if the two requests arrive at the same instant, before the first one has finished saving? Both might look up the key, both might find nothing, and both might run the handler. That would create two orders again.
This is a race condition, and it is the part beginners most often miss. The fix is to make the "claim this key" step atomic — only one request can win.
Winning the Key Atomically
Steps
Both arrive
Same key, almost the same millisecond
Atomic claim
An insert or SETNX that only one can succeed at
Winner
Does the real work and saves the response
Loser
Sees it lost, waits briefly, replays the saved reply
With Redis you can use a SET key value NX (set-if-not-exists) to place a short "in progress" marker. With SQL you can rely on a unique constraint on the key column: the second insert fails, and you catch that failure and treat it as "someone else is handling this". Either way, only one request gets to run the handler.
Here is the SQL-style idea in code, using a unique key column to win the claim:
public async Task<bool> TryClaimAsync(string key, string requestHash, CancellationToken ct)
{
try
{
dbContext.IdempotencyKeys.Add(new IdempotencyKeyEntity
{
Key = key,
RequestHash = requestHash,
Status = "InProgress",
CreatedAt = DateTime.UtcNow
});
// The unique index on Key means only ONE insert can win.
await dbContext.SaveChangesAsync(ct);
return true; // We won the claim, we do the work.
}
catch (DbUpdateException) // Unique key violation = someone else won.
{
return false; // We lost, we should wait and replay instead.
}
}This pairs beautifully with database transactions. You can save the idempotency record inside the same transaction as your real business write. Then either both succeed or both roll back, and you never end up with a saved key but no order, or an order with no saved key.
A state machine view of one key
It helps to picture the life of a single idempotency key as a small state machine. A key starts unknown, becomes "in progress" when claimed, and finally becomes "completed" with a stored response.
The Failed path matters. If the handler throws halfway, you do not want to leave the key stuck as "in progress" forever, because then honest retries would be blocked. So on failure you release the claim, letting the client try again with the same key.
Common mistakes to avoid
A few traps catch people again and again. Keep this list close.
- Using an in-memory dictionary. It only works on one server. With two or more servers behind a load balancer, each has its own memory, so duplicates slip through. Always use a shared store like Redis or SQL.
- Never expiring keys. Without a time-to-live, your store grows forever. Pick a sensible window, such as 24 hours.
- Letting the server invent the key. Then the server cannot tell a retry from a brand-new request. The key must come from the client.
- Ignoring the request body. If you do not hash the body, a reused key with new data quietly returns the wrong old response. Store and compare a body hash.
- Forgetting the race condition. Two simultaneous requests need an atomic claim, or you get duplicates anyway.
- Protecting
GETrequests. There is no need. Reads are already safe and adding a filter just slows them down.
When to reach for a library instead
You do not always have to hand-write this. The open-source IdempotentAPI library gives you an [Idempotent] attribute and plugs into IDistributedCache, handling much of the race-condition care for you. It is a solid choice when you want the pattern fast and battle-tested.
Two notes on the wider .NET ecosystem, since teams often combine idempotency with messaging tools. MediatR and MassTransit moved to a commercial license in their recent versions, so check the terms before adding them to a new project. Idempotency itself, though, needs neither — it is just a header, a store, and a filter, all of which ship in the box with ASP.NET Core.
Writing it yourself, as we did above, is genuinely worth doing once. It makes the moving parts clear, and the whole thing is small enough to fully understand in an afternoon.
How idempotency connects to other reliability patterns
Idempotency is one piece of a larger reliability story. It guards the front door of your API against duplicate requests. Two sibling patterns guard the messaging side:
- The Outbox Pattern makes sure a message you intend to publish is never lost, by saving it in the same transaction as your data.
- The Inbox Pattern (the idempotent consumer) makes sure a message you receive is never processed twice, using the same "have I seen this id before?" idea you saw here.
So the same core trick — remember what you have already handled, and skip the repeat — shows up at the API edge and at the message edge. Learn it once and you can apply it everywhere data flows through your system.
Quick recap
- Idempotency means repeating a request gives the same result as doing it once. Retries become safe.
GET,PUT, andDELETEare already idempotent by the HTTP rules.POSTand oftenPATCHare not, so we protect them.- The client sends a unique
Idempotency-Keyheader. The server remembers the response for each key and replays it on repeats. - Use a shared, persistent store like Redis or SQL with a time-to-live (for example, 24 hours). Never use a plain in-memory dictionary across multiple servers.
- Store a hash of the request body so a reused key with different data is rejected, usually with
422. - Handle the race condition with an atomic claim —
SETNXin Redis or a unique constraint in SQL — so only one request runs the handler. - Release the claim on failure so honest retries are not blocked forever.
- A minimal API endpoint filter keeps your business handler clean and lets you protect only the routes that need it.
- Idempotency pairs naturally with the Outbox and Inbox patterns for end-to-end reliability.
References and further reading
- Implementing Idempotent REST APIs in ASP.NET Core — Milan Jovanović
- Idempotent methods — MDN Web Docs (HTTP glossary)
- IdempotentAPI — open-source .NET library on GitHub
- How to implement idempotent APIs in ASP.NET Core — InfoWorld
- Implementing Idempotency in .NET Core — Esau Silva
Related Patterns
The Inbox Pattern in .NET: Handle Each Message Exactly Once
Learn the Inbox Pattern in .NET to stop duplicate messages from causing double charges and double emails. Simple real-life examples, EF Core code, diagrams, and how it pairs with the Outbox Pattern.
The Outbox Pattern in .NET: Never Lose a Message Again
Learn the Outbox Pattern in .NET with simple, real-life examples. Save your data and your messages in one transaction so a broker outage can never lose an event. Includes EF Core code, diagrams, and best practices.
The Result Pattern in .NET: Error Handling Without Exceptions
Learn the Result pattern in .NET for clean, explicit error handling. Replace hidden exceptions with type-safe return values using simple examples, railway-oriented diagrams, code, and clear advice on when to use it.
Best Practices for Building REST APIs in ASP.NET Core
A friendly, beginner guide to REST API best practices in ASP.NET Core with naming, status codes, validation, ProblemDetails, paging, versioning, security, and code.
The Idempotent Consumer Pattern in .NET (And Why You Need It)
A friendly .NET guide to the idempotent consumer pattern: stop duplicate messages from double-charging customers using message ids, transactions, and EF Core.
Idempotent Consumer: Handling Duplicate Messages in .NET
Learn the Idempotent Consumer pattern in .NET to safely handle duplicate messages, prevent double charges, and build reliable message-driven systems.