Idempotency Keys
6.31TIP
This is the HTTP half of Wolverine's logical message deduplication. Read that page first for the storage, the retention window, and why this is opt-in — everything here builds on it.
A POST that creates something is the classic at-most-once problem. The user double-clicks; the client retries after a timeout it could not distinguish from a failure; a mobile app replays a queued request when connectivity returns. Each is a separate HTTP request that means the same thing, and without an identity for that meaning, all of them create a record.
Wolverine's answer is the conventional Idempotency-Key request header — the same header Stripe, Adyen, and the IETF draft already use, so a client that already sends one gets this for free.
Deduplicating an endpoint
[Deduplicated]
[WolverinePost("/orders")]
public static async Task<OrderCreated> Post(CreateOrder command, IDocumentSession session)
{
// create the order...
}With Durability.EnableMessageDeduplication turned on, that endpoint now:
- runs normally for the first request carrying a given
Idempotency-Key - returns 409 Conflict with a
ProblemDetailsbody for any later request carrying the same key within the deduplication window - returns 400 Bad Request with a
ProblemDetailsbody for a request carrying no key at all
Both refusal codes are registered as endpoint metadata, so they appear in the generated OpenAPI document. A 409 a client can receive but cannot discover from the spec is a contract change hidden from exactly the people who have to handle it.
When a replay is benign
409 is the right default — it tells the caller plainly that this request was not the one that did the work. But some endpoints are genuinely safe to replay, and the caller would rather see success:
// The second call gets a 204 rather than a 409
[Deduplicated(DuplicateStatusCode = 204)]
[WolverinePost("/schedules/{scheduleId}/occurrences")]
public static async Task Post(string scheduleId, ScheduleOccurrence body)
{
// ...
}Any 2xx code is written as a bare status with no body. Anything else is written as a problem document, so a refusal always carries a machine-readable reason rather than a status code the caller has to guess at.
Using a different key
The header name and the source are both configurable, exactly as for message handlers:
// A different header
[Deduplicated("X-Request-Id")]
// A member of the request body
[Deduplicated(ValueSource.InputMember, nameof(CreateOrder.ClientReference))]
// A route value
[Deduplicated(ValueSource.RouteValue, "occurrenceId")]Optional keys
Required defaults to true, so an unkeyed request is a 400. Set it to false when some clients send a key and some do not — the ones that do are protected, and the ones that do not are handled exactly as if the feature were off:
[Deduplicated(Required = false)]
[WolverinePost("/orders")]
public static async Task<OrderCreated> Post(CreateOrder command) { /* ... */ }What this is not
This is not full Stripe-style idempotency-key support. Wolverine does not store the original response and replay it to the second caller; it tells the second caller that the work was already done. That is enough to make a create endpoint safe to retry, and it is considerably less machinery than storing and versioning response bodies.
If a client genuinely needs the original response body back, it has to fetch the created resource — which is why returning a Location header from the first request is worth doing.
Failed requests do not poison the key
If your endpoint throws, the claim is released, and a retry with the same Idempotency-Key gets through. Where the endpoint carries transactional middleware the claim was written inside that transaction and rolls back with it; otherwise Wolverine issues a compensating release.

