Network is unreliable. A POST /orders request might submit, time out before the response arrives, and get retried. Without idempotency, the retry creates a second order. With it, the retry returns the same response as the first.
The kit ships an Idempotency-Key header convention. Callers send a fresh key with every request that mutates state; the kit caches the response (status + body) keyed on (tenant, route, key) for a configurable window. Duplicate requests within the window return the cached response without re-executing the handler.
Opt an endpoint in
The Web block ships a .WithIdempotency() extension on RouteHandlerBuilder:
endpoints.MapPost("/orders", handler) .RequirePermission(perm) .WithIdempotency();That’s the whole opt-in. The kit’s IdempotencyEndpointFilter runs before the handler and after the response is generated:
- Before: read
Idempotency-Keyheader. If present, build(tenant, route, key)cache key. Check the cache. - If cached: return the cached response without invoking the handler.
- If not: invoke the handler. After the response is written, capture status code + body and store under the cache key with a TTL.
If no header is sent, the filter is a no-op — the endpoint behaves like an ordinary one.
Configuration
{ "IdempotencyOptions": { "Enabled": true, "RetentionWindow": "24:00:00", "MaxBodyBytes": 65536 }}AddHeroPlatform(o => o.EnableIdempotency = true) wires the filter. The cache itself uses HybridCache, so the L2 (Valkey) backing means cached responses survive across instances. Without Valkey you get per-instance idempotency, which is OK for dev but not for multi-instance production.
What clients send
POST /api/v1/orders HTTP/1.1Authorization: Bearer <token>tenant: acmeIdempotency-Key: 7f3d9a2c-1b8e-4f5a-9c0d-2e8f6b1a3d4cContent-Type: application/json
{ "items": [...] }The key should be a fresh UUID per logical request:
- A retry of the same request uses the same key — gets the cached response.
- A different request (even from the same client) uses a fresh key — gets a fresh response.
Most HTTP client libraries can generate keys automatically; for HttpClientFactory consumers, a DelegatingHandler is a clean place to do it.
What it doesn’t do
- It doesn’t dedupe content. If the client sends two requests with different keys and identical bodies, both create resources. Idempotency keys are per-request-attempt identifiers, not content hashes. (Use a content hash + lookup if you want content dedupe — but it’s a different feature.)
- It doesn’t span the request boundary forever. The retention window is configurable; after it expires, a replayed request runs again. Set the window to whatever is realistic for your client retry strategy — 24 h is the default; 7 days is reasonable for batch / async flows.
- It doesn’t catch partial failures. If the handler runs, mutates state, then crashes before the response is captured, the cache won’t have an entry. The retry runs again. Combine idempotency with idempotent domain operations for true safety.
Domain-level idempotency
The kit’s domain already has several naturally-idempotent operations:
Notification.MarkRead()—ReadAtUtc ??= now; second call is a no-op.Ticket.Reopen()— guarded against illegal source states; second call from a valid state is harmless.WebhookSubscriptiondeactivation — setsIsActive = false; idempotent.Find-or-create DMchannels —DirectKeyuniqueness makes “find or create” race-safe.
Layer Idempotency-Key on top of these for full retry safety across both transport and domain failure modes.
Gotchas
- The cache key includes tenant, so two tenants using the same idempotency key get separate responses. This is correct; tenants are independent.
MaxBodyBytesdefaults to 64 KB. Responses larger than this aren’t cached (and a warning logs). For large responses, raise the cap or skip idempotency on that endpoint.- Cache miss after restart is normal. If you restart the host and Valkey isn’t running, the in-memory L2 starts empty — replayed requests run again. Always use Valkey in production.
Related
- Caching — the HybridCache that backs the replay store.
- HTTP resilience — caller-side retry policy that needs idempotency to be safe.
- Web building block — the filter implementation.