Imagine a customer clicks the Pay button on your website. The request reaches your API, but the response takes too long. The customer clicks the button again, and your API processes the payment twice.
The same problem can happen when a mobile app retries a request after losing its internet connection or when a network timeout occurs after the server has already completed an operation.
These situations are common in real applications. The problem is that the client may not know whether the first request succeeded.
One way to handle this is to use an idempotency key. It helps your API recognize repeated requests and avoid performing the same operation more than once.
In this article, you will learn what idempotency keys are, how to use them in ASP.NET Core, and how to store and validate them safely.
What Is an Idempotency Key?
An idempotency key is a unique value that identifies one operation requested by a client.
The client generates the key and sends it with the API request. The server stores the key along with the result of the operation.
If the client sends the same request again with the same key, the server checks whether that operation has already been processed. If it has, the server can return the saved result instead of performing the operation again.
For example, a payment request might look like this:
POST /api/payments
Idempotency-Key: 9f6a7c21-2d85-4d44-ae35-123456789abc
Content-Type: application/json
The request body might contain:
{
"orderId": 105,
"amount": 2500
}
The client should reuse the same key when retrying this particular payment request.
If the client starts a completely new payment operation, it should generate a new key.
The important rule is that the same operation must use the same idempotency key across retries.
Generating a new key for every retry defeats the purpose because the server will treat each request as a different operation.
Why Duplicate API Requests Are a Problem
Duplicate requests can cause different problems depending on what the API does.
For example:
A payment API may charge a customer twice.
An order API may create two orders for one purchase.
A booking API may reserve the same seat more than once.
An email API may send the same notification repeatedly.
A wallet API may deduct money multiple times.
Not every repeated request is dangerous. Calling an API that returns product details multiple times is usually harmless.
However, requests that create records, move money, reserve resources, or trigger external actions need more careful handling.
Idempotency keys are especially useful for these operations because they let the server distinguish a retry from a new request.
How Idempotency Keys Work
The process is straightforward.
The client generates a unique key for an operation.
The client sends the key with the API request.
The server checks whether the key has already been used.
If the operation has completed, the server returns the previously stored result.
If the key is new, the server processes the operation and saves its result.
If the client retries the request, the server uses the stored result instead of repeating the operation.
A simple example makes this easier to understand.
Suppose a client submits a payment request with key payment-123.
The server processes the payment and stores the result. The response gets lost because of a network problem.
The client sends the same request again with payment-123. Instead of charging the customer again, the API returns the result saved during the first request.
The client receives the response it needs without creating another payment.
Step 1: Create a Table to Store Idempotency Keys
For a production API, idempotency records should be stored somewhere that survives application restarts.
An in-memory dictionary is not enough for most production systems. If the application restarts, the stored keys disappear. It also does not provide reliable coordination between multiple application instances.
A database table is a common choice.
For example, you can create an SQL Server table like this:
CREATE TABLE dbo.IdempotencyRequests
(
Id BIGINT IDENTITY(1,1) PRIMARY KEY,
IdempotencyKey NVARCHAR(100) NOT NULL,
RequestHash VARCHAR(64) NOT NULL,
Status NVARCHAR(20) NOT NULL,
ResponseBody NVARCHAR(MAX) NULL,
CreatedAtUtc DATETIME2 NOT NULL
DEFAULT SYSUTCDATETIME(),
CompletedAtUtc DATETIME2 NULL
);
CREATE UNIQUE INDEX UX_IdempotencyRequests_Key
ON dbo.IdempotencyRequests(IdempotencyKey);
Each record stores the key, a hash of the request, its processing status, and the response when the operation completes.
The unique index is particularly important. It prevents two concurrent requests from successfully creating separate records with the same key.
In a multi-user application, you may need to scope keys by customer or account. In that case, use a unique constraint on the appropriate combination, such as UserId and IdempotencyKey, rather than assuming every key is globally unique.
The table should also have a cleanup policy so old records do not remain forever. The retention period must be long enough to cover the retries your API promises to support.
Step 2: Require an Idempotency Key in ASP.NET Core
Your API should check that the client sends a valid key before processing an operation that requires idempotency.
For example, a controller action can read the key from the request headers:
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("api/payments")]
public class PaymentsController : ControllerBase
{
[HttpPost]
public async Task<IActionResult> CreatePayment(
[FromBody] CreatePaymentRequest request)
{
if (!Request.Headers.TryGetValue(
"Idempotency-Key",
out var key))
{
return BadRequest(new
{
message = "Idempotency-Key header is required."
});
}
string idempotencyKey = key.ToString();
if (string.IsNullOrWhiteSpace(idempotencyKey) ||
idempotencyKey.Length > 100)
{
return BadRequest(new
{
message = "Invalid idempotency key."
});
}
// Process the request through an
// idempotency service.
return Ok(new
{
message = "Request received."
});
}
}
public sealed record CreatePaymentRequest(
int OrderId,
decimal Amount);
This example validates the header but does not yet prevent duplicate payments. That requires storing and checking the key as part of the actual processing workflow.
Also, do not trust the key as proof of a user's identity. Authenticate the caller and check whether the caller is allowed to make the payment.
Step 3: Check the Key Before Processing the Request
When a request arrives, the API needs to check whether the key already exists.
There are three important cases.
Case 1: The key is new.
The server reserves the key and starts processing the operation.
Case 2: The key belongs to a completed operation.
The server returns the saved response without repeating the operation.
Case 3: The key belongs to an operation that is still running.
The server should not start another copy of the operation. Depending on your API design, it can return a conflict response or indicate that the client should retry later.
The database must handle these cases safely when requests arrive at the same time. Checking for a key and inserting it as two unrelated operations is not sufficient.
For example, two requests could both check the table, find no matching key, and then both attempt to process the payment. The unique index helps prevent duplicate key records, but your application must handle the conflict and ensure that only the request that successfully claims the key can execute the operation.
Step 4: Store the Result After Processing
When the operation completes successfully, store its result with the idempotency record.
For example, if a payment API creates payment ID PAY-501, the record might contain:
{
"idempotencyKey": "payment-123",
"status": "Completed",
"responseBody": {
"paymentId": "PAY-501",
"status": "Succeeded"
}
}
When the client retries the request with the same key, the API can return the saved payment result.
The response should represent the original operation, not a newly created payment.
You should also decide how to handle failed operations. Some failures can be safely retried, while others may require the API to return the recorded failure. The behavior should be consistent and documented.
For example, a validation error that occurs before any operation begins may not need a stored idempotency record. A payment that has been submitted to an external provider but has an uncertain result needs more careful handling.
Step 5: Make the Operation and Idempotency Record Consistent
A common mistake is saving the idempotency key in one database operation and creating the payment in another without a transaction or recovery strategy.
Suppose the application saves the key but crashes before creating the payment. A later request may find the key and incorrectly assume that the payment completed.
The reverse problem can also happen. The payment succeeds, but the application crashes before saving the result. A retry may then attempt to create another payment.
For operations stored entirely in the same database, you can often use a database transaction to group the business operation and the final idempotency result.
For example, the transaction can:
Claim the idempotency key.
Create the payment record.
Save the completed result.
Commit all changes together.
If the transaction rolls back, those database changes are rolled back together.
External payment providers are different because their operations cannot normally be rolled back by your SQL Server transaction. In that case, use the same idempotency key with the payment provider when supported, and design the workflow to recover safely when the provider's result is uncertain.
A database transaction alone cannot guarantee exactly-once execution across separate systems.
What If the Same Key Is Used with Different Data?
An idempotency key should identify one specific operation, not any request a client chooses to send.
Suppose a client first sends:
{
"orderId": 105,
"amount": 2500
}
Then the client sends a second request with the same key but changes the amount to 5000.
The API should not silently treat this as a valid retry.
Store a hash of the relevant request data, or otherwise record enough information to verify that the repeated request represents the same operation.
When the same key arrives with different data, reject it with a clear client error, such as HTTP 409 Conflict.
Be consistent about how request data is compared. Hashing raw JSON text can treat harmless formatting differences as different requests. A better approach is to hash a normalized representation of the fields that define the operation.
Common Mistakes to Avoid
Mistake |
Why it causes problems |
Better approach |
Generating a new key for every retry |
Every retry looks like a new operation |
Reuse the original key |
Storing keys only in memory |
Restarts and multiple servers can lose coordination |
Use a durable, shared store |
Checking and inserting without concurrency protection |
Two requests may process the same operation |
Use a unique constraint and safe claim logic |
Returning success for an unfinished operation |
The operation may not have completed |
Track processing status clearly |
Ignoring different request bodies |
The same key may be reused for a different operation |
Validate the request against the stored hash |
Keeping records forever |
The table grows without limit |
Define a suitable retention and cleanup policy |
Assuming a database transaction protects external calls |
An external payment may succeed even if the local transaction fails |
Use provider idempotency and recovery logic |
Best Practices for Production APIs
Follow these practices when implementing idempotency keys:
Use idempotency for important operations. Payments, orders, bookings, and similar operations are good candidates.
Store keys durably. Use a shared database or another reliable store when requests can reach multiple application instances.
Enforce uniqueness in the database. Application-level checks alone are not enough for concurrent requests.
Validate repeated requests. The same key should not be accepted for different operation data.
Save and return the original result. A retry should not create a new business operation.
Handle in-progress requests. Decide what happens when another request arrives before the first operation finishes.
Plan for crashes and timeouts. An interrupted request does not always mean the operation failed.
Define a retention period. Keep records long enough to cover supported retries and document when a key can be used again.
Conclusion
Duplicate API requests are a normal part of unreliable networks, client retries, and slow responses. Without proper handling, they can create duplicate payments, orders, bookings, and other unwanted changes.
Idempotency keys give your ASP.NET Core API a way to recognize repeated requests and return the original result instead of performing the same operation again.
For a reliable implementation, store keys in a durable database, enforce uniqueness, validate that repeated requests contain the same data, and handle concurrent requests carefully. For operations involving external services, add a recovery strategy rather than relying only on a database transaction.
The goal is simple: when a client retries the same operation, your API should handle that retry safely without creating an unwanted second result.
ASP.NET Coreidempotency keysWeb APIduplicate requestsAPI retriesSQL Server
Join the conversation! Your thoughts help the community grow.