Designing idempotent APIs: why it matters and how to avoid costly mistakes

Imagine this: you’re on a shopping app, you hit “Place Order,” and the network drops. The button spins. Nothing seems to happen. So, naturally, you tap it again.
Without idempotency in the backend, you’ve just bought the same item twice. Inventory is deducted twice. You get double confirmation. And the warehouse? It’s now shipping you two identical packages.
This is exactly why idempotency in APIs isn’t just a “nice to have” it’s essential to building reliable, user-safe systems.
What Is Idempotency in the Context of APIs?
At its core, an operation is idempotent if performing it multiple times results in the same outcome as performing it once. In practical terms: calling the same API repeatedly (intentionally or not) should not cause unintended side effects.
For example:
- Deleting a user should still return success if you try to delete the same user again.
- Updating a profile with the same data shouldn’t rewrite history every time it’s submitted.
Why Idempotency Matters in Real Systems
Systems don’t live in perfect environments. They deal with:
- Flaky networks
- Mobile clients with auto-retry logic
- Payment processors sending webhook retries
- Users mashing the refresh or submit button
Without safeguards, this leads to:
- Duplicate orders
- Double payments
- Inventory miscounts
- Confused customers and stressed engineers
With proper idempotency in place:
- The first request is processed.
- Any duplicate requests return the same response without triggering the business logic again.
This simple guarantee brings massive stability to systems.
HTTP Methods and Idempotency Know What to Expect
Some HTTP methods are naturally idempotent by design. Others… not so much.
Method | Idempotent? | Notes |
GET | ✅ | Always idempotent. It just retrieves data. |
PUT | ✅ | Replaces a resource. Sending it multiple times is safe. |
DELETE | ✅ | Deleting something that’s already gone is fine. |
POST | ❌ | Creates new resources. Retrying can create duplicates. |
PATCH | ⚠️ | Depends on the logic. Not always idempotent (If you are updating an array as an example). |
So if you’re working with POST
or PATCH
, you'll need to go the extra mile to build in idempotency.
How to Implement Idempotency
There are a few common ways to handle idempotency. It really depends on how the system is structured and where the risk of duplication comes from. Here are three approaches that are often used.
1. Idempotency Keys (Most Common for APIs)
Clients generate a unique key for each request (often a GUID or hash) and attach it to the Idempotency-Key
HTTP header.
How it works:
Server receives the request, checks if it’s seen the key before.
- If yes, return the stored response.
- If no, process the request, store the response with the key, and return it.
This is the industry standard for avoiding duplicate transactions in financial systems, e-commerce platforms, and REST APIs.
2. Database Upserts (INSERT or UPDATE Logic)
Use SQL operations like:
INSERT INTO orders (...)
ON CONFLICT (idempotency_key) DO UPDATE ...
This ensures the request inserts a new row if it’s new, or updates the same row if retried. Many modern ORMs or raw SQL support this.
3. Message ID Tracking (For Messaging/Event Systems)
In event-driven or message queue architectures, you may receive the same message more than once.
Solution: Track message IDs. If one is already processed, just skip it.
Out of the approaches mentioned above, this article focuses on the Idempotency-Key pattern which is especially useful for making HTTP APIs safe against duplicate requests. The following sections walk through how this was implemented in a .NET-based application using Minimal APIs and Redis for fast lookup.
Implementing Idempotency in .NET using the Idempotency-Key Pattern
This implementation is done in a typical client-server application using .NET, with a relational database like SQL Server and Redis added for faster access. The client sends a request with a unique Idempotency-Key
in the header. On the server side, the key is checked in Redis to see if the request was already handled. If it was, the saved response is returned directly. If not, the request is processed, and the response is stored in Redis along with the key, so any duplicate request can be safely ignored.
Redis is used here primarily for performance it allows quick access to previously handled requests without the overhead of hitting a relational database for every check. The same concept can be applied across different backend stacks as well. The key principle remains the same: prevent duplicate side effects by caching responses for repeat requests.
In the following implementation, this is done in a .NET Web API project using Minimal APIs. Since Minimal APIs are becoming more common, the idempotency key checking logic is implemented as an EndpointFilter
. This allows the logic to be cleanly separated and reused it can be easily plugged into any endpoint that needs to be made idempotent.
Here’s what a typical request looks like when a client includes an Idempotency-Key
in the header:
POST /api/items HTTP/1.1
Host: example.com
Content-Type: application/json
Idempotency-Key: 58fa4d7c-b92f-4c9a-ae32-6f4fc5311d77
{
"name": "Wireless Mouse",
"quantity": 2,
"price": 29.99
}
The following code shows how the core logic is handled inside the custom Idempotent Filter.
using System.Text.Json;
using Idempotency.Api.Idempotency;
using Idempotency.Api.Services;
namespace Idempotency.Api.Filters;
internal sealed class IdempotencyFilter(ICacheService cache, int cacheTimeInMinutes = 60)
: IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
// Parse the Idempotence-Key header from the request
if (!TryGetIdempotenceKey(context.HttpContext, out Guid idempotenceKey))
{
return Results.BadRequest("Invalid or missing Idempotence-Key header");
}
if (await cache.ExistsAsync(idempotenceKey.ToString()))
{
var cachedRequest = await cache.GetAsync<IdempotencyRequest>(idempotenceKey.ToString());
if (cachedRequest == null)
{
// Cache exists but couldn't deserialize - remove corrupted entry and continue
await cache.RemoveAsync(idempotenceKey.ToString());
}
else
{
var deserializedValue = DeserializeValue(cachedRequest.Value);
return new IdempotentResult(cachedRequest.StatusCode, deserializedValue);
}
}
object? result = await next(context);
await CacheSuccessfulResponse(result, idempotenceKey);
return result;
}
}
Here’s an explanation of the above code:
- Step 1: Validate the Idempotence-Key
The filter checks if the request contains a validIdempotence-Key
header. If it's missing or invalid, the request is rejected with a400 Bad Request
. - Step 2: Check for Cached Response
If the key exists in the cache: - It tries to deserialize and return the previously stored response.
- If deserialization fails, the corrupted cache entry is removed, and the request continues as normal.
- Step 3: Call the Actual Endpoint Logic
👉object? result = await next(context);
This is where the actual endpoint is invoked. It's the core of the pipeline everything before this line is just a pre-check to decide whether to skip execution. - Step 4: Cache the Response
If the endpoint processed successfully, the response is cached against the idempotency key so future identical requests return the cached result instantly.
Plugging the Idempotency Filter into an Endpoint
To apply the IdempotencyFilter
, you can register it directly on an endpoint like this:
public static class ItemRoutes
{
public static void MapItemRoutes(this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/api/items").WithTags("Items");
group.MapPost("/", CreateItem)
.WithName("CreateItem")
.WithSummary("Create a new item")
.WithDescription("Creates a new item with the provided details")
.AddEndpointFilter<IdempotencyFilter>(); // 👈 Plug in the filter here
}
private static async Task<IResult> CreateItem(CreateItemDto createItemDto, IItemService itemService)
{
var createdItem = await itemService.CreateItemAsync(createItemDto);
return Results.Created($"/api/items/{createdItem.Id}", createdItem);
}
}
Here, MapGroup("/api/items")
helps organize related routes, and .AddEndpointFilter<IdempotencyFilter>()
attaches the idempotency logic to this specific POST
endpoint.
Things to Consider
- Client Responsibility:
The client is responsible for generating and including a validIdempotency-Key
header with the request. The server only validates and uses it and it doesn’t generate one on the client’s behalf. - In-Memory vs Distributed Caching:
If an in-memory cache likeMemoryCache
is used, keep in mind that it won’t work reliably in multi-instance setups (like when your app is running behind a load balancer). For proper scaling, use a shared or distributed cache like Redis. - Same Key with Different Request Bodies:
If a client reuses the sameIdempotency-Key
but changes the request body, the request will still be ignored because the key matches an existing response. To prevent this, store a hash of the request body along with the key. On repeated requests, compare both the key and the hashed payload to ensure the content hasn't changed.
Conclusion
In real-world systems, things break, networks flake out, users double-tap buttons, and retries happen whether you like it or not. Idempotency isn’t just a backend best practice; it’s a safety net that keeps your application sane under pressure. By building idempotent endpoints, especially for sensitive operations like payments or order creation, you ensure that a glitchy moment doesn’t become a costly mistake. No matter the tools or stack, the principle is simple: handle it once, and make it safe to try again. Your users (and your future self) will thank you.