API Idempotency Design Patterns: Idempotency Keys, Distributed Deduplication, and Payment-Safe APIs
Summary
- Idempotency is the "seat belt" of distributed systems: network retries, duplicate messages, and double-clicks can all cause multiple API invocations
- Idempotency Keys are the standard approach for HTTP API idempotency — adopted by Stripe, Alipay, and WeChat Pay
- Three layers of idempotency defense: client idempotency key → server dedup table → database unique constraint
- Payment, order creation, and inventory deduction are the top 3 idempotency-critical APIs — a must-know interview topic
- This article provides a complete solution from theory to Go implementation, with Redis + PostgreSQL dual-safety architecture
Table of Contents
- What Is Idempotency and Why It Is Mandatory
- Four Levels of Idempotency
- Idempotency Key Design Standards
- Three-Layer Idempotency Defense Architecture
- Go Production Idempotency Middleware
- Payment Idempotency in Practice
- Interview FAQs and Pitfall Guide
- Summary and Further Reading
What Is Idempotency and Why It Is Mandatory
One-Line Definition
Executing the same request any number of times produces the same result as executing it once, with no extra side effects.
Mathematical expression: f(f(x)) = f(x)
Why Distributed Systems Always Get Duplicate Requests
| Source | Typical Scenario | Frequency |
|---|---|---|
| User double-click | Pay button, submit order | Daily occurrence |
| Network timeout retry | Client auto-retry, gateway retry | Production norm |
| Message queue redelivery | Kafka at-least-once, RabbitMQ redelivery | By design |
| Load balancer retry | Nginx/Envoy upstream timeout retry | Frequent if misconfigured |
| Microservice chain retry | Downstream timeout, upstream resends | More common with longer chains |
Real Cost of Missing Idempotency
In 2025, a second-hand trading platform had a "duplicate charge" incident: after a user clicked pay, network jitter caused the client to auto-retry 3 times. The server had no idempotency check, charging the same order 3 times — over 2 million CNY involved, with regulatory investigation.
Idempotency is not an optimization — it is an entry requirement for payment/transaction systems.
Four Levels of Idempotency
Level 0: Naturally Idempotent (HTTP GET/PUT/DELETE)
POST and PATCH are NOT idempotent by default — each call may create a new resource or produce new side effects.
Level 1: Business-Layer Idempotency
Check if the order already exists and return the previous result. Simple but vulnerable to concurrent race conditions.
Level 2: Idempotency Key + Dedup Table
Client sends a unique idempotency key; server records whether this key has been processed.
Level 3: Database Unique Constraint (Final Defense)
CREATE UNIQUE INDEX idx_orders_idempotency_key
ON orders (idempotency_key)
WHERE idempotency_key IS NOT NULL;
Production recommendation: Level 2 + Level 3 combined.
Idempotency Key Design Standards
| Rule | Description | Example |
|---|---|---|
| Client-generated | Caller generates UUID before request | 550e8400-e29b-41d4-a716-446655440000 |
| Uniqueness | Same business operation uses same key | Must reuse original key on retry |
| Scope | Usually bound to user_id + operation type | Different users can use same key format |
| TTL | 24-72 hours | Reusable after expiry (rare) |
| Transport | HTTP Header | Idempotency-Key: <uuid> |
Stripe Idempotency Key Standard
Stripe logic: check key → not exists: execute and store result (201) → exists with same params: return cached result (200) → exists with different params: return 409 Conflict.
Idempotency Key vs Request ID vs Trace ID
| Field | Purpose | Lifecycle | Used for Dedup |
|---|---|---|---|
| Idempotency-Key | Business idempotency | 24-72h | Yes |
| X-Request-ID | Request tracing | Single request | No |
| Trace-ID | Distributed tracing | Single call chain | No |
Three-Layer Idempotency Defense Architecture
Layer 1: Redis SETNX Fast Intercept
func (s *IdempotencyService) TryAcquire(ctx context.Context, key string, ttl time.Duration) (bool, error) {
redisKey := fmt.Sprintf("idem:%s", key)
acquired, err := s.redis.SetNX(ctx, redisKey, "processing", ttl).Result()
if err != nil {
return false, fmt.Errorf("redis setnx: %w", err)
}
return acquired, nil
}
Layer 2: Idempotency Records Table
CREATE TABLE idempotency_records (
id BIGSERIAL PRIMARY KEY,
idempotency_key VARCHAR(64) NOT NULL,
request_hash VARCHAR(64) NOT NULL,
response_status INT,
response_body JSONB,
status VARCHAR(20) NOT NULL DEFAULT 'processing',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
CONSTRAINT uq_idempotency_key UNIQUE (idempotency_key)
);
Layer 3: Business Table Unique Constraint
Even if Layers 1 and 2 fail (Redis down, app bug), the database unique constraint is the last line of defense.
Go Production Idempotency Middleware
func (m *IdempotencyMiddleware) Handle() gin.HandlerFunc {
return func(c *gin.Context) {
key := c.GetHeader("Idempotency-Key")
if key == "" {
c.Next()
return
}
ctx := c.Request.Context()
cached, err := m.getCachedResponse(ctx, key)
if err == nil && cached != nil {
c.JSON(cached.Status, cached.Body)
c.Abort()
return
}
acquired, err := m.redis.SetNX(ctx, "idem:"+key, "1", m.ttl).Result()
if !acquired {
for i := 0; i < 30; i++ {
time.Sleep(100 * time.Millisecond)
cached, err = m.getCachedResponse(ctx, key)
if cached != nil {
c.JSON(cached.Status, cached.Body)
c.Abort()
return
}
}
c.JSON(409, gin.H{"error": "duplicate request in progress"})
c.Abort()
return
}
blw := &bodyLogWriter{body: bytes.NewBufferString(""), ResponseWriter: c.Writer}
c.Writer = blw
c.Next()
m.saveResponse(ctx, key, c.Writer.Status(), blw.body.String())
}
}
The SETNX + poll wait pattern (100ms × 30 = 3 seconds) is the standard at Stripe, Alipay, and other major platforms.
Payment Idempotency in Practice
Payment Flow with Idempotency
- Client generates Idempotency-Key (UUID)
- POST /api/payments with header
- Idempotency check: Redis SETNX → dedup table → order status check
- Business processing in DB transaction
- Cache response to dedup table + Redis
- Return result
Three Special Considerations
- Payment channel idempotency: pass merchant order number (out_trade_no) to WeChat/Alipay
- State machine:
pending → processing → success/failed— retries on success always return success - Reconciliation fallback: daily reconciliation catches inconsistencies idempotency cannot prevent
Interview FAQs and Pitfall Guide
Q1: GET is idempotent, why not POST?
GET is read-only. POST is write — may create new resources. Idempotency keys make POST idempotent.
Q2: Does idempotency work if Redis is down?
Layers 2 and 3 still work. Redis is a performance layer, not the only guarantee.
Q3: Can distributed locks implement idempotency?
Possible but not optimal. Locks solve mutual exclusion; idempotency keys + dedup tables are simpler and more reliable.
Q4: Relationship between idempotency and transactions?
Idempotency ensures multiple executions = one execution. Transactions ensure atomicity. Payment needs both.
Pitfall Checklist
| Pitfall | Symptom | Fix |
|---|---|---|
| Server-generated key | Different key on retry | Client must generate and reuse |
| Only cache success | Failed retry returns old error | Cache both success and failure |
| No TTL | Unlimited data growth | TTL 24-72h + cleanup job |
| No lock on concurrency | Two requests pass check | SETNX + poll wait |
| Ignore payment channel idempotency | Local ok but channel charged twice | Globally unique merchant order ID |
Inventory Deduction Idempotency
Beyond payments, inventory deduction is the second most common idempotency scenario. Unlike payments (create new records), inventory updates existing rows — race conditions are subtler.
Use idempotency key + conditional update:
result, err := tx.ExecContext(ctx, `
UPDATE inventory SET quantity = quantity - $1, version = version + 1
WHERE product_id = $2 AND quantity >= $1
`, req.Quantity, req.ProductID)
WHERE quantity >= $1 is the database-level atomic guard against overselling.
Distributed Environment Challenges
- Multi-instance: SETNX + poll wait pattern (Stripe standard)
- Redis failover: Redis is acceleration only; DB unique constraint is final defense
- Clock skew: Use
NOW() + INTERVALfrom database, not local clocks
Idempotency with Message Queues
Kafka producer idempotence (enable.idempotence=true) only covers single-producer-to-single-partition retries. Consumers still need business-level dedup via processed_events table or business unique keys.
Performance Optimization
| Approach | P99 Latency | Suitable QPS |
|---|---|---|
| DB unique constraint only | 15-30ms | < 1,000 |
| Redis SETNX + DB | 3-8ms | < 10,000 |
| Redis + Bloom Filter + DB | 1-3ms | < 50,000 |
Enterprise Case Study
A mid-size payment platform (2M daily transactions) eliminated duplicate charges after implementing three-phase rollout: Idempotency-Key headers → shared middleware → partitioned idempotency table with 72h TTL. Result: zero duplicate charge incidents over 8 months, P99 check latency 4.2ms.
Advanced Interview Questions
Q7: Return 201 or 200? First creation returns 201; idempotent replay returns 200 with cached result.
Q8: Global or per-user unique key? Prefer UNIQUE(user_id, idempotency_key).
Q9: GraphQL mutations? Same Idempotency-Key HTTP header at gateway level.
Hands-On: 30-Minute Idempotent Payment API
curl -X POST http://localhost:8080/api/payments \
-H "Idempotency-Key: test-key-001" \
-d '{"order_id":"ORD-001","amount":9900}'
# Repeat same request — should return identical result, no double charge
2026 Trends
Gateway plugins (Envoy, Kong) for first-line dedup; OpenTelemetry integration for idempotency hit rate monitoring; AI Agent Function Calling requires API-level idempotency as last defense.
Summary and Further Reading
Remember the core formula: Client idempotency key + Redis SETNX + Database unique constraint = Production idempotency.
Related reading:
References:
Try these browser-local tools — no sign-up required →