Idempotency Pattern — Designing Duplicate-Proof APIs for Distributed Systems

Posted on: 4/21/2026 8:13:11 AM

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.

60%+ API failures from network timeouts cause automatic retries
$2.5B Global losses from duplicate transactions (2025)
99.99% SLA requirement for payment API idempotency
3-5x Average retries by mobile clients on timeout

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 MethodIdempotent?Safe?Explanation
GET✅ Yes✅ YesRead-only, no state changes
PUT✅ Yes❌ NoFull resource replacement — 10 calls yield the same result
DELETE✅ Yes❌ NoFirst call succeeds, subsequent return 404 — state unchanged
PATCH⚠️ Depends❌ NoCounter increment is not idempotent; setting a value is
POST❌ No❌ NoCreates 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:

StorageLatencyDurabilityBest For
Redis (SET NX + TTL)< 1msMediumHigh-throughput APIs, acceptable data loss on Redis restart
PostgreSQL / SQL Server2-5msHighFinancial transactions, ACID guarantee required
DynamoDB< 5msHighServerless, global scale, conditional writes
In-memory + WAL< 0.1msDepends on WALUltra-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 GatewayHeaderTTLDuplicate Key Behavior
StripeIdempotency-Key24 hoursReturns cached response, logs warning
PayPalPayPal-Request-Id72 hoursReturns 200 + original response
AdyenIdempotency-Key24 hoursReturns cached response
SquareIdempotency-Key24 hoursReturns 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

#ItemDetails
1Identify endpoints needing idempotencyPOST endpoints creating resources, mutation endpoints with side effects
2Choose idempotency store storageRedis for high-throughput, SQL for ACID guarantees
3Implement request body hashingSHA-256 of normalized request body
4Add distributed lockingPrevent race conditions from concurrent requests
5Set appropriate TTL24h standard, 72h payments, 7d async
6Handle edge casesKey conflicts (different body), lock timeouts, store unavailability
7Monitoring & alertingTrack duplicate hit rate, lock contention, store latency
8Consumer-side dedupMessage 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: