How to Add an Idempotency-Key Header to a POST Endpoint in ASP.NET Core
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:
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:
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
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:
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:
// 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 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
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}]}
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
}
]
}
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
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}]}'
201
200
200
200
200
200
+----------------+----------------+--------------+
| QuantityOnHand | SumOfMovements | OrdersPlaced |
+----------------+----------------+--------------+
| 15 | 15 | 5 |
+----------------+----------------+--------------+

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
| Mistake | What happens | Do this instead |
|---|---|---|
| No unique index | Two overlapping requests both save | Add it |
| Checking the key only once, outside the delegate | A retry after a lost commit repeats the work | Check inside |
| Using a new key for each retry | Each one looks new | One key per action |
| Keeping the transaction open after the duplicate | The stock it took stays locked | Roll back first |
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
A 400 for the header | It's over the length limit | Keep it to 64 characters |
Duplicate entry reaches the client | Nothing catches it | Add the catch |
| The same key with a different body returns the first order | That's what this code does | Refuse 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
- The Idempotency-Key HTTP Header Field — the draft.
- Transaction commit failure and the idempotency issue — why retries need it.
