We build a real .NET 10 API where a
POSTcan be repeated without creating two payments: the first request executes the operation, completed retries receive the exact stored response, and incorrect key reuse produces409or422.
A timeout does not mean the server failed to execute the operation. It means the client does not know whether it did. That distinction is enough to duplicate a payment, order, or email.
Consider this flow:
- an application sends
POST /api/payments; - the server creates the payment;
- the connection drops before the response arrives;
- the client retries;
- the server creates a second payment.
The retry was reasonable. The API simply had no way to recognize that both requests represented the same intent.
An idempotency key adds that identity:
Idempotency-Key: "payment-order-1001"The API can now distinguish four cases:
- a new operation that must execute;
- a completed retry that must replay the response;
- a duplicate received while the first request is still running;
- a key accidentally reused with another payload.
What we are going to build
The project uses ASP.NET Core 10, Microsoft.Data.Sqlite 10.0.10, and SQLite as durable storage.
SHA-256:
b566e7fad483ff4528503dfd1ac8913f159bd6707f01fe302c5b13e8222f5fa7After extracting it:
chmod +x verify.sh
./verify.shThe script does not simulate behavior through direct class calls. It starts the API, executes real HTTP requests, launches concurrent duplicates, and restarts the process.
Expected output:
Build succeeded.
0 Warning(s)
0 Error(s)
The given project `IdempotencyDemo` has no vulnerable packages.
Missing key: 400
First execution: 201
Completed retry: 201, exact response replayed
Different payload: 422
Concurrent duplicates: 5 x 409 while the owner completed
Persisted replay after restart: 201
Payment rows created: 2
All idempotency checks passed.The demo proves:
400 Bad RequestwhenIdempotency-Keyis missing;201 Createdon the first execution;- the same
201, body, and identifier on a completed retry; 409 Conflictwhile the original request is still processing;422 Unprocessable Contentwhen the same key arrives with another payload;- one payment row per logical operation;
- response persistence after restarting the API;
- a NuGet audit with no vulnerable packages.
Final project structure
aspnetcore-idempotency-demo/
├── Contracts/
│ ├── CreatePaymentRequest.cs
│ ├── PaymentResponse.cs
│ └── PaymentValidation.cs
├── Idempotency/
│ ├── IdempotencyException.cs
│ ├── IdempotencyExceptionHandler.cs
│ ├── IdempotencyExecutor.cs
│ ├── IdempotencyKey.cs
│ ├── IdempotencyModels.cs
│ ├── IdempotencyOptions.cs
│ └── IdempotencyStore.cs
├── Infrastructure/
│ └── SqliteDatabase.cs
├── Payments/
│ └── PaymentRepository.cs
├── IdempotencyDemo.csproj
├── Program.cs
├── appsettings.json
├── verify.sh
└── README.mdThe idempotency flow
sequenceDiagram
participant C as Client
participant A as API
participant S as Idempotency record
participant P as Payment
C->>A: POST /payments + key K + payload P
A->>S: Claim(K, hash(P))
alt K is new
S-->>A: acquired
A->>P: create payment
A->>S: commit payment + 201 response
A-->>C: 201 · Replayed=false
else K + hash(P) already completed
S-->>A: stored response
A-->>C: 201 · Replayed=true
else K + hash(P) still processing
S-->>A: outstanding
A-->>C: 409 Conflict
else K exists with another hash
S-->>A: fingerprint mismatch
A-->>C: 422 Unprocessable Content
end

The animation keeps the same key through all three cases. The side-effect counter moves from 0 to 1 during the first execution and never increments again.
Before the code: the HTTP contract
The latest IETF document is
draft-ietf- httpapi-idempotency-key-header-07. As of August 2026, it is an
expired Internet-Draft —it expired on April 18, 2026—; no -08 revision or
RFC has been published.
The draft defines Idempotency-Key as an Item Structured Field (RFC 8941)
whose value type is a String, so the normative form uses quotes:
Idempotency-Key: "payment-order-1001"The demo also accepts the common unquoted form for interoperability with existing providers and clients, but the article examples follow the draft syntax.
The relevant semantics are:
| Situation | Response |
|---|---|
| A required key is missing | 400 Bad Request |
| First key + fingerprint combination | Process normally |
| Retry after completion | Replay the previous result |
| Same key while the operation is pending | 409 Conflict |
| Same key with another payload | 422 Unprocessable Content |
Expiry remains a server policy. This example retains records for 24 hours and
reports the timestamp through the custom
Idempotency-Key-Expires-At response header.
Idempotency-Replayed is not standardized either. The demo adds it only to
make execution versus replay observable.
1. Validate before reserving a key
The endpoint accepts a small payment command:
public sealed record CreatePaymentRequest(
long AmountCents,
string Currency,
string Reference)
{
public CreatePaymentRequest Normalize() =>
this with
{
Currency = Currency.Trim().ToUpperInvariant(),
Reference = Reference.Trim()
};
}Validation runs before claiming the key. An invalid request should not consume idempotency storage:
Dictionary<string, string[]> errors =
PaymentValidation.Validate(request);
if (errors.Count > 0)
{
return TypedResults.ValidationProblem(errors);
}The payload is normalized next. usd and USD represent the same currency, so
they should produce the same fingerprint.
2. Read and validate Idempotency-Key
The parser requires exactly one header and limits the key to 128 ASCII characters:
public static string Parse(IHeaderDictionary headers)
{
StringValues values = headers["Idempotency-Key"];
if (values.Count == 0)
{
throw IdempotencyException.KeyRequired();
}
if (values.Count != 1)
{
throw IdempotencyException.KeyInvalid(
"Exactly one Idempotency-Key header is required.");
}
string value = values[0]?.Trim() ?? string.Empty;
if (value.Length >= 2 &&
value.StartsWith('"') &&
value.EndsWith('"'))
{
value = value[1..^1];
}
if (!KeyPattern().IsMatch(value))
{
throw IdempotencyException.KeyInvalid(
"Use 1-128 ASCII letters, numbers, dots, underscores, colons, or hyphens.");
}
return value;
}A public API should also require enough entropy—such as a UUID—to avoid accidental collisions. A key must not contain credentials, email addresses, or other personal data: it will be stored and will probably appear in logs.
3. Fingerprint the request
A key alone is not enough. If the client reuses
"payment-order-1001" with another amount, replaying the previous response
would be misleading.
The demo combines the operation name with normalized JSON and computes SHA-256:
private static string CreateFingerprint(
string operationName,
string requestJson)
{
byte[] bytes = Encoding.UTF8.GetBytes(
$"{operationName}\n{requestJson}");
byte[] digest = SHA256.HashData(bytes);
return Convert.ToHexString(digest);
}Including the operation prevents a key used by POST /payments from being
treated as the same intent on another endpoint.
Canonicalization matters
Serializing a normalized record works for this small controlled contract. Do not generalize it to arbitrary raw JSON.
These documents can be semantically equivalent:
{"amount":100,"currency":"USD"}{"currency":"USD","amount":100}Their bytes differ. More flexible contracts should:
- deserialize into a stable model;
- normalize case, whitespace, time zones, and equivalent values;
- use deterministic canonical JSON;
- or fingerprint selected business fields only.
The fingerprint is part of the contract, not an incidental serialization detail.
4. Store state, lease, and response
SQLite contains two tables:
CREATE TABLE idempotency_records (
key TEXT PRIMARY KEY,
request_hash TEXT NOT NULL,
status TEXT NOT NULL
CHECK (status IN ('processing', 'completed')),
owner_token TEXT NOT NULL,
status_code INTEGER NULL,
content_type TEXT NULL,
response_body TEXT NULL,
created_at TEXT NOT NULL,
locked_until TEXT NOT NULL,
expires_at TEXT NOT NULL
);
CREATE TABLE payments (
id TEXT PRIMARY KEY,
idempotency_key TEXT NOT NULL,
amount_cents INTEGER NOT NULL,
currency TEXT NOT NULL,
reference TEXT NOT NULL,
created_at TEXT NOT NULL
);The idempotency record stores:
- request hash;
processingorcompletedstate;- the worker token that owns the lease;
- status code, content type, and response body;
- creation, lock, and expiry timestamps.
The body is stored because a replay must return the same paymentId, not create
a similar object with another identifier or timestamp.
5. Claim the key atomically
ClaimAsync opens a connection and a short transaction:
await using SqliteConnection connection = database.CreateConnection();
await connection.OpenAsync(cancellationToken);
using SqliteTransaction transaction = connection.BeginTransaction();
DateTimeOffset now = DateTimeOffset.UtcNow;
await DeleteExpiredAsync(connection, transaction, now, cancellationToken);
StoredRecord? existing = await ReadAsync(
connection,
transaction,
key,
cancellationToken);Inside that transaction it decides:
- Missing: insert
processing, generateowner_token, and set the lease. - Different hash: return
PayloadMismatch. - Completed: return the stored status, content type, and body.
- Still locked: return
InProgress. - Expired lease: assign a new owner and recover the work.
SQLite permits one pending writer. Microsoft.Data.Sqlite retries busy and
locked errors until the timeout is reached. Every operation opens its own
SqliteConnection; these objects are not thread-safe and must not be shared
between requests.
The connection string configures:
Default Timeout=10;Pooling=Trueand the database uses WAL:
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;6. Commit the effect and response together
After receiving the claim, IdempotencyExecutor decides whether to execute,
replay, or reject:
IdempotencyClaim claim =
await store.ClaimAsync(key, fingerprint, cancellationToken);
switch (claim.Kind)
{
case IdempotencyClaimKind.PayloadMismatch:
throw IdempotencyException.PayloadMismatch();
case IdempotencyClaimKind.InProgress:
throw IdempotencyException.RequestInProgress();
case IdempotencyClaimKind.Replay:
return new IdempotencyExecutionResult(
claim.StatusCode!.Value,
claim.ContentType!,
claim.ResponseBody!,
Replayed: true,
claim.ExpiresAt);
}The first execution opens another transaction. Creating the payment and moving
the record to completed commit together:
await using SqliteConnection connection = database.CreateConnection();
await connection.OpenAsync(cancellationToken);
using SqliteTransaction transaction = connection.BeginTransaction();
PaymentResponse response = await operation(
connection,
transaction,
cancellationToken);
string responseBody = JsonSerializer.Serialize(response, JsonOptions);
int completed = await store.CompleteAsync(
connection,
transaction,
key,
ownerToken,
StatusCodes.Status201Created,
"application/json; charset=utf-8",
responseBody,
cancellationToken);
if (completed != 1)
{
throw IdempotencyException.OwnershipLost();
}
transaction.Commit();If the payment insert succeeds but storing the response fails, the entire transaction rolls back. No payment remains without its idempotent response.
The UPDATE checks owner_token. A worker whose lease was reclaimed cannot
mark the record as completed.
7. Return the exact response
The endpoint adds observability headers:
context.Response.Headers["Idempotency-Key"] = key;
context.Response.Headers["Idempotency-Replayed"] =
result.Replayed ? "true" : "false";
context.Response.Headers["Idempotency-Key-Expires-At"] =
result.ExpiresAt.ToString("O");
return Results.Content(
result.Body,
result.ContentType,
statusCode: result.StatusCode);A completed retry does not call PaymentRepository again. It returns the
persisted bytes with the same status code.
The demo stores successful responses. Production systems must decide and document which errors are stored too. Pre-validation usually should not consume the key; a deterministic business error may be worth storing; a transient infrastructure failure may need to release the claim.
8. Return errors as Problem Details
Idempotency exceptions become application/problem+json through
IExceptionHandler:
ProblemDetails problem = new()
{
Status = idempotencyException.StatusCode,
Title = idempotencyException.Title,
Detail = idempotencyException.Message,
Type = $"https://example.com/problems/{idempotencyException.Code}"
};
problem.Extensions["code"] = idempotencyException.Code;Example of a reused key:
{
"type": "https://example.com/problems/idempotency.key_reused",
"title": "Idempotency-Key is already used",
"status": 422,
"detail": "The same Idempotency-Key cannot be reused with a different request payload.",
"code": "idempotency.key_reused"
}The 409 response also carries Retry-After: 1.
9. Test without a frontend
First execution:
curl -i -X POST http://127.0.0.1:5089/api/payments \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: "payment-order-1001"' \
-d '{"amountCents":2599,"currency":"USD","reference":"order-1001"}'Response:
HTTP/1.1 201 Created
Idempotency-Replayed: false
Content-Type: application/json; charset=utf-8{
"id": "adfc499b-f1b1-4ffd-b7f7-946ad21c1e64",
"amountCents": 2599,
"currency": "USD",
"reference": "order-1001",
"createdAt": "2026-08-08T22:11:28.9393421+00:00"
}Repeat the same command. The API returns:
HTTP/1.1 201 Created
Idempotency-Replayed: trueThe body is identical, including id and createdAt.
Different payload
curl -i -X POST http://127.0.0.1:5089/api/payments \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: "payment-order-1001"' \
-d '{"amountCents":9999,"currency":"USD","reference":"different"}'Result:
HTTP/1.1 422 Unprocessable ContentConcurrent duplicates
verify.sh starts an owner request and sends five duplicates with the same key
and payload while it is waiting.
The measured result is:
owner: 201 Created
duplicate 1: 409 Conflict
duplicate 2: 409 Conflict
duplicate 3: 409 Conflict
duplicate 4: 409 Conflict
duplicate 5: 409 ConflictAfter the owner finishes, another retry receives 201 with
Idempotency-Replayed: true.
Persistence after restart
The script stops the API, starts it again with the same SQLite file, and repeats the first request.
It receives the same paymentId, and the final counter confirms:
{
"count": 2
}There are two rows because the script uses two unique keys: one for the normal flow and another for the concurrent test. The many retries added no rows.
Production considerations
Idempotency is not “exactly once”
This pattern guarantees one local write when the effect and idempotency record share a transaction.
It cannot undo a charge already sent to an external provider before the process failed. For real payments:
- forward an idempotency key to the provider;
- retain the correlation between both keys;
- use an outbox, inbox, or state machine for long workflows;
- reconcile operations whose external result is unknown.
Leases require fencing
The owner_token prevents an old worker from committing the response after it
loses the lease.
It does not prevent that worker from having already executed an external side effect. The lease must exceed normal execution time, and the downstream system must accept a fencing token or its own idempotency key.
Multiple instances need shared storage
A local SQLite file works for one instance. With multiple containers, each replica would own a different registry and could execute the same request.
Use PostgreSQL, Redis with atomic operations, or another shared durable store. The claim operation must remain atomic.
Scope the key correctly
The demo has one endpoint and uses key as the primary key. A real API normally
uses a composite key:
tenant_id + operation + idempotency_keyThis prevents collisions between clients, users, and endpoints.
Define retention and limits
Keeping responses forever grows the table. Document:
- key lifetime;
- maximum stored body size;
- cleanup policy;
- behavior after expiry;
- whether errors are stored;
- which response headers are replayed.
The demo removes expired records opportunistically during a new claim. A service with long idle periods needs a dedicated cleanup job.
Protect sensitive responses
The registry can contain personal data. Apply encryption, access control, minimal retention, and log redaction. Never let one tenant retrieve another tenant's stored response.
Do not store unlimited bodies
Persisting small responses is practical. For files or large responses, store an identifier or an object-storage reference.
Native dependencies count too
During development, the default package initially resolved an older native
SQLite bundle with a known high-severity vulnerability. The final project pins
SQLitePCLRaw.bundle_e_sqlite3 3.0.5, which carries SQLite 3.53.4, and
verify.sh runs:
dotnet list package --vulnerable --include-transitiveThe final audit reports no vulnerable packages.
When I would not use this pattern
You do not need Idempotency-Key for:
- correctly implemented
GET,HEAD,PUT, orDELETEoperations that already have idempotent semantics; - read-only queries;
- internal queue commands that already use inbox deduplication;
- actions where each repetition intentionally creates another result.
I would use it for:
- payments, refunds, and transfers;
- order creation;
- subscription enrollment;
- unique generation of expensive resources;
- webhooks that senders may redeliver;
- mobile commands executed over unreliable networks.
Production checklist
- Require a key with enough entropy.
- Document syntax and compatibility.
- Normalize the payload before fingerprinting it.
- Include tenant and operation in the scope.
- Claim the key atomically.
- Distinguish
processingfromcompleted. - Return
409for an outstanding operation. - Return
422when the fingerprint changes. - Store status, content type, body, and relevant headers.
- Commit the local effect and response in one transaction.
- Use a lease, owner token, and fencing.
- Propagate idempotency to external providers.
- Define expiry, cleanup, and body-size limits.
- Use shared storage across multiple instances.
- Treat stored bodies as sensitive data.
- Test concurrency, crashes, restarts, and real timeouts.
Conclusion
Making a POST safe to retry is not just storing a key in memory.
You need to:
- identify the client's intent;
- verify that the payload did not change;
- claim the operation atomically;
- distinguish execution, replay, and outstanding work;
- commit the effect and response together;
- retain the result for a documented window;
- extend the guarantee to every external system involved.
Idempotency does not remove network failures. It turns the uncertainty they create into a contract that the client and server can resolve without duplicating operations.
Sources
- The Idempotency-Key HTTP Header Field — IETF Datatracker
- draft-ietf-httpapi-idempotency-key-header-07
- RFC 8941 — Structured Field Values for HTTP
- .NET and .NET Core support policy
- Microsoft.Data.Sqlite 10.0.10 — NuGet
- Transactions — Microsoft.Data.Sqlite
- Database errors, locking, and retries — Microsoft.Data.Sqlite
- Handle errors in ASP.NET Core APIs
- RFC 9110 — HTTP Semantics