
Category:.NET
Idempotency keys for APIs: stop duplicate orders, emails, and writes
When retries create duplicate side effects, idempotency keys are the only safe fix. This playbook shows how to design keys, store results, and prove duplicates cannot recur.
Free download: Idempotency key contract template (.NET). Jump to the download section.
Paid pack available. Jump to the Axiom pack.
You see the worst kind of incident: “everything is green” but customers are harmed. A gateway returns a 502/504, the client retries (correctly), and your API executes the side effect twice. Support now has duplicate orders, duplicate emails, and a backlog of manual cleanup — and engineering can’t prove what happened without reconstructing it from partial logs.
That is not a client bug. It’s a server-side safety gap. If retries exist anywhere in the chain (client SDK, gateway, queue consumer, operator replays), side effects must be protected against duplication. Idempotency keys are the smallest fix that works in a live .NET API without a rewrite.
- Require an idempotency key on any endpoint with side effects.
- Store the key and the result before returning success.
- Return the same response for repeated keys within a safe TTL.
Why retries create duplicate side effects
Retries are normal in production. Networks drop packets. Dependencies time out. Clients retry because they should. The problem is that most APIs treat every retry as a new request and execute the side effect again.
If an order creation endpoint inserts a record and sends a confirmation email, a retry can insert a second order and send a second email. This is not a bug in the client. It is a missing safety guard in the server.
Idempotency keys solve this by turning multiple identical requests into one logical operation. The server recognizes a repeated key and returns the stored result instead of executing the side effect again.
The incident pattern this playbook targets
- Duplicate orders after a timeout or 500.
- Duplicate emails or notifications after a retry.
- Partial success where the client is unsure if it should retry.
- Manual cleanup after a production incident.
If any of those are real in your system, idempotency is not optional.
Mini incident timeline
- A partial outage causes intermittent 502/504 at the edge.
- Clients and upstream services retry. Some original requests actually succeeded, but the response never made it back.
- The retry replays the write and you now have duplicate rows / duplicate side effects.
- Cleanup starts without evidence: which retries were replays vs. genuinely new operations.
A single idempotency key would have turned the retry into a replayed response instead of a second side effect.
Diagnosis ladder for duplicate side effects
- Do retries exist. If the client retries on timeout or 5xx, duplicates are possible.
- Is there a server side idempotency key. If not, duplication is guaranteed under failure.
- Is the key persisted before side effects. If not, a crash can still duplicate.
- Is the response cached and returned on repeats. If not, clients will keep retrying.
- Are keys scoped to a caller. If not, one client can block another.
Fast triage table: symptom → likely cause → confirm → fix
| Symptom | Likely cause | Confirm | Fix (minimum) |
|---|---|---|---|
| Duplicate orders/emails after 502/504 | Client/gateway retries replayed the side effect | Same user action produces 2 records; edge logs show retries | Require Idempotency-Key for the endpoint and replay stored response |
| Duplicates happen even with a key | Key not persisted before success or response not replayed | Logs show key but handler still executes | Persist key + result atomically; return cached response on repeats |
| 409 conflicts spike | Same key reused with different request body | Same key but different payload hash | Store request_hash; return 409 on mismatch |
| One customer blocks another | Keys not scoped to client/account | Different client ids share the same key | Scope unique key by (client_id, idempotency_key) |
| Duplicates across multi-step side effects | No outbox/ledger; partial side effects | Order created but email/payment duplicated | Use outbox/ledger for multi-step operations; keep idempotency for API boundary |
| Storage grows too fast | TTL missing or too long | Table never shrinks; high cardinality keys | TTL + cleanup job; cap retention (start ~24h) |
Common misconceptions that cause duplicate orders
The bad fixes are predictable:
- “Disable retries.” That trades duplicate harm for downtime and timeouts. Retries are normal; you need to make them safe.
- “POST can’t be safe.” POST can be safe when the server treats repeated requests as one logical operation.
- “A unique constraint is enough.” Constraints help, but they don’t replay the original response and they don’t cover multi-step side effects. You still need a key store + stored result.
- “Idempotency is a big redesign.” The minimal version is bounded: validate key, store result, replay response for repeats within a TTL.
A minimal idempotency design for .NET APIs
Definition (for clarity): an idempotency key is a client-provided operation id that lets the server treat retries as a replay of the same logical operation. Definition: a request hash is a stable fingerprint of the request payload used to detect “same key, different intent.” Definition: response replay means returning the original stored response for repeated keys instead of running the side effect again.
Key rules
- Clients generate a unique key per logical operation.
- The key is sent in a header such as
Idempotency-Key. - The server stores the key and response before returning success.
- Repeated keys return the stored response, not a new side effect.
Key scope
- Scope the key by client or account.
- This prevents one client from blocking another by reusing keys.
TTL
- Keep idempotency records long enough to cover retries and delayed responses.
- A common starting point is 24 hours, adjusted by business risk.
Minimal implementation sketch (safe replay)
The minimum is: validate key, scope it to a caller, store (key, request_hash, status, response) and replay on repeats.
Storage schema (example)
CREATE TABLE idempotency_requests (
client_id TEXT NOT NULL,
idempotency_key TEXT NOT NULL,
request_hash TEXT NOT NULL,
status TEXT NOT NULL, -- in_progress | completed
response_status_code INTEGER NULL,
response_body TEXT NULL,
created_at_utc TIMESTAMP NOT NULL,
expires_at_utc TIMESTAMP NOT NULL,
PRIMARY KEY (client_id, idempotency_key)
);C# flow (pseudocode, but implementable)
// 1) Read headers + identify caller
var key = Request.Headers["Idempotency-Key"].ToString();
var clientId = GetClientIdFromAuth(User);
// 2) Hash the request body (canonicalize JSON first in real code)
var body = await ReadBodyAsync(Request);
var requestHash = Sha256Hex(body);
// 3) Try to load existing record
var existing = await store.TryGetAsync(clientId, key);
if (existing is not null)
{
if (existing.RequestHash != requestHash)
return Conflict(new { error = "Idempotency key reuse with different request" });
if (existing.Status == "completed")
return StatusCode(existing.ResponseStatusCode, existing.ResponseBody);
// in_progress: either wait, return 409/202, or return a standard “still processing” response
return StatusCode(409, new { error = "Request with this idempotency key is in progress" });
}
// 4) Create an in-progress record BEFORE running side effects
await store.CreateInProgressAsync(clientId, key, requestHash, expiresAtUtc: DateTime.UtcNow.AddHours(24));
// 5) Run the side effect once
var result = await handler.ExecuteSideEffectAsync(body);
// 6) Persist the response so repeats can be replayed
await store.MarkCompletedAsync(clientId, key, responseStatusCode: 201, responseBody: result);
return StatusCode(201, result);The key invariant: repeats must return the stored response (replay) and must not re-run the operation.
Example: a simple idempotency contract
Idempotency-Key: 1c7f8c4a-5b4a-4d9d-9dd9-948f92f2c5b4Store it with a hash of the request body and the response metadata. When a repeat request arrives with the same key and same body hash, return the stored response. If the body hash does not match, return a 409 and stop the duplicate.
Fix plan: roll out idempotency without breaking production
Phase 1: Require keys on the most painful endpoint
- Start with the endpoint that caused duplicates.
- Accept the key but log when it is missing.
- Do not block requests yet.
Phase 2: Enforce keys and store results
- Reject requests without a key for the protected endpoint.
- Store the response and return it on repeat.
- Add metrics for duplicate requests prevented.
Phase 3: Expand and standardize
- Roll out to other side effect endpoints.
- Add a shared middleware for key validation and storage.
- Add a runbook for incidents involving duplicates.
What to log so duplicates are provable
If you do not log the key, you will not be able to prove duplicates or prevent them. These fields are the minimum:
idempotency_keyidempotency_result(stored, replayed, conflict)request_hashstatus_codeduration_msclient_id
Log these fields on every request that uses a key. This turns duplicate detection from guesswork into evidence.
Tradeoffs and limits
Idempotency is not free. You need storage and a TTL policy. You must decide how long to keep the record and how to handle conflicts. But it is bounded work compared to the cost of duplicate orders and manual cleanup.
The other limit is partial side effects. If your system has multiple side effects, you may need a ledger or outbox. The minimal approach here still reduces duplicates but may not cover every cross system scenario. Use it to stop the bleeding, then harden further.
Shipped asset
Idempotency key contract template for .NET APIs
Key format, storage checklist, and response replay rules (free, email delivery)
- You have endpoints with side effects (create/update, emails, payments) and retries exist anywhere upstream.
- You’ve seen (or can’t rule out) “request succeeded but client didn’t get the response” scenarios.
- You need to prove duplicates can’t recur during incidents.
- The endpoint is read-only (no side effects).
- You’re trying to replace an outbox/ledger for multi-step cross-system side effects (you may need both).
- You can’t store the response (start by storing status + operation id, then evolve to full replay).
What you get (4 files):
idempotency-contract-template.mdidempotency-key-strategy.mdrequest-cache-schema.sqlREADME.md
Idempotency Implementation Playbook
Dealing with duplicate writes across multiple services or side effects? Get schemas, outbox patterns, and verification steps that prove retries can’t re-run the operation.
- ✓Storage schema + TTL guidance for safe replay at scale
- ✓Outbox / ledger patterns for multi-step side effects
- ✓Verification harness ideas (prove duplicates can’t recur, not “seems fixed”)
Resources
Internal:
- The .NET Production Rescue hub
- The .NET category
- Retry logic makes outages worse
- Handling 429s correctly
- Structured logging that actually helps
External:
- Microsoft guidance on idempotency
- RFC 9110 HTTP semantics
- Stripe idempotency keys
- Azure API Management policies
Troubleshooting Questions Engineers Search
Yes. A retry repeats the request. If the server does not protect the side effect, it will run again. The only safe fix is an idempotency key with stored results.
Only for endpoints that create or mutate state. Read only endpoints do not need them. Start with the most painful endpoints and expand carefully.
Log the missing key first. Then enforce it once clients are updated. For public APIs, return a clear error with a short explanation and a link to docs.
Long enough to cover retries and delayed responses. A common starting point is 24 hours. Adjust based on business risk and cost.
Not always. The minimal approach uses a key store and cached response. If you have multiple side effects that must be consistent, you will likely need an outbox or ledger.
Treat it as a conflict. Return a 409 or similar error and log the mismatch. This prevents accidental or malicious reuse of keys across different requests.
Coming soon
If you are dealing with duplicates across multiple services, the idempotency playbook includes schemas, outbox patterns, and verification steps to make retries safe at scale.
Axiom .NET Rescue (Coming Soon)
Get notified when we ship idempotency templates, outbox patterns, and production runbooks for .NET services.
Checklist (copy/paste)
- Side-effect endpoints require
Idempotency-Key. - Keys are scoped by caller (e.g.,
(client_id, idempotency_key)is unique). -
request_hashis stored and mismatches return 409 (no accidental/malicious key reuse). - An “in_progress” record is created before running the side effect.
- The successful response is stored and replayed on repeats (same status + body).
- TTL is configured (start ~24 hours) and expired records are cleaned up.
- Logs include:
idempotency_key,idempotency_result(stored/replayed/conflict),request_hash,client_id,status_code. - Metrics exist for duplicates prevented (replayed count) and conflicts.
- You can prove in a test:
- retry after timeout does not create a second order
- same key + different body returns 409
Key takeaways
- Retries create duplicates unless the server enforces idempotency.
- A key, a store, and a replayed response is the minimum safe design.
- Log idempotency fields so duplicates are provable and preventable.
- Roll out in phases to avoid breaking clients.
- Use the minimal approach to stop the bleeding, then harden.
Recommended resources
Download the shipped checklist/templates for this post.
A key format, storage checklist, and replay rules to prevent duplicate side effects in .NET APIs.
resource
Related posts

HttpClient keeps getting 429s: why retries amplify rate limiting in .NET
When retries multiply 429 errors instead of fixing them: how retry amplification happens, how to prove it, and how to honor Retry-After with budgets.

Retries making outages worse: when resilience policies multiply failures in .NET
Retry storms don't look like a bug—they look like good engineering until retries amplify failures and multiply in-flight requests during backpressure.

Structured logging that actually helps: Serilog fields that matter in .NET incidents
When logs are noisy but useless: why incidents stay unsolved, which fields actually explain failures, and the minimal schema that makes .NET outages diagnosable.