← Back to the blogHow to Build an Idempotent ASP.NET Core API: Safe Retries Without Duplicate Operations
dotnetaspnet-coreapiidempotencysqlite

How to Build an Idempotent ASP.NET Core API: Safe Retries Without Duplicate Operations

Build a retry-safe .NET 10 API with Idempotency-Key, request fingerprints, SQLite, exact response replay, concurrency handling, and 409/422 responses.

We build a real .NET 10 API where a POST can be repeated without creating two payments: the first request executes the operation, completed retries receive the exact stored response, and incorrect key reuse produces 409 or 422.

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:

  1. an application sends POST /api/payments;
  2. the server creates the payment;
  3. the connection drops before the response arrives;
  4. the client retries;
  5. 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.

Download the working project

SHA-256:

b566e7fad483ff4528503dfd1ac8913f159bd6707f01fe302c5b13e8222f5fa7

After extracting it:

chmod +x verify.sh
./verify.sh

The 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 Request when Idempotency-Key is missing;
  • 201 Created on the first execution;
  • the same 201, body, and identifier on a completed retry;
  • 409 Conflict while the original request is still processing;
  • 422 Unprocessable Content when 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.md

The 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

First execution, replay, and Idempotency-Key conflict

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;
  • processing or completed state;
  • 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:

  1. Missing: insert processing, generate owner_token, and set the lease.
  2. Different hash: return PayloadMismatch.
  3. Completed: return the stored status, content type, and body.
  4. Still locked: return InProgress.
  5. 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=True

and 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: true

The 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 Content

Concurrent 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 Conflict

After 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_key

This 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-transitive

The 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, or DELETE operations 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 processing from completed.
  • Return 409 for an outstanding operation.
  • Return 422 when 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:

  1. identify the client's intent;
  2. verify that the payload did not change;
  3. claim the operation atomically;
  4. distinguish execution, replay, and outstanding work;
  5. commit the effect and response together;
  6. retain the result for a documented window;
  7. 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

Comments

Loading comments…