Post

Idempotency Keys: Safe Retries and Preventing Duplicate Operations

Idempotency Keys: Safe Retries and Preventing Duplicate Operations

Introduction

Imagine this scenario: Your API receives the same request twice. The first request succeeded, but the response was lost due to a network timeout. The client retries without any protection mechanism—and now you’ve processed the operation twice. For a payment, that means charging the customer twice. For a ticket booking, that’s a duplicate reservation. For an order API, that’s shipping the same product twice.

This isn’t a hypothetical edge case. It’s a real problem that happens in production systems every day. Network failures, client timeouts, and retry logic are fundamental realities of distributed systems. The question isn’t whether duplicate requests will happen—it’s how your API handles them when they do.

The solution is idempotency keys: a pattern that makes retries safe by ensuring the same operation never executes twice, even if the request is sent multiple times.

What Is Idempotency?

In mathematics and computer science, an operation is idempotent if applying it multiple times produces the same result as applying it once.

1
2
3
4
5
6
7
8
# ❌ Not idempotent
counter = 0
counter += 1  # First call: counter = 1
counter += 1  # Second call: counter = 2 (different result!)

# ✅ Idempotent
user.email = "new@example.com"  # First call: email updated
user.email = "new@example.com"  # Second call: email still the same (same result)

In HTTP, some methods are naturally idempotent:

  • GET requests read data without side effects
  • PUT requests set a resource to a specific state
  • DELETE requests remove a resource (deleting twice has the same effect)

But POST requests—the most common operation for creating resources, processing payments, or triggering actions—are not idempotent by default. Each POST creates a new resource or triggers a new action, regardless of whether it’s a retry.

That’s where idempotency keys come in.

The Problem: Why Duplicate Requests Happen

Network failures and timeouts are inevitable in distributed systems. Here’s what typically happens:

  1. Client sends a request to create an order, process a payment, or book a ticket
  2. Server successfully processes the request and updates the database
  3. Network fails before the response reaches the client
  4. Client times out and assumes the request failed
  5. Client retries the same request
  6. Server processes it again, creating a duplicate operation

Without protection, you end up with:

  • Double charges in payment systems
  • Duplicate orders in e-commerce platforms
  • Multiple bookings for the same seat
  • Repeated side effects like sending duplicate emails or webhooks

The goal of idempotency keys is not to prevent retries—retries are essential for reliability. The goal is to make retries safe.

The Solution: Idempotency Keys

An idempotency key is a unique identifier generated by the client and sent with each request. The server uses this key to detect duplicate requests and return the same response without re-executing the operation.

Basic Implementation Flow

1
2
3
4
5
POST /api/orders
Headers:
  Idempotency-Key: 7d8e9f0a-1b2c-3d4e-5f6g-7h8i9j0k1l2m
Body:
  { "product_id": 123, "quantity": 2 }

First Request (Success):

  1. Server receives request with Idempotency-Key: 7d8e9f0a...
  2. Checks if this key exists in the database → Not found
  3. Processes the order, stores the result with the key
  4. Returns response: 201 Created

Second Request (Retry):

  1. Server receives request with same Idempotency-Key: 7d8e9f0a...
  2. Checks if this key exists → Found!
  3. Skips processing, returns the stored response
  4. Returns same response: 201 Created (with original order data)

The client doesn’t need to know whether this is the first or second request. It just gets a consistent result.

Implementation Pattern

Here’s a robust implementation in Ruby on Rails:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# Database migration for idempotency keys
class CreateIdempotencyKeys < ActiveRecord::Migration[7.0]
  def change
    create_table :idempotency_keys do |t|
      t.string :key, null: false, index: { unique: true }
      t.string :request_path, null: false
      t.json :request_params
      t.integer :response_status
      t.json :response_body
      t.datetime :locked_at
      t.timestamps
    end
  end
end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
# app/models/idempotency_key.rb
class IdempotencyKey < ApplicationRecord
  # Lock timeout in seconds
  LOCK_TIMEOUT = 30

  validates :key, presence: true, uniqueness: true

  def self.process_with_idempotency(key, request_path, request_params)
    # Try to find existing key
    idempotency_record = find_by(key: key)

    if idempotency_record
      # Key exists - check if it's locked (processing in progress)
      if idempotency_record.locked?
        return { status: :processing, message: "Request is being processed" }
      end

      # Return cached response
      return {
        status: :cached,
        response_status: idempotency_record.response_status,
        response_body: idempotency_record.response_body
      }
    end

    # Create new key with lock
    begin
      idempotency_record = create!(
        key: key,
        request_path: request_path,
        request_params: request_params,
        locked_at: Time.current
      )
    rescue ActiveRecord::RecordNotUnique
      # Race condition: another request created the key first
      return process_with_idempotency(key, request_path, request_params)
    end

    # Process the request
    yield idempotency_record
  end

  def locked?
    locked_at.present? && locked_at > LOCK_TIMEOUT.seconds.ago
  end

  def store_response(status, body)
    update!(
      response_status: status,
      response_body: body,
      locked_at: nil  # Unlock
    )
  end
end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# app/controllers/api/orders_controller.rb
class Api::OrdersController < ApplicationController
  def create
    idempotency_key = request.headers['Idempotency-Key']

    unless idempotency_key.present?
      return render json: { error: 'Idempotency-Key header required' }, 
                    status: :bad_request
    end

    result = IdempotencyKey.process_with_idempotency(
      idempotency_key,
      request.path,
      order_params.to_h
    ) do |idem_record|
      # This block only runs for new requests
      order = Order.create!(order_params)
      
      response_body = { order: order.as_json }
      idem_record.store_response(201, response_body)
      
      { status: :created, response_status: 201, response_body: response_body }
    end

    case result[:status]
    when :processing
      render json: { message: result[:message] }, status: :conflict
    when :cached, :created
      render json: result[:response_body], status: result[:response_status]
    end
  end

  private

  def order_params
    params.require(:order).permit(:product_id, :quantity, :customer_id)
  end
end

Key Implementation Details

1. Unique Constraint Protection

The database unique constraint on the key column is critical. It ensures that even if two requests arrive simultaneously, only one will succeed in creating the idempotency record. The other will get a RecordNotUnique exception and retry the lookup.

2. Locking Mechanism

The locked_at timestamp prevents returning incomplete responses. If a request is still processing, concurrent retries receive a 409 Conflict status, signaling them to retry later.

3. Atomic Operations

The create! with unique constraint provides atomicity. You don’t need distributed locks or transactions spanning multiple services—the database handles it.

Output:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// First request - processes order
{
  "order": {
    "id": 12345,
    "product_id": 123,
    "quantity": 2,
    "status": "pending",
    "created_at": "2026-08-23T14:30:00Z"
  }
}

// Retry with same key - returns cached response
{
  "order": {
    "id": 12345,  // Same order!
    "product_id": 123,
    "quantity": 2,
    "status": "pending",
    "created_at": "2026-08-23T14:30:00Z"
  }
}

Common Pitfalls and Best Practices

❌ Pitfall 1: Client-Generated Keys Aren’t Unique Enough

1
2
3
4
5
6
// ❌ Bad: Sequential or predictable keys
const idempotencyKey = `order-${userId}-${Date.now()}`;

// ✅ Good: UUID v4 for cryptographic randomness
const idempotencyKey = crypto.randomUUID();
// "7d8e9f0a-1b2c-3d4e-5f6g-7h8i9j0k1l2m"

Use UUID v4 or similar cryptographically strong random identifiers to avoid collisions.

❌ Pitfall 2: Storing Keys Forever

Idempotency keys should have a retention policy. Stripe keeps them for 24 hours. After expiration, the same key can be reused for a new operation.

1
2
3
4
5
6
# Cleanup job
class CleanupIdempotencyKeysJob < ApplicationJob
  def perform
    IdempotencyKey.where('created_at < ?', 24.hours.ago).delete_all
  end
end

❌ Pitfall 3: Not Validating Request Consistency

If the same idempotency key is sent with different request parameters, it should be rejected:

1
2
3
4
5
6
7
8
9
10
11
12
def self.process_with_idempotency(key, request_path, request_params)
  idempotency_record = find_by(key: key)

  if idempotency_record
    # Validate request matches original
    if idempotency_record.request_params != request_params
      raise IdempotencyError, "Key reused with different parameters"
    end
    # ... return cached response
  end
  # ... continue processing
end

✅ Best Practice: Scope Keys Appropriately

For multi-tenant systems, scope idempotency keys by account or user:

1
2
# Include tenant context in the key lookup
IdempotencyKey.where(key: key, account_id: current_account.id).first

This prevents one tenant from accidentally (or maliciously) reusing another tenant’s key.

When to Use Idempotency Keys

Critical Use Cases (Always Implement):

  • Payment processing (charges, refunds, transfers)
  • Order creation (e-commerce, food delivery, bookings)
  • Ticket reservations (events, flights, hotels)
  • Inventory updates (stock allocation, reservations)
  • Financial transactions (transfers, withdrawals, deposits)
  • Webhook deliveries (external system notifications)

When You Can Skip It:

  • Read operations (GET requests are naturally idempotent)
  • Truly idempotent writes (PUT/PATCH with complete replacement)
  • Internal APIs with no retry logic (though this is rare)

Performance Consideration:

Each idempotent request requires a database lookup. For high-throughput APIs, consider:

  • Using Redis or another fast key-value store
  • Implementing TTL-based expiration
  • Sharding keys by hash prefix
1
2
3
4
5
6
7
8
9
10
# Redis-based idempotency check
def self.check_redis_idempotency(key)
  cached = REDIS.get("idem:#{key}")
  return JSON.parse(cached) if cached.present?
  nil
end

def self.cache_redis_response(key, status, body)
  REDIS.setex("idem:#{key}", 86400, { status: status, body: body }.to_json)
end

Real-World Examples

Stripe’s Implementation

Stripe requires idempotency keys for all POST requests:

1
2
3
4
5
6
curl https://api.stripe.com/v1/charges \
  -u sk_test_123: \
  -H "Idempotency-Key: 7d8e9f0a-1b2c-3d4e-5f6g-7h8i9j0k1l2m" \
  -d amount=2000 \
  -d currency=usd \
  -d source=tok_visa

If the request fails and the client retries with the same key, Stripe returns the original charge without creating a duplicate.

AWS S3’s Idempotent PUT

AWS S3’s PUT operation is naturally idempotent—uploading the same file twice with the same key overwrites the first version. No duplicate objects are created.

Database Unique Constraints as Idempotency

Sometimes your domain model provides natural idempotency:

1
2
3
4
# Email must be unique
User.create!(email: "user@example.com", name: "John")
User.create!(email: "user@example.com", name: "John")  
# Raises ActiveRecord::RecordNotUnique

This is a form of idempotency enforcement, though it’s better to use explicit keys for better error handling and response caching.

Conclusion

Idempotency keys are not just a defensive programming technique—they’re a fundamental pattern for building reliable distributed systems. They acknowledge the reality that networks fail, clients timeout, and retries happen, and they provide a clean, predictable way to handle these situations.

The pattern is simple: the client generates a unique key, the server stores the result, and subsequent requests with the same key return the cached result. But the impact is profound: no more double charges, duplicate orders, or repeated side effects.

If your API handles any operation where duplicate execution causes real damage—payments, orders, reservations, financial transactions—idempotency keys aren’t optional. They’re essential.

Implement them once, test them thoroughly, and sleep better knowing your retries are safe.

Suggested Reading

This post is licensed under CC BY 4.0 by the author.