skills/idempotency-key-patterns/SKILL.md
Implementing the `Idempotency-Key` HTTP header pattern (Stripe-pioneered, IETF-standardizing) so retries don't double-charge or duplicate side effects — full Postgres schema with recovery-point state machine, fingerprint check, in-progress lock with 409 response, and 24h retention. Grounded in Stripe blog/docs, the IETF httpapi WG draft, and Brandur Leach's canonical implementation.
npx skillsauth add curiositech/windags-skills idempotency-key-patternsInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
TL;DR: Client generates a unique key (UUID v4 recommended), sends it as
Idempotency-Keyheader on a POST. Server stores(key, request fingerprint, response, recovery_point)for ~24h. Retries with the same key return the cached response. Same key + different body → 422 (IETF) / 400 (Stripe). Concurrent retry while original is in-flight → 409. Without the fingerprint check + in-progress lock + recovery-point state machine, you don't have idempotency — you have a cache that pretends to be one.
| Symptom | Section | |---|---| | "Customer got double-charged on a network retry" | The full pattern | | "Need a Postgres schema, not just a cache" | Schema with recovery points | | "Two retries hit at the same time — race condition" | In-progress lock | | "Worker crashed mid-request, key is stuck" | Recovery points | | "What status code for key reuse?" | Status codes |
flowchart TD
A[POST request arrives<br/>Idempotency-Key: ABC-123] --> B{Key exists in store?}
B -->|No| C[INSERT row with locked_at=now<br/>recovery_point='started']
C --> D[Process request, advancing recovery_point<br/>through atomic phases]
D --> E[Store response, set recovery_point='finished']
E --> F[Return response]
B -->|Yes, recovery_point='finished'| G{Request body fingerprint matches?}
G -->|Yes| H[Return cached response<br/>200/201 with stored body]
G -->|No| I["422 Unprocessable<br/>(IETF) or 400 (Stripe)<br/>'Key reused with different params'"]
B -->|Yes, recovery_point != 'finished'| J{locked_at within<br/>lock TTL?}
J -->|Yes, still in-progress| K["409 Conflict<br/>'Another request with this key is in progress'"]
J -->|No, lock expired| L[Resume from recovery_point<br/>re-acquire lock]
L --> D
From Stripe: Designing robust APIs with idempotency:
Clients [pass] a unique value in with the special
Idempotency-Keyheader.
On a response failure (i.e. the operation executed successfully, but the client couldn't get the result), the server simply replies with a cached result.
The full pattern has three ingredients that work together. Drop any one and you have a leak:
| Ingredient | What it prevents | |---|---| | Key persistence with response cache | Duplicate side effects on simple retry | | Fingerprint check (request body hash) | Buggy clients reusing a key for a different request and getting the wrong cached response | | In-progress lock with TTL | Two concurrent retries both executing the work |
A "key persistence + cache" without the other two is the most common shipped-broken implementation.
There's a real divergence here.
Stripe (API reference):
IETF draft-ietf-httpapi-idempotency-key-header:
The IETF draft says:
The idempotency key MUST be unique and MUST NOT be reused with another request with a different request payload.
It is RECOMMENDED that a UUID or a similar random identifier be used.
Idempotency-Key is an Item Structured Header. Its value MUST be a String.
Pick one (Stripe's is the most-deployed; IETF's will become the standard) and document the choice in your API reference. Don't invent a third.
The de facto canonical implementation reference is Brandur Leach (Stripe), "Implementing Stripe-like Idempotency Keys in Postgres". The schema:
CREATE TABLE idempotency_keys (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- Per-tenant uniqueness
user_id BIGINT NOT NULL,
idempotency_key TEXT NOT NULL CHECK (char_length(idempotency_key) <= 100),
-- Lock + retention
last_run_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
locked_at TIMESTAMPTZ, -- NULL = unlocked, set = in progress
-- Fingerprint check inputs
request_method TEXT NOT NULL CHECK (char_length(request_method) <= 10),
request_path TEXT NOT NULL CHECK (char_length(request_path) <= 100),
request_params JSONB NOT NULL,
-- Cached response
response_code INT,
response_body JSONB,
-- Recovery point state machine
recovery_point TEXT NOT NULL CHECK (char_length(recovery_point) <= 50)
);
CREATE UNIQUE INDEX idempotency_keys_user_id_idempotency_key
ON idempotency_keys (user_id, idempotency_key);
Field semantics (verbatim from the post):
idempotency_key: "the user-specified idempotency key... constrain the field's length so that nobody sends us anything too exotic." Uniqueness is scoped per (user_id, idempotency_key) so different tenants can't collide.locked_at: "indicates whether this idempotency key is actively being worked. The first API request that creates the key will lock it automatically, but subsequent retries will also set it to make sure that they're the only request doing the work."request_params: "stored mostly so that we can error if the user sends two requests with the same idempotency key but with different parameters." (The fingerprint check.)recovery_point: "a text label for the last phase completed for the idempotent request... Gets an initial value of started and is set to finished when the request is considered to be complete."Atomic phase 1 — upsert the key with a lock:
INSERT INTO idempotency_keys
(user_id, idempotency_key, request_method, request_path, request_params,
recovery_point, locked_at)
VALUES
($1, $2, $3, $4, $5, 'started', now())
ON CONFLICT (user_id, idempotency_key) DO UPDATE
SET locked_at = now(),
last_run_at = now()
WHERE idempotency_keys.locked_at IS NULL -- only re-lock if currently unlocked
OR idempotency_keys.locked_at < now() - interval '90 seconds' -- or stale
RETURNING *;
Three branches from the result:
| Result | Branch |
|---|---|
| Returned row, recovery_point = 'finished', fingerprint matches | Return cached response |
| Returned row, recovery_point = 'finished', fingerprint mismatch | Return 422/400 |
| Returned row, recovery_point != 'finished' (we got the lock) | Continue to phase 2 |
| No row returned (UPDATE filtered out: lock held by another live request) | Return 409 Conflict |
The 90-second TTL on the stale-lock check covers crashed workers — they'll be re-acquired by the next retry. Tune to your worst-case request latency.
Two layers of defense: the unique index + the locked_at check. The index prevents two INSERTs racing on a fresh key. The locked_at check prevents two UPDATEs racing on an already-existing key.
The hard problem: a worker crashes mid-request after charging the customer's card but before recording the order. A naive retry double-charges. A naive "reject all retries on partial state" loses the order.
Brandur's pattern: each foreign side effect commits its own atomic phase, advancing recovery_point:
started → ride_created → charge_created → finished
↑ ↑ ↑ ↑
tx1 tx2: insert tx3: charge tx4: send email,
local row Stripe API mark finished
+ advance RP
Each phase:
recovery_point to the next state.On retry, you read recovery_point and resume from there. Because each phase is atomic, you either did the side effect and advanced the marker, or did neither. No double-charges.
The post puts it concisely:
Each foreign state mutation (charge Stripe, send email) gets its own phase. Each phase commits a new
recovery_pointso a crashed retry resumes mid-flow rather than restarting.
The discipline: at most one external side effect per atomic phase. Two side effects in one phase reintroduces the dual-write hazard.
A common mistake: using Redis as the only idempotency store. Redis is great for the in-progress lock (TTL'd lease, auto-expires on crash) but lossy for the response cache (eviction, persistence configs vary).
The pragmatic split:
| Layer | Backend | Why |
|---|---|---|
| In-progress lock | Redis with SET key NX EX 90 | TTL'd, fast, auto-cleanup on crash |
| Durable record | Postgres (the schema above) | ACID, unique constraint as last-line defense |
| Response cache | Postgres response_body column | Read on retry within retention window |
Or just Postgres for everything (Brandur's choice) — simpler if your throughput allows it. Use Redis only when you have hard latency budgets that rule out a DB roundtrip on the hot path.
24 hours is the canonical window (Stripe). Reasons it's not "forever":
audit_records table, not the idempotency tableA reaper job:
DELETE FROM idempotency_keys
WHERE created_at < now() - interval '24 hours';
If your domain table references idempotency_keys.id (e.g., orders.idempotency_key_id), use ON DELETE SET NULL so the reaper doesn't cascade-delete real records.
| Anti-pattern | Why it bites | Fix |
|---|---|---|
| Server generates the key | Defeats the purpose — can't survive a network retry | Client generates UUID v4 before the request |
| No fingerprint check | Buggy client reuses key with different body → returns wrong response | Always check request_params matches; 422/400 if not |
| In-progress lock with no TTL | Crashed worker → key stuck "in progress" forever | TTL on the lock (Redis EX, or locked_at < cutoff check) |
| One atomic phase containing 2+ external side effects | Crash between them → re-execute on retry → double-charge | One side effect per phase; advance recovery_point between them |
| PII as the key (email, phone) | Logs leak; legitimately different requests collide | Random opaque UUID |
| Retention < 1 hour | Mobile clients on flaky networks can't retry | 24h is the floor; tune up for offline-first apps |
| Permanent retention as audit log | Conflates two concerns; fingerprint hashes outlive their utility | Separate audit_records table for compliance/audit |
| Treating GET/PUT/DELETE as needing idempotency keys | They're already idempotent per HTTP semantics | Only POST and other unsafe mutators benefit |
| Returning the cached response without checking the user matches | One tenant's key collides with another's; data leak | Scope uniqueness per (user_id, key) |
| | Novice | Expert | |---|---|---| | First "idempotent endpoint" | Stores key + response in a hash | Schema with fingerprint + recovery_point + per-tenant scope | | On concurrent retry | Both run, double-side-effect | 409 from in-progress lock; second retry waits for first to finish | | On worker crash mid-request | Permanently stuck in-progress | Stale lock detection; resume from recovery_point | | On client bug reusing key | Returns stale response | 422/400 with explicit error | | Storage | Redis-only (loses keys on eviction) | Postgres-durable + Redis-fast-lock | | Cleanup | None — table grows forever | 24h reaper, audit table separate |
Timeline test: simulate a network partition during a payment request. An expert system either succeeds exactly once or returns a clean retryable error. A novice system charges the customer twice — and the postmortem reveals there was no fingerprint check.
An idempotency implementation ships when:
pg_advisory_lock to gate the second).(user_id, idempotency_key) exists; verified by EXPLAIN of the upsert.sagas-garcia-molina-salem-1987 or cqrs-event-sourcing-architect)kafka-eos-design)webhook-receiver-design)outbox-pattern-implementation)circuit-breaker-and-retries)Idempotency-Key headerdata-ai
license: Apache-2.0 NOT for unrelated tasks outside this domain.
development
Use when designing caching strategies (cache-aside, write-through, write-behind), implementing distributed locks, building rate limiters, leaderboards, real-time streams (XADD/consumer groups), pub/sub, or tuning eviction policies. Triggers: thundering-herd on cache miss, dogpile on key expiry, Redlock vs SET-NX-PX choice, sliding-window rate limiter, hot-key on a single cluster slot, big-key blowup, MULTI/EXEC across slots, KEYS in production. NOT for Redis Cluster operations/admin (different domain), embedded KV (SQLite, leveldb), in-process LRU caches, or Memcached.
tools
Drawing the `'use client'` boundary correctly in React Server Components apps (Next.js App Router, RSC frameworks) — leaf-pushing, slot composition, serialization rules, and environment poisoning prevention. Grounded in react.dev and Next.js 16 docs.
development
Use when designing rate limiting for an API, choosing between token bucket / sliding window / leaky bucket / fixed window, implementing it in Redis, deciding edge (Cloudflare/Upstash) vs origin enforcement, sizing per-user vs per-IP vs per-endpoint quotas, returning the right 429 response with Retry-After, or fixing the boundary-burst bug in fixed-window limiters. Triggers: 429 too many requests, INCR + EXPIRE, ZADD + ZREMRANGEBYSCORE + ZCARD, X-RateLimit-Remaining header, Cloudflare WAF rate limiting rules, Upstash @upstash/ratelimit, leaky bucket shaping vs policing, distributed rate limiter consistency. NOT for DDoS mitigation specifically (different scale), CAPTCHA / bot management, full WAF design, or per-user quota billing.