Skip to main content

How to Add an Idempotency-Key Header to a POST Endpoint in ASP.NET Core

· 6 min read
Jagdish Kumawat
Founder @ Dewiride

A client that gets no answer can't tell whether its POST worked. If it tries again, you can get two orders. An Idempotency-Key header fixes that: the client sends the same key each time, and the second request gets back what the first one made.

Prerequisites​

  • An ASP.NET Core API using EF Core. I used ASP.NET Core 10, EF Core 10.0.14 and MySQL 9.7.4.

Step 1: Store the Key​

Add a column with a unique index. The index is what settles two requests that arrive together:

src/Dewiride.Inventory.Data/Configurations/StockMovementConfiguration.cs (excerpt)
builder.Property(m => m.IdempotencyKey)
.HasMaxLength(64);

// MySQL allows many NULLs in a unique index, so movements without a key never clash.
builder.HasIndex(m => m.IdempotencyKey)
.IsUnique();

Create the migration, and apply it:

Terminal
dotnet ef migrations add AddStockMovementIdempotencyKey --project src/Dewiride.Inventory.Data --startup-project src/Dewiride.Inventory.Api
dotnet ef database update --project src/Dewiride.Inventory.Data --startup-project src/Dewiride.Inventory.Api
Output (the SQL from the migration)
ALTER TABLE `stock_movements` ADD `IdempotencyKey` varchar(64) NULL;
CREATE UNIQUE INDEX `IX_stock_movements_IdempotencyKey` ON `stock_movements` (`IdempotencyKey`);

The orders table has the same column and index, the same way.

Step 2: Read the Header​

[FromHeader] reads it, with a length limit so it fits the column. It's nullable, because the header is optional:

src/Dewiride.Inventory.Api/Features/Orders/OrderEndpoints.cs (excerpt)
private static async Task<Results<Created<OrderResponse>, Ok<OrderResponse>, ProblemHttpResult>> PlaceAsync(
PlaceOrderRequest request,
[FromHeader(Name = "Idempotency-Key"), StringLength(64)] string? idempotencyKey,
InventoryDbContext db,
TimeProvider clock,
CancellationToken cancellationToken)

Step 3: Check First, Then Catch the Race​

At the start of each attempt, look for the key. After the work, catch the duplicate:

src/Dewiride.Inventory.Api/Features/Orders/OrderEndpoints.cs (excerpt)
// Every attempt below shares this key. With no key from the client, it still protects
// the request from itself when a retry repeats an attempt that really did commit.
var key = idempotencyKey ?? Guid.NewGuid().ToString("N");

// ...inside the strategy's delegate...

// A repeat of a request that already worked returns the same order. This is what
// makes a retry safe when the connection dropped before the COMMIT's reply arrived.
if (await FindByIdempotencyKeyAsync(db, key, ct) is { } existing)
{
return TypedResults.Ok(existing);
}

// ...the transaction, then...

try
{
await db.SaveChangesAsync(ct);
}
catch (DbUpdateException exception) when (exception.IsDuplicateKey())
{
// Another request with the same key committed first. Undo this attempt,
// including the stock it took, and return the order that request placed.
await transaction.RollbackAsync(ct);

var placedFirst = await FindByIdempotencyKeyAsync(db, key, ct);

return TypedResults.Ok(placedFirst!);
}

The code in a macOS-style code window: a key from the header or a new Guid, a lookup returning the existing order, and a catch for the duplicate key that rolls back and returns the first order.

The check handles a repeat that comes later, and the catch handles two that overlap, where neither sees the other's row yet.

Step 4: Send It Twice​

Request
POST http://localhost:5190/api/v1/orders
Content-Type: application/json
Idempotency-Key: order-0001

{"customerName":"Ben Silva","customerEmail":"ben@example.com","lines":[{"productId":14,"quantity":1}]}
Response (first)
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Location: /api/v1/orders/283

{
"id": 283,
"customerName": "Ben Silva",
"customerEmail": "ben@example.com",
"status": "Placed",
"totalAmount": 89.00,
"placedAtUtc": "2026-11-05T05:22:03.806515Z",
"fulfilledAtUtc": null,
"cancelledAtUtc": null,
"lines": [
{
"productId": 14,
"quantity": 1,
"unitPrice": 89.00
}
]
}
Response (second)
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8

{
"id": 283,
"customerName": "Ben Silva",
"customerEmail": "ben@example.com",
"status": "Placed",
"totalAmount": 89.00,
"placedAtUtc": "2026-11-05T05:22:03.806515Z",
"fulfilledAtUtc": null,
"cancelledAtUtc": null,
"lines": [
{
"productId": 14,
"quantity": 1,
"unitPrice": 89.00
}
]
}

Same order, 283. The first is 201 Created, and the second is 200 OK.

Step 5: Send It Six Times at Once​

Terminal
seq 6 | xargs -P 6 -I{} curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:5190/api/v1/orders -H "Content-Type: application/json" -H "Idempotency-Key: order-0002" -d '{"customerName":"Ben Silva","customerEmail":"ben@example.com","lines":[{"productId":14,"quantity":1}]}'
Output
201
200
200
200
200
200
Output (the quantity, the sum of the movements, and the orders)
+----------------+----------------+--------------+
| QuantityOnHand | SumOfMovements | OrdersPlaced |
+----------------+----------------+--------------+
| 15 | 15 | 5 |
+----------------+----------------+--------------+

Six simultaneous requests with one key giving one 201 and five 200, and one more order with one unit taken.

One 201, five 200, and one order.

Step 6: Tell Clients How to Use It​

Generate one key per user action, such as one click on "Place order", and reuse it for every retry of that action. A new action gets a new key. The IETF draft describes the header: "An idempotency key is a unique value generated by the client which the resource uses to recognize subsequent retries of the same request."

Common Mistakes​

MistakeWhat happensDo this instead
No unique indexTwo overlapping requests both saveAdd it
Checking the key only once, outside the delegateA retry after a lost commit repeats the workCheck inside
Using a new key for each retryEach one looks newOne key per action
Keeping the transaction open after the duplicateThe stock it took stays lockedRoll back first

Troubleshooting​

SymptomLikely causeFix
A 400 for the headerIt's over the length limitKeep it to 64 characters
Duplicate entry reaches the clientNothing catches itAdd the catch
The same key with a different body returns the first orderThat's what this code doesRefuse it if your clients need that

Conclusion​

Store the key with a unique index, look for it at the start of each attempt, and catch the duplicate for the race. Clients can then repeat a POST without fear, and so can your own retry.

Additional Resources​

Stay Updated

Subscribe to our newsletter for the latest tutorials, tech insights, and developer news.

By subscribing, you agree to our privacy policy. Unsubscribe at any time.