Idempotency Pattern — Designing Duplicate-Proof APIs for Distributed Systems
Posted on: 4/21/2026 8:13:11 AM
Table of contents
- What Is Idempotency?
- HTTP Methods and Natural Idempotency
- The Idempotency Key Pattern
- Designing the Idempotency Store
- Implementation with .NET 10
- Database-Level Idempotency
- Idempotency in Message Queues
- Payment Systems — Where Idempotency Is Critical
- Race Conditions and Concurrent Requests
- End-to-End Architecture — Idempotency in Microservices
- Anti-Patterns to Avoid
- Implementation Checklist
- Conclusion
Imagine this: a user clicks the "Pay Now" button twice because of network lag. The system charges them twice. The customer loses money, the support team gets a ticket, and you're hotfixing at 2 AM. This isn't hypothetical — it's a real problem every distributed system must solve. The solution? Idempotency Pattern.
What Is Idempotency?
In mathematics, an operation f is idempotent if f(f(x)) = f(x). In software engineering, an API endpoint is idempotent when calling it multiple times with the same input produces the exact same result as calling it once — no duplicate side effects.
Formal Definition
An operation is idempotent if executing it 1 time or N times produces the same final state on the system. The response may differ (e.g., first call returns 201 Created, subsequent calls return 200 OK), but the state must remain consistent.
HTTP Methods and Natural Idempotency
Not all HTTP methods require manual idempotency handling. RFC 7231 clearly specifies:
| HTTP Method | Idempotent? | Safe? | Explanation |
|---|---|---|---|
GET | ✅ Yes | ✅ Yes | Read-only, no state changes |
PUT | ✅ Yes | ❌ No | Full resource replacement — 10 calls yield the same result |
DELETE | ✅ Yes | ❌ No | First call succeeds, subsequent return 404 — state unchanged |
PATCH | ⚠️ Depends | ❌ No | Counter increment is not idempotent; setting a value is |
POST | ❌ No | ❌ No | Creates a new resource each time — needs manual handling |
⚠️ Common Trap
PATCH /api/accounts/123 {"balance_add": 100} — adds $100 to the account. Call it 3 times = $300 added. This PATCH is not idempotent. Instead, use: PATCH /api/accounts/123 {"balance": 1100} (set absolute value) or add an idempotency key.
The Idempotency Key Pattern
This is the most widely adopted pattern, used by Stripe, PayPal, and most payment gateways. The client generates a unique key for each operation and sends it in a request header. The server stores the key + response, and if the same key is received again, it returns the cached response instead of reprocessing.
sequenceDiagram
participant C as Client
participant API as API Gateway
participant S as Service
participant DB as Database
participant IS as Idempotency Store
C->>API: POST /payments
Idempotency-Key: abc-123
API->>IS: Check key "abc-123"
IS-->>API: Not found
API->>S: Process payment
S->>DB: INSERT transaction
DB-->>S: OK
S-->>API: 201 Created
API->>IS: Save key + response
API-->>C: 201 Created
Note over C,IS: Network timeout, client retries...
C->>API: POST /payments
Idempotency-Key: abc-123
API->>IS: Check key "abc-123"
IS-->>API: Found, return cached response
API-->>C: 201 Created (cached)
Figure 1: Idempotency Key flow — second call returns cached result
Designing the Idempotency Store
The Idempotency Store maps idempotency keys to processed responses. Multiple implementation strategies exist depending on performance and durability requirements:
| Storage | Latency | Durability | Best For |
|---|---|---|---|
| Redis (SET NX + TTL) | < 1ms | Medium | High-throughput APIs, acceptable data loss on Redis restart |
| PostgreSQL / SQL Server | 2-5ms | High | Financial transactions, ACID guarantee required |
| DynamoDB | < 5ms | High | Serverless, global scale, conditional writes |
| In-memory + WAL | < 0.1ms | Depends on WAL | Ultra-low latency, single-node deployment |
SQL Server Table Structure
CREATE TABLE IdempotencyStore (
IdempotencyKey NVARCHAR(256) NOT NULL PRIMARY KEY,
HttpMethod VARCHAR(10) NOT NULL,
Endpoint NVARCHAR(500) NOT NULL,
RequestHash CHAR(64) NOT NULL, -- SHA-256 of request body
StatusCode INT NOT NULL,
ResponseBody NVARCHAR(MAX) NULL,
CreatedAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(),
ExpiresAt DATETIME2 NOT NULL,
INDEX IX_ExpiresAt (ExpiresAt) -- For cleanup job to purge expired entries
);
Redis Approach (Faster, Simpler)
# Atomic check-and-set with NX (only set if Not eXists)
SET idempotency:abc-123 '{"status":201,"body":{...}}' NX EX 86400
# NX ensures only the first request "wins"
# EX 86400 = auto-expire after 24 hours
Implementation with .NET 10
In .NET, the cleanest approach is using an Action Filter or Middleware to handle idempotency as a cross-cutting concern, without requiring each endpoint to implement it manually.
Idempotency Filter
public class IdempotencyFilter(
IIdempotencyStore store,
TimeProvider timeProvider) : IAsyncActionFilter
{
public async Task OnActionExecutionAsync(
ActionExecutingContext context,
ActionExecutionDelegate next)
{
if (!context.HttpContext.Request.Headers
.TryGetValue("Idempotency-Key", out var keyValues))
{
await next();
return;
}
var key = keyValues.ToString();
var cached = await store.GetAsync(key);
if (cached is not null)
{
// Verify request body matches (prevent misuse)
var requestHash = await ComputeRequestHash(context.HttpContext.Request);
if (cached.RequestHash != requestHash)
{
context.Result = new ConflictObjectResult(new {
error = "Idempotency key already used for a different request"
});
return;
}
context.Result = new ObjectResult(cached.ResponseBody)
{
StatusCode = cached.StatusCode
};
return;
}
// Execute the original request
var executedContext = await next();
// Save response to store
if (executedContext.Result is ObjectResult result)
{
await store.SaveAsync(new IdempotencyEntry
{
Key = key,
RequestHash = await ComputeRequestHash(context.HttpContext.Request),
StatusCode = result.StatusCode ?? 200,
ResponseBody = result.Value,
ExpiresAt = timeProvider.GetUtcNow().AddHours(24)
});
}
}
}
Register for Specific Endpoints
app.MapPost("/api/payments", async (CreatePaymentRequest req, PaymentService svc) =>
{
var result = await svc.CreatePaymentAsync(req);
return Results.Created($"/api/payments/{result.Id}", result);
})
.AddEndpointFilter<IdempotencyFilter>();
Database-Level Idempotency
Not every use case requires a separate idempotency store. For many scenarios, you can achieve idempotency directly at the database level:
graph TD
A[Incoming Request] --> B{Has natural key?}
B -->|Yes| C[UNIQUE constraint
on natural key]
B -->|No| D{Has idempotency key?}
D -->|Yes| E[Idempotency Store
Redis / SQL]
D -->|No| F{Operation type?}
F -->|SET/PUT| G[Upsert pattern
MERGE / ON CONFLICT]
F -->|INCREMENT| H[Conditional update
with version/timestamp]
F -->|CREATE| I[⚠️ Needs
idempotency key]
style A fill:#e94560,stroke:#fff,color:#fff
style C fill:#4CAF50,stroke:#fff,color:#fff
style E fill:#4CAF50,stroke:#fff,color:#fff
style G fill:#4CAF50,stroke:#fff,color:#fff
style H fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style I fill:#ff9800,stroke:#fff,color:#fff
Figure 2: Decision tree — choosing the right idempotency strategy
UNIQUE Constraint (Natural Key)
-- Orders already have a unique order code
CREATE TABLE Orders (
Id INT IDENTITY PRIMARY KEY,
OrderCode VARCHAR(50) NOT NULL UNIQUE, -- Natural idempotency key
CustomerId INT NOT NULL,
TotalAmount DECIMAL(18,2) NOT NULL,
CreatedAt DATETIME2 DEFAULT SYSUTCDATETIME()
);
-- Insert will fail if OrderCode already exists — client retry is safe
INSERT INTO Orders (OrderCode, CustomerId, TotalAmount)
VALUES ('ORD-2026-04-21-001', 42, 1500000);
Upsert Pattern (MERGE)
-- Idempotent upsert — calling any number of times produces the same result
MERGE INTO UserPreferences AS target
USING (VALUES (@UserId, @Theme, @Language)) AS source (UserId, Theme, Language)
ON target.UserId = source.UserId
WHEN MATCHED THEN
UPDATE SET Theme = source.Theme, Language = source.Language
WHEN NOT MATCHED THEN
INSERT (UserId, Theme, Language)
VALUES (source.UserId, source.Theme, source.Language);
Idempotency in Message Queues
Message queues (Kafka, RabbitMQ, Azure Service Bus) provide at-least-once delivery — consumers may receive the same message multiple times. Consumer-side idempotency is mandatory.
graph LR
P[Producer] -->|Message + MessageId| Q[Message Queue]
Q -->|Deliver| C1[Consumer Instance 1]
Q -->|Redeliver
after timeout| C1
C1 --> DS[(Dedup Store)]
DS -->|Already processed?| SKIP[Skip]
DS -->|New?| PROCESS[Process + Commit]
style P fill:#2c3e50,stroke:#fff,color:#fff
style Q fill:#e94560,stroke:#fff,color:#fff
style C1 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style DS fill:#4CAF50,stroke:#fff,color:#fff
Figure 3: Consumer-side deduplication in message queues
public class IdempotentOrderConsumer(
IIdempotencyStore dedupStore,
OrderService orderService) : IConsumer<OrderCreatedEvent>
{
public async Task Consume(ConsumeContext<OrderCreatedEvent> context)
{
var messageId = context.MessageId?.ToString()
?? context.Message.OrderId.ToString();
if (await dedupStore.ExistsAsync(messageId))
return; // Already processed, skip
await orderService.ProcessOrderAsync(context.Message);
await dedupStore.SaveAsync(messageId, TimeSpan.FromDays(7));
}
}
Payment Systems — Where Idempotency Is Critical
In payment systems, duplicate transactions aren't just bugs — they're financial incidents. Major payment gateways mandate idempotency keys:
| Payment Gateway | Header | TTL | Duplicate Key Behavior |
|---|---|---|---|
| Stripe | Idempotency-Key | 24 hours | Returns cached response, logs warning |
| PayPal | PayPal-Request-Id | 72 hours | Returns 200 + original response |
| Adyen | Idempotency-Key | 24 hours | Returns cached response |
| Square | Idempotency-Key | 24 hours | Returns original response with same status |
💡 Best Practice from Stripe
Stripe recommends: Idempotency keys should be UUID v4 generated by the client, tied to a user action (button click), not to a request object. When the user clicks "Pay" → generate UUID → all retries of that click use the same UUID. If the user clicks "Pay" again (after seeing the result) → generate a new UUID.
Race Conditions and Concurrent Requests
When two requests with the same idempotency key arrive simultaneously (before the first request completes), you need a locking mechanism:
sequenceDiagram
participant C1 as Request 1
participant C2 as Request 2
participant L as Lock Manager
participant S as Service
participant IS as Idempotency Store
C1->>L: Acquire lock "key-abc"
L-->>C1: Lock acquired ✅
C2->>L: Acquire lock "key-abc"
L-->>C2: Lock busy, waiting... ⏳
C1->>S: Process payment
S-->>C1: 201 Created
C1->>IS: Save response
C1->>L: Release lock
L-->>C2: Lock acquired ✅
C2->>IS: Check key "key-abc"
IS-->>C2: Found cached response
C2-->>C2: Return cached 201
C2->>L: Release lock
Figure 4: Distributed lock prevents concurrent processing of the same idempotency key
Redis Distributed Lock
public async Task<IActionResult> ProcessWithLock(string idempotencyKey)
{
var lockKey = $"lock:idempotency:{idempotencyKey}";
// Acquire lock with 30s timeout
await using var lockHandle = await _redisLock
.AcquireAsync(lockKey, TimeSpan.FromSeconds(30));
if (lockHandle is null)
return StatusCode(429, "Request is being processed, please retry");
// Check cached response
var cached = await _store.GetAsync(idempotencyKey);
if (cached is not null)
return StatusCode(cached.StatusCode, cached.ResponseBody);
// Process request
var result = await _service.ExecuteAsync();
// Cache response
await _store.SaveAsync(idempotencyKey, result);
return Ok(result);
}
End-to-End Architecture — Idempotency in Microservices
graph TD
CLIENT[Mobile / Web Client] -->|Idempotency-Key header| GW[API Gateway]
GW --> IF{Idempotency Filter}
IF -->|Cache hit| CACHED[Return Cached Response]
IF -->|Cache miss| LOCK[Acquire Distributed Lock]
LOCK --> SVC[Business Service]
SVC --> DB[(Primary Database)]
SVC --> MQ[Message Queue]
MQ --> CONSUMER[Consumer Service]
CONSUMER --> DEDUP{Dedup Check}
DEDUP -->|Already processed| SKIP2[Skip]
DEDUP -->|New| PROC[Process + Save MessageId]
SVC -->|Response| SAVE[Save to Idempotency Store]
SAVE --> RELEASE[Release Lock]
RELEASE --> CLIENT
style CLIENT fill:#2c3e50,stroke:#fff,color:#fff
style GW fill:#e94560,stroke:#fff,color:#fff
style IF fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style LOCK fill:#ff9800,stroke:#fff,color:#fff
style SVC fill:#4CAF50,stroke:#fff,color:#fff
style CONSUMER fill:#4CAF50,stroke:#fff,color:#fff
style DB fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style MQ fill:#f8f9fa,stroke:#e94560,color:#2c3e50
Figure 5: Idempotency across microservices architecture — from API Gateway to Message Consumer
Anti-Patterns to Avoid
1. Using Timestamps as Idempotency Keys
// ❌ WRONG — timestamps can collide across different requests
headers: { 'Idempotency-Key': Date.now().toString() }
// ✅ CORRECT — UUID v4 guarantees uniqueness
headers: { 'Idempotency-Key': crypto.randomUUID() }
2. Not Verifying the Request Body
// Request 1: POST /payments, Key: abc, Body: {amount: 100}
// Request 2: POST /payments, Key: abc, Body: {amount: 999}
// If you only check the key without the body → returns $100 response for $999 request
// → MUST hash request body and compare
3. TTL Too Short or Too Long
⚠️ Choose the Right TTL
Too short (1 minute): Client retries after 2 minutes → key expired → duplicate processed.
Too long (30 days): Store bloats, wastes memory/storage unnecessarily.
Recommended: 24h for standard APIs, 72h for payments, 7 days for async workflows.
4. Wrong Scope for Idempotency Keys
// ❌ Key per user — 2 different payments by the same user get blocked
Idempotency-Key: user-42
// ❌ Key per endpoint — all requests to the endpoint are seen as duplicates
Idempotency-Key: create-payment
// ✅ Key per user-action — each click generates a unique key
Idempotency-Key: user-42-pay-order-789-attempt-1
Implementation Checklist
| # | Item | Details |
|---|---|---|
| 1 | Identify endpoints needing idempotency | POST endpoints creating resources, mutation endpoints with side effects |
| 2 | Choose idempotency store storage | Redis for high-throughput, SQL for ACID guarantees |
| 3 | Implement request body hashing | SHA-256 of normalized request body |
| 4 | Add distributed locking | Prevent race conditions from concurrent requests |
| 5 | Set appropriate TTL | 24h standard, 72h payments, 7d async |
| 6 | Handle edge cases | Key conflicts (different body), lock timeouts, store unavailability |
| 7 | Monitoring & alerting | Track duplicate hit rate, lock contention, store latency |
| 8 | Consumer-side dedup | Message queue consumers also need idempotency checks |
Conclusion
Idempotency isn't a "nice-to-have" feature — it's a mandatory architectural requirement for any distributed system that needs data consistency. From the API Gateway to the Message Consumer, every layer needs its own duplicate-handling strategy. The Idempotency Key + distributed lock + consumer dedup trio forms a comprehensive defense, keeping your system resilient against network failures, client retries, and message redelivery.
💡 Golden Rule
Design every write operation as if it will be called at least twice. If calling it twice produces a different outcome than calling it once — you have a bug. Idempotency by design, not idempotency by afterthought.
References:
Webhook Design Patterns — Building Reliable Event Notification Systems
Dapr — 13 Building Blocks That Solve Every Distributed Microservices Challenge
Disclaimer: The opinions expressed in this blog are solely my own and do not reflect the views or opinions of my employer or any affiliated organizations. The content provided is for informational and educational purposes only and should not be taken as professional advice. While I strive to provide accurate and up-to-date information, I make no warranties or guarantees about the completeness, reliability, or accuracy of the content. Readers are encouraged to verify the information and seek independent advice as needed. I disclaim any liability for decisions or actions taken based on the content of this blog.