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:
GETrequests read data without side effectsPUTrequests set a resource to a specific stateDELETErequests 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:
- Client sends a request to create an order, process a payment, or book a ticket
- Server successfully processes the request and updates the database
- Network fails before the response reaches the client
- Client times out and assumes the request failed
- Client retries the same request
- 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):
- Server receives request with
Idempotency-Key: 7d8e9f0a... - Checks if this key exists in the database → Not found
- Processes the order, stores the result with the key
- Returns response:
201 Created
Second Request (Retry):
- Server receives request with same
Idempotency-Key: 7d8e9f0a... - Checks if this key exists → Found!
- Skips processing, returns the stored response
- 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
- Stripe API Documentation: Idempotent Requests - Industry-standard implementation reference
- AWS Architecture Blog: Idempotency in Distributed Systems - Amazon’s approach to safe retries
- Martin Kleppmann: Designing Data-Intensive Applications - Chapter on distributed systems and consistency
- RFC 7231: HTTP/1.1 Semantics - Specification of idempotent HTTP methods
- PostgreSQL Documentation: Unique Constraints - Database-level idempotency enforcement
- Redis Documentation: Distributed Locks - High-performance idempotency checking