Skip to content

Idempotency Keys: Why Every POST Should Be Retry-Safe

Your payment endpoint runs. The database row is inserted. The response is on its way back — and the connection drops. The client retries. Now you have two charges for the same order. The customer notices before you do.

This is not a theoretical edge case. It happens every day on every API that accepts POST requests over an unreliable network (all of them). The HTTP specification already solved this for GET, PUT, and DELETE — they are idempotent by definition. POST is the outlier. Idempotency keys close that gap.

An idempotency key is a client-generated unique identifier attached to a request via a header. The server uses it to recognize retries and return the original response instead of executing the operation again.

POST /api/v1/payments/charge
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
Content-Type: application/json
{ "orderId": "ORD-42", "amount": 149.99, "currency": "EUR" }

If the server has seen this key before, it returns the cached response — same status code, same body, same headers. The client cannot tell the difference between a fresh response and a replay. That is the point.

The key must come from the client because only the client knows whether a request is new or a retry. The server cannot distinguish a legitimate duplicate (user clicked “Pay” twice) from a network retry (load balancer timed out, client resent). The key is the client’s declaration: “this is the same logical operation.”

In practice, a UUID v4 generated before the first attempt works. The client stores it alongside the request and reuses it on retry. Some teams use a deterministic key derived from business data (SHA-256(orderId + amount + currency)) — this prevents the “same intent, different key” problem when the client forgets to store the original.

The three failure modes without idempotency

Section titled “The three failure modes without idempotency”

Every non-idempotent POST is vulnerable to three scenarios:

ScenarioWhat happensImpact
Network retryLoad balancer times out, resends the requestDouble charge, duplicate record
Client retryMobile app loses connectivity, user taps againSame as above, plus user frustration
Saga compensationOrchestrator retries a failed step after partial successInconsistent state across services

All three produce the same symptom: duplicate side effects. The fix is the same for all three: recognize the retry, return the original result.

Granit.Http.Idempotency implements the full Stripe-style idempotency pattern as ASP.NET Core middleware. The state machine has three states:

flowchart TD
    Start([Request arrives])
    HasKey{Idempotency-Key<br/>header present?}
    Normal[Execute normally<br/>no idempotency]
    State{Cached state?}
    Absent[SET NX — acquire lock<br/>Execute handler<br/>Cache response]
    InProgress[Return 409 Conflict<br/>+ Retry-After header]
    Completed{Payload hash<br/>matches?}
    Replay[Return cached response<br/>replay]
    Mismatch[Return 422<br/>key reuse, different body]

    Start --> HasKey
    HasKey -- no --> Normal
    HasKey -- yes --> State
    State -- Absent --> Absent
    State -- InProgress --> InProgress
    State -- Completed --> Completed
    Completed -- match --> Replay
    Completed -- mismatch --> Mismatch

    style Start fill:#e8f0ff,stroke:#1f4e8c,color:#0b1c38
    style Normal fill:#e2f5e2,stroke:#1a7a1a,color:#0b3a0b
    style Absent fill:#e2f5e2,stroke:#1a7a1a,color:#0b3a0b
    style InProgress fill:#fff4e0,stroke:#a15c00,color:#3a2000
    style Replay fill:#e2f5e2,stroke:#1a7a1a,color:#0b3a0b
    style Mismatch fill:#fde2e2,stroke:#a31515,color:#3a0000

Decorate any endpoint with [Idempotent]:

PaymentEndpoints.cs
group.MapPost("/charge", ChargeAsync)
.WithName("ChargePayment")
.WithSummary("Initiates a payment charge.")
.WithDescription(
"Charges the specified amount against the payment method on file. " +
"Requires an Idempotency-Key header to prevent duplicate charges.")
.Produces<PaymentChargeResponse>(StatusCodes.Status201Created)
.ProducesProblem(StatusCodes.Status409Conflict)
.ProducesProblem(StatusCodes.Status422UnprocessableEntity)
.WithMetadata(new IdempotentAttribute { Required = true });

Required = true means the middleware rejects requests without the header — returning 400 instead of silently executing without protection. For payment endpoints, this is the right default.

appsettings.json
{
"Idempotency": {
"HeaderName": "Idempotency-Key",
"CompletedTtl": "24:00:00",
"InProgressTtl": "00:00:30",
"ExecutionTimeout": "00:00:25",
"MaxBodySizeBytes": 1048576
}
}
OptionDefaultPurpose
HeaderNameIdempotency-KeyHTTP header name
CompletedTtl24 hoursHow long a completed response is cached
InProgressTtl30 secondsLock duration for in-flight requests
ExecutionTimeout25 secondsMax time before the lock is released on timeout
MaxBodySizeBytes1 MiBMaximum request body size for hash computation

The ExecutionTimeout must be less than InProgressTtl. If the handler takes longer than ExecutionTimeout, the middleware releases the lock and returns 503 — allowing the client to retry safely.

Program.cs
await app.UseGranitAsync();
app.UseGranitIdempotency(); // After authentication — needs ICurrentUserService

Placement matters: the middleware must run after authentication and authorization because the composite Redis key includes the tenant ID and user ID.

The Redis key is not just a hash of the idempotency header. It is a composite of five elements:

{prefix}:{tenantId}:{userId}:{METHOD}:{routePattern}:{sha256(idempotencyKey)}

Example: idp:acme-corp:d4e5f6a7:POST:/api/v1/payments/charge:a1b2c3d4...

This structure prevents three classes of attack:

  • Cross-tenant replay — tenant A cannot reuse tenant B’s idempotency key
  • Cross-user replay — user A cannot replay user B’s cached response
  • Cross-endpoint collision — the same key on /charge and /refund are independent

The idempotency key value itself is SHA-256 hashed before storage. This prevents enumeration attacks — even with Redis access, an attacker cannot reconstruct client-generated keys.

Payload hash: catching key reuse with a different body

Section titled “Payload hash: catching key reuse with a different body”

A common mistake: the client sends the same idempotency key with a different request body. Maybe the user changed the amount and retried. The server must not return the cached response for the original amount.

Granit computes a SHA-256 digest over METHOD + route + idempotency key + request body using IncrementalHash and ArrayPool<byte> — zero-allocation streaming. If the hash does not match the cached entry, the middleware returns 422 Unprocessable Entity:

422 response
{
"type": "https://tools.ietf.org/html/rfc7807",
"title": "Idempotency payload mismatch",
"status": 422,
"detail": "The Idempotency-Key has already been used with a different request payload."
}

The client must generate a new key for the modified request. This is correct behavior — changing the payload means it is a different logical operation.

When two identical requests arrive simultaneously (the user double-tapped “Pay”), the first request acquires the lock via SET NX. The second request finds the key in InProgress state and receives 409 Conflict with a Retry-After header:

HTTP/1.1 409 Conflict
Retry-After: 2
Content-Type: application/problem+json
{
"type": "https://tools.ietf.org/html/rfc7807",
"title": "Request in progress",
"status": 409,
"detail": "A request with this Idempotency-Key is currently being processed."
}

The client waits 2 seconds and retries. By then, the first request has completed and the retry receives the cached response. No double charge. No race condition.

Not every response should be replayed. Authentication failures, for example, should never be cached — the user might fix their token and retry legitimately.

Status codesCachedRationale
2xx, 400, 404, 409, 410, 422YesDeterministic outcomes safe to replay
401, 403NoAuth state may change between retries
5xxNoTransient failures should be retried fresh
499 (client disconnect)NoResponse never reached the client

When a 5xx occurs, the middleware deletes the lock — freeing the key for a clean retry.

Database-level idempotency: defense in depth

Section titled “Database-level idempotency: defense in depth”

HTTP-level idempotency handles the network layer. But what about the database? If two pods process the same payment before the Redis lock propagates, you still get duplicates.

Granit uses unique indexes as a second line of defense in domains where duplication has financial impact:

PaymentTransactionConfiguration.cs
builder.HasIndex(e => new { e.TenantId, e.IdempotencyKey })
.IsUnique()
.HasDatabaseName("uq_granitp_transactions_tenant_idempotency");
MeterEventConfiguration.cs
builder.HasIndex(e => new { e.TenantId, e.IdempotencyKey })
.IsUnique()
.HasDatabaseName("ix_granitm_meter_events_dedup");

The database constraint catches what the middleware misses. The middleware prevents unnecessary database round-trips. Together, they provide defense in depth — no single layer failure can produce duplicates.

The middleware exposes four OpenTelemetry counters:

MetricWhat it tells you
granit.http.idempotency.lock_acquiredSuccessful lock acquisitions (new requests)
granit.http.idempotency.conflicts409 responses (concurrent duplicates)
granit.http.idempotency.replayedCached responses served (successful dedup)
granit.http.idempotency.hash_mismatches422 responses (key reuse with different payload)

A spike in replayed means clients are retrying heavily — check upstream timeouts. A spike in hash_mismatches means clients are reusing keys incorrectly — check the SDK documentation.

A well-behaved client follows this pattern:

api-client.ts
async function chargePayment(order: Order): Promise<PaymentResponse> {
const idempotencyKey = crypto.randomUUID();
for (let attempt = 0; attempt < 3; attempt++) {
const response = await fetch("/api/v1/payments/charge", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey, // Same key on every retry
},
body: JSON.stringify({
orderId: order.id,
amount: order.total,
currency: "EUR",
}),
});
if (response.status === 409) {
const retryAfter = parseInt(response.headers.get("Retry-After") ?? "2");
await sleep(retryAfter * 1000);
continue;
}
return response.json();
}
throw new Error("Payment request failed after 3 attempts");
}

The key is generated once, before the loop. Every retry sends the same key. If the server returns 409, the client respects Retry-After. This is the entire client-side contract.

  • Every POST endpoint that mutates state needs idempotency protection. Network retries are not edge cases — they are normal operation.
  • The client generates the key because only the client knows whether a request is new or a retry.
  • Payload hashing prevents key reuse with a different body — a common source of subtle bugs.
  • Double-click protection (409 + Retry-After) handles concurrent duplicates without data corruption.
  • Defense in depth: HTTP middleware + database unique indexes cover both network-level and infrastructure-level races.
  • Granit.Http.Idempotency implements the full pattern — state machine, tenant isolation, payload verification, observability — in a single middleware call.