A customer submits an order, the server saves it, and the connection drops before the response arrives. The customer retries. Without additional protection, a perfectly valid second POST request can create a second order.
An idempotent operation has the same intended effect when repeated. POST does not provide that guarantee automatically, but an ASP.NET Core application can introduce it with a client-supplied key and a durable record of the result. The difficult part is making this reliable when requests race or processes crash.
Introduction
Retries are part of normal network behaviour. A phone can change networks after sending a request, a reverse proxy can time out while the application continues working, and a client can lose the response after the database has committed. From the customer's perspective, all three situations can look like a failed purchase even when the order exists.
The server therefore needs a way to recognise an attempt to repeat one logical action. It cannot infer that action solely from identical JSON. A customer may intentionally buy the same product twice. It also cannot rely on one application instance remembering the previous request, because the retry may reach another instance.
This article designs an order-creation endpoint whose local effects live in one relational database. The idempotency record, order and any outbox event will share a transaction. Later sections explain what changes when the request starts a long-running workflow or calls a payment provider.
The desired contract is specific: within an agreed retention period, the same authorised operation with the same key and inputs returns its stored result without creating another order. Reusing that key with different inputs produces a conflict. A new key represents a new attempted operation, subject to the application's normal business rules.
This does not mean every repeated HTTP request returns the same headers forever. Authentication can expire, authorisation can change and request tracing produces new identifiers. Separate the durable business result from transport details so the contract is both useful and implementable.
ASP.NET Core provides the endpoint pipeline and EF Core can coordinate database changes, but the guarantee comes from the data model and transaction boundary. A filter named Idempotent or a cache lookup is only an interface to that design. The important question is what survives when the process stops at the worst possible instruction.
Define What Counts as the Same Operation
Ask clients to generate an unpredictable key once for each logical operation and reuse it for retries:
POST /api/orders
Idempotency-Key: 87c21455-1a77-432b-bdf0-513d7acb54e4
Content-Type: application/json
{"productId":42,"quantity":2}
A new order needs a new key, even when its contents happen to match an earlier order. Hashing the request body alone would incorrectly collapse intentional repeat purchases.
Scope the key to the authenticated tenant or account and the operation, such as CreateOrder:v1. Never trust a tenant identifier supplied only in the body. Two customers using the same string must not receive each other's order response.
Stripe's idempotent request documentation provides a concrete example of a published retry contract. Your endpoint should similarly document key reuse, conflicts, retained results, and expiry.
Make the Client Responsible for Stable Retry Identity
Generate the key when the client begins the logical action, then persist it for as long as that action remains unresolved. Generating a new key in a generic HTTP retry handler defeats the mechanism: every attempt arrives with a new identity and can legitimately create another order.
A browser interface can associate a key with its pending checkout state. A background job can persist one alongside the queued command. A mobile application may need to save it locally before making the first request so restarting the application does not lose the retry identity.
Disabling the submit button improves the experience but does not replace server protection. Two tabs, impatient repeated taps, a queued offline request or an automatic retry can still submit the same action concurrently. The server must enforce the rule independently of presentation behaviour.
Document a reasonable maximum key length and allowed representation. A random UUID is a convenient example, but the exact format is less important than low collision risk, stable reuse and bounded storage. Reject oversized or malformed values before they become database keys or large log entries.
Decide whether the header is mandatory for this endpoint. If it is optional, requests without it do not receive the same duplicate protection, and clients need to understand that difference. For actions where a retry could create a costly duplicate, requiring the header makes the contract easier to use consistently.
Scope the Key to the Right Authority
Build the scope from authenticated identity and a stable operation name. For example, account 27's CreateOrder:v1 is separate from account 28's operation using the same key. An untrusted accountId inside JSON must not decide which customer's stored response is returned.
The operation scope should distinguish endpoints with different meanings. Reusing a string for creating an order and issuing a refund must not connect those operations. If an API version changes input semantics, decide whether it represents the same retry contract or a new operation namespace.
Keep keys separate from permanent business identifiers. A supplier's purchase-order reference may need a unique constraint for years, whereas an HTTP retry key may be retained for days. An idempotency key protects repeated delivery of a command; a business constraint protects a domain rule regardless of how the command arrives.
Store Enough Information to Replay the Result
Use a shared database table, not an in-memory dictionary that disappears on restart or differs between application instances.
Store the tenant, operation, key, request fingerprint, status code, response body, content type, relevant response headers, and expiry time. The fingerprint should cover validated business inputs using a stable representation. Decide how omitted defaults and equivalent representations are treated.
Require non-null scope fields and enforce a unique database index over (TenantId, Operation, Key). Entity Framework Core supports unique composite indexes. The constraint is the concurrency guard; an earlier lookup is only an optimisation.
Persist identifiers and the original response rather than rebuilding it from mutable order data. A later price change should not silently alter the replayed creation response.
Define a Stable Request Fingerprint
The fingerprint answers whether the same scoped key is being used for the same intended inputs. Compute it after parsing and validating the request, using a canonical representation of the business command. Include every field that can change the effect, such as product, quantity, delivery option and an accepted quote identifier.
Raw JSON bytes are usually a poor default. Property order and insignificant whitespace can differ while the parsed request means the same thing. Conversely, normalising too aggressively can hide meaningful changes. Treating all strings as case-insensitive, for example, may alter an external reference whose case matters.
Choose how defaults work. If omitted deliveryMethod means Standard, the canonical representation can explicitly contain Standard for both omitted and supplied values. If the default changes in a later API version, the operation's fingerprint version needs to preserve the earlier interpretation for retained retries.
Avoid including volatile transport fields such as the current access token, request timestamp or tracing header. A legitimate retry will often have different values. Include the resulting trusted scope separately, and keep a version for the fingerprint algorithm so deployments can continue interpreting existing records.
The fingerprint is a comparison aid, not a substitute for authorisation. A matching hash does not prove that the caller may read the stored order. Use a standard collision-resistant hash over a clearly specified representation, and protect both the original inputs and the saved response according to their sensitivity.
Model the Durable Result Explicitly
A simplified schema can distinguish the identity from the replayable response:
IdempotencyResult
TenantId
Operation
Key
FingerprintVersion
RequestFingerprint
OrderId
HttpStatusCode
ResponseBody
ContentType
Location
CreatedAt
ExpiresAt
Unique: TenantId + Operation + Key
For the short transaction design, an unfinished row exists only within the transaction until the completed result is committed. Other requests should not treat uncommitted state as an acquired durable operation. This keeps recovery simpler than independently committing a long-lived InProgress marker.
Store a deliberately small response, such as the created order identifier, accepted total and initial status. A creation response containing a large mutable order graph increases retention cost and makes the replay contract harder to explain. Keep safe replayable headers on an allowlist rather than copying every response header automatically.
For example, preserve Location: /api/orders/842. Do not replay a Set-Cookie header from an old request, a connection-specific header or an obsolete tracing identifier. Security headers can be applied by the current response pipeline; the stored record represents the business response rather than a complete captured network packet.
Choose column types and collations intentionally. If keys are case-sensitive opaque strings, a case-insensitive database comparison would merge values the API regards as different. All scope fields should be non-null so uniqueness does not depend on provider-specific treatment of nulls.
In EF Core, the essential model configuration can be kept explicit:
modelBuilder.Entity<IdempotencyResult>(entity =>
{
entity.Property(x => x.TenantId).IsRequired();
entity.Property(x => x.Operation).IsRequired().HasMaxLength(100);
entity.Property(x => x.Key).IsRequired().HasMaxLength(200);
entity.HasIndex(x => new { x.TenantId, x.Operation, x.Key })
.IsUnique()
.HasDatabaseName("UX_IdempotencyResult_Scope");
});
The lengths are example API limits, not universal recommendations. Check the generated migration and the provider's index-key limits, then deploy the constraint before depending on it. Naming the index makes the intended concurrency guard easier to identify, although exception inspection still depends on the specific provider's error details.
Make Acquisition and Creation Atomic
For a short operation whose effects are entirely in one relational database, use one transaction for the reservation, business change, and completed result:
- Authenticate, authorise, and validate the request.
- Begin a database transaction and insert the scoped idempotency record.
- Create the order and construct its response.
- Save the completed idempotency result in the same transaction.
- Commit, then write the HTTP response.
Two simultaneous inserts for the same unique key cannot both succeed. Depending on the database, the second may wait and then encounter a unique constraint violation. After rolling back its failed transaction, it should read the committed winner using a fresh context, compare fingerprints, and replay the result.
Do not interpret every database exception as a duplicate. Identify the specific constraint violation; handle deadlocks, timeouts, and unavailable databases separately. Microsoft's transaction documentation explains how to coordinate multiple saves and the interaction with retrying execution strategies.
Put the Guarantee in the Application Operation
A middleware component is a useful place to parse a header and apply common limits. It is often the wrong place to assume ownership of an arbitrary database transaction. The order handler understands which changes must commit together and whether the operation includes external effects.
One practical design uses a reusable idempotency service called by application handlers. It receives a trusted scope, fingerprint and a callback that performs local database work. The service owns the transaction and durable result, while each handler defines its business response. Provider-specific conflict detection remains explicit and testable.
The following is workflow pseudocode rather than a drop-in implementation:
Authenticate and authorise the caller
Validate input and build the canonical command
Look up a committed result for the scoped key
If found:
Compare fingerprints
Return the saved result or a key-reuse conflict
Begin transaction
Insert the scoped key reservation
Save to enforce the unique constraint
Create the order using this transaction
Construct and serialise the creation response
Complete the durable result
Insert any required outbox event
Commit
Send the saved response
The initial lookup avoids opening a transaction for a completed retry. It is an optimisation because two requests can both observe a missing row. Only the unique insert inside the database decides which attempt owns creation.
Serialise the result before committing. If response serialisation can fail because of an unsupported value or reference loop, discover that while the transaction can still roll back. After commit, write the already prepared response without requiring another business query that may fail or return changed data.
Follow Two Racing Requests
Requests A and B arrive on different application instances with the same scope, key and fingerprint. Both miss the early lookup. A inserts the key and starts creating the order. B attempts the same unique insert and may wait for A's transaction, depending on provider behaviour and isolation settings.
If A commits, B cannot commit another row with that unique identity. B handles the specific conflict, abandons its failed transaction and reads the committed result. If A rolls back, B may acquire the key and become the creator. The database arbitrates the race even though the application instances share no memory.
Set bounds on waiting. B should not hold an HTTP connection indefinitely because A is stalled. A lock timeout or overall deadline should lead to the endpoint's documented retryable outcome. The client retains the same key for the next attempt.
Do not resolve the race by catching a uniqueness error and returning a newly constructed success response without reading the winner. The winner may have different inputs, a different result, or may not yet be visible through a replica. Read from a source that can establish the committed outcome, compare the fingerprint, and enforce authorisation before replay.
Database execution retries need their own care. An EF Core execution strategy can retry a transaction delegate, so that delegate must not send email or make an unprotected external charge. Microsoft's connection resiliency guidance specifically discusses uncertain commits and checking whether an operation succeeded. Test the provider behaviour your application actually enables.
Handle Crashes Without Guessing
If the process crashes before the transaction commits, neither the order nor its idempotency record survives. A retry can safely acquire the key again. If it crashes after commit but before responding, both survive and the retry receives the saved result.
An uncertain commit outcome requires checking the durable record on recovery. It does not justify blindly generating a new key and rerunning the operation.
Avoid separately committing an InProgress marker and assuming that its expiry proves nothing happened. A worker could have completed its side effect immediately before crashing. Longer workflows need explicit recovery state, ownership checks, and reconciliation with the work already performed.
Separate Known Failure from Unknown Outcome
A validation failure before the transaction begins is known not to have created an order. A database rollback confirmed by the provider is also a known failure for the local work. A lost connection during commit is different: the server may have committed and the acknowledgement may have been lost.
In that uncertain case, first look for the durable scoped result through a fresh, valid context. If it exists, its order and result committed together. If the database is temporarily unavailable, the correct response is temporary uncertainty, not a claim that the order definitely failed.
The same principle applies to request cancellation. An aborted client connection can cancel application work before commit, but it can also occur immediately after commit. Do not delete the idempotency record simply because writing the HTTP response failed. That record is exactly what the disconnected client needs on its retry.
Keep generated identifiers stable where the transaction retry design requires it. If recovery attempts create new identities without consulting the scoped result, an uncertain first commit can turn into a duplicate order. Uniqueness on the idempotency scope provides an important guard, but the whole retry delegate must respect that guard.
Use an Operation Resource for Long Workflows
An operation that spends minutes provisioning an account or awaiting payment confirmation should usually return an accepted operation resource. Commit its identifier and initial workflow state with the idempotency result, then let a durable worker continue the work.
For example, return 202 Accepted with Location: /api/operations/op-842. A retry of the creation POST can return that same accepted response. A separate GET returns the operation's current state, such as pending, completed or requiring intervention. This avoids trying to make an old creation response simultaneously represent every later state.
If workers claim operations using leases, include ownership tokens in state updates. Lease expiry means another worker may investigate or resume; it does not prove the previous worker performed no external work. Each workflow step needs a recoverable identity and a defined way to determine whether its effect happened.
Design terminal failure states as carefully as success states. A customer should be able to distinguish an order rejected for a known business reason from an operation whose payment result remains unknown. Hiding both behind a generic retry loop can cause more damage than exposing the uncertainty clearly.
Define Responses and External Side Effects
Return the saved status, body, content type, and safe headers such as Location for a completed retry. Reject reuse of a key with different inputs, for example with a documented 409 Conflict response. Continue to enforce current authentication and access rules before replaying stored data.
If a competing request is still executing, use a bounded wait or a documented retryable response. Do not hold connections indefinitely. Choose which validation and business failures are retained, and distinguish a known rolled-back failure from an unknown outcome.
Sending email or charging a payment provider falls outside the database transaction. For asynchronous work, commit an outbox event alongside the order and idempotency result. For a payment provider, persist and reuse a stable provider idempotency key, then reconcile uncertain outcomes through its supported APIs.
Publish a Response Policy Clients Can Follow
For this example, a first successful creation returns 201 Created, a small JSON body and a Location header. A completed retry returns the saved creation status and representation. Reuse with changed business inputs returns a documented 409 Conflict explaining that the key belongs to another request.
A still-running competitor can receive a bounded retryable response rather than a replay that does not exist yet. The exact status code is part of the API contract; be consistent, provide machine-readable error codes, and document whether a Retry-After header is supplied. Clients should never have to infer whether they must change the key from an unstructured message.
Decide which failures consume a key. Malformed JSON and missing authentication are generally rejected before acquisition. A definitive business rejection discovered during execution can be retained if repeated attempts should reproduce that decision. Alternatively, a documented policy can release a fully rolled-back attempt. Neither policy should quietly treat an unknown commit as a confirmed rollback.
If a retained rejection says a quote expired, retrying that same key should not unexpectedly buy at a new price. The customer should explicitly accept a new quote and begin a new logical operation. This connects retry semantics to consent and business meaning rather than treating every error as a technical inconvenience.
Continue to enforce current access rules on replay. An administrator who lost permission should not recover sensitive order details merely by retaining an old key. Where ownership can change, decide whether to check the current order resource, a retained access relationship or another authoritative policy before releasing the saved body.
Carry Stable Identity to the Payment Boundary
The API key and payment-provider key serve related but separate scopes. Persist a provider request identifier tied to the order or payment attempt, then reuse it for retries of that exact charge attempt. A genuinely different charge or corrected payment method may require a different provider operation according to that provider's contract.
Suppose the provider accepts a charge and the application times out. Do not immediately create a new provider key. Retrieve or reconcile the known payment operation using the provider's supported API, and let webhooks update the same durable payment state. Deduplicate webhook deliveries independently from the incoming order POST.
An outbox is useful for email, fulfilment and other asynchronous work: it commits the instruction together with the order. Its consumers still need duplicate-safe processing. The incoming endpoint's idempotency result does not automatically deduplicate every message or external action generated later.
Set Retention and Test the Failure Windows
Keep results for the retry window you promise, including delayed mobile requests and queued jobs. Once a key expires, reuse may create new work; document that boundary. Consider an additional permanent business constraint where duplication must never occur.
Protect stored responses as customer data and avoid retaining unnecessary secrets. Test simultaneous retries across two application instances, changed payloads, tenant isolation, process termination around commit, and a lost HTTP response.
Make Expiry a Defined Business Boundary
Choose retention from actual client behaviour. A retry window covering a browser page for a few minutes may be insufficient for an offline mobile queue or a background job that waits through a weekend outage. State the minimum period during which clients may safely reuse a key and what happens afterwards.
For a hypothetical service receiving 100,000 protected operations per day, retaining each result for seven days creates about 700,000 live records before considering delayed cleanup. At an assumed average of 2 KB per record, that is roughly 1.4 GB of raw record data, excluding indexes, database overhead and replicas. Estimate your own payloads rather than treating these figures as a sizing recommendation.
Expiry and deletion are not necessarily simultaneous. A cleanup job may run in batches. Decide whether an expired but still present result is replayed or treated as outside the guarantee, and coordinate reacquisition safely with the unique constraint. A durable tombstone can reject old key reuse when creating new work would be surprising.
Where duplication must never happen, retain a permanent business identity separately. A settlement reference, import item identifier or external purchase reference may deserve a lasting unique constraint. Short-lived replay records then improve response handling without carrying the entire burden of permanent business correctness.
Observe Results Without Exposing Customer Data
Track first executions, completed replays, fingerprint conflicts, waits, database conflicts and uncertain outcomes. A rise in replay rate may indicate a network or latency problem rather than malicious traffic. A rise in mismatched fingerprints may reveal a client that incorrectly reuses one key for every purchase.
Measure latency separately for first executions and replays so quick cached results do not hide slow order creation. Monitor storage growth and cleanup lag. Keep keys and fingerprints out of high-cardinality metric labels; use appropriately protected logs when individual operation investigation is necessary.
Rate limits still matter. An idempotency key is not an authentication token or protection against unlimited unique requests. A caller can create many distinct keys, and even repeated lookups consume database resources. Apply the endpoint's normal abuse controls around the durable mechanism.
Test Outcomes at the Persistence Boundaries
Run integration tests against the actual database provider, including two application instances sharing one database. Submit the same key concurrently and assert one order, one completed result and one intended outbox event. Repeat with changed inputs and confirm a conflict rather than a second order.
Deliberately fail response delivery after commit and confirm that the next request receives the original identifier and response. Terminate execution before commit and verify that a subsequent attempt can create the order. Simulate a lost commit acknowledgement and confirm recovery consults durable state before acting again.
Test changed authorisation, key comparison rules, equivalent canonical inputs, expiry races and retained failures. For external workflows, test duplicate webhooks and a provider timeout after acceptance. These cases verify the promise clients rely on; checking only that a second request returns HTTP 200 does not prove duplicate business actions were prevented.
Summary
Idempotent POST endpoints need more than a response cache. Use a scoped client key, a request fingerprint, and a unique database constraint to identify and coordinate retries.
For local database work, commit the business change and replayable result together. For external or long-running work, design a durable recovery path. That combination lets clients retry uncertain requests without turning temporary network failures into duplicate business actions.
