Idempotency Keys — Developer Guide
Idempotency keys let clients safely retry POST, PUT, PATCH, and DELETE requests without worrying about double-applying the side effect. Same key + same body = same response, for 24 hours.
This is the Stripe model (Idempotency-Key header); API consumers familiar with Stripe's SDK should feel at home.
Client usage
Include an Idempotency-Key header with any write request you want to protect:
POST /api/v1/risks HTTP/1.1
Authorization: Bearer <api-key>
Idempotency-Key: r9f7d2c4-e1a8-44b3-9c7e-3f8b1e9d0a2c
Content-Type: application/json
{ "title": "New risk", "riskScore": 18 }
On a first call, the server executes normally and caches the response for 24 hours. On any retry within that window with the same body, the server skips the handler and returns the exact cached response (same status code + same body).
Key format: [A-Za-z0-9._-]{8,128}. UUIDs, short base64 tokens, and business-identifier strings all fit.
Public API (v2) — a real example
Idempotency applies to every public-API v2 route the same
way it applies to the app's own session-authenticated routes — including
API-key-authenticated write endpoints such as POST /v2/risks
(risks:write permission scope; see
public-api-reference.md for the full v2
route list). Retry the exact same request below any number of times with
the same Idempotency-Key and body and you get back the exact same 201
and risk record — the risk is created once:
curl -X POST https://app.cmdash.net/api/v2/risks \
-H "Authorization: Bearer cmdash_ek_your_api_key" \
-H "Idempotency-Key: 8f14e45f-ceea-467e-9f4d-4e5a3a8fcb1a" \
-H "Content-Type: application/json" \
-d '{
"title": "Vendor contract missing a DPA",
"category": "Third-Party",
"likelihood": 3,
"impact": 4
}'
Run that exact command a second time (same key, same body) before the
24-hour TTL expires and the API returns the identical 201 response —
including the same risk id — without creating a second risk. Change the
body while reusing the same key and the API returns 422 instead (see
error-catalogue.md for the full error-envelope
shape a 4xx/5xx from this or any other public-API route takes). Two
different API keys belonging to two different tenants may safely reuse the
literal same Idempotency-Key string — keys are scoped by tenant first,
whether the caller authenticates with an API key or a browser session.
Error responses
| Scenario | HTTP | Meaning |
|---|---|---|
| Key format invalid | 400 | Server rejected the key shape before storing anything. Client should generate a new key. |
| Same key in flight (concurrent retry) | 409 Conflict | A prior request with this key is still running. Client should retry in a moment, or wait for the original call's response. |
| Same key, different body | 422 Unprocessable Entity | The key has already been used for a different payload. Client has a bug — either the client used a stale key or reused an unrelated one. Generate a new key. |
| First-time success | (handler status) | Normal response; server cached it. |
| Retry success | (cached status) | Identical response to the first-time success; the handler did not run a second time. |
Server-side semantics
-
Lookup — on receipt of
Idempotency-Key, the interceptor queries theidempotency_recordstable by(tenantId, method, path, key):- No row → reserve a PROCESSING row and run the handler.
- PROCESSING row → 409 Conflict (concurrent retry).
- COMPLETE row, body hash matches → return cached response (handler does not run).
- COMPLETE row, body hash differs → 422.
- FAILED row → treated as absent. Retry is allowed to proceed (failures are not cached — the side effect didn't succeed the first time).
-
Reserve — atomically insert a PROCESSING row. If a concurrent reservation loses the race (Prisma
P2002unique violation), the loser gets 409. -
Complete — on handler success, the interceptor stores
statusCode+responseBodyand flips the row to COMPLETE. -
Fail — on handler exception, the row flips to FAILED. The original exception bubbles up untouched. Sequential retries will run fresh; concurrent retries during the same attempt still get 409.
All records expire after 24 hours. A scheduled sweep deletes expired records.
File uploads (multipart/form-data)
The request fingerprint includes the uploaded bytes. A retry that keeps
the same key and the same form fields but attaches a different file is a
different request: it returns 422, not the first file's cached response.
This means re-uploading a corrected file under the same key is never answered with the response for the file it replaced.
How it works:
- The fingerprint covers the parsed form fields plus, for each file, its field name, filename, MIME type and a content digest. Large uploads are hashed as a stream, so they are never buffered just to be fingerprinted.
- For a request with no files, the fingerprint is the same as for any JSON body.
- A multipart request carrying an
Idempotency-Keyon a route that does not support idempotency is refused withIDEMPOTENCY_UNSUPPORTED_ROUTE(422) rather than processed with the key quietly ignored.
The official SDKs
Both official SDKs send the header for you.
| Client | How |
|---|---|
JavaScript SDK (@cmdash/api-sdk) | Automatic on every POST/PUT/PATCH/DELETE. Pass your own as the last argument of a mutating method (client.v2.evidence.push(body, 'nightly-2026-09-19')) when the retry lives outside the process. maxRetries (default 2) retries transport failures, 429 and 502/503/504 reusing the same key. |
Python SDK (cmdash-api-sdk) | Identical: automatic key, idempotency_key= keyword on every mutating method, max_retries=2. |
What gets cached
- Full response body (as JSON) and HTTP status code.
- Response headers are NOT cached. If your endpoint sets critical response headers (e.g.
Location,X-RateLimit-*), be aware they are re-computed on each retry from the handler's view — but since the handler didn't run, they come from the interceptor's defaults. - Side effects in the handler (writes, emails, external API calls) happen exactly once per (key, body) pair, which is the whole point.
What does NOT get an idempotency key
- GET / HEAD — HTTP spec already makes these idempotent; no need.
- Inbound webhook receivers — they deduplicate on the sending provider's event ID, not a client-supplied header.
- Write-side effects that happen outside the HTTP lifecycle — e.g., BullMQ job handlers. Those need their own deduplication at the job level.
Tenant scoping
Keys are scoped per tenant. Two different tenants can use the same idempotency-key string and they won't collide. Pre-auth write paths (webhooks, when we ship them) store tenantId = NULL — uniqueness in that case falls back to (method, path, key) alone, which is still safe because providers emit globally-unique event IDs.
Retry strategy for API clients
- Generate the key once before the first attempt. Reuse it across retries until the call succeeds (or permanently fails).
- Do NOT generate a new key per retry — that defeats the whole mechanism.
- Exponential backoff between retries, capped at ~30s. After a minute, give up and surface the error.
- On 409 Conflict: wait 1–2 seconds and retry — the original request is still in flight.
- On 422: stop and investigate — the key was reused with a different body, which is almost always a client bug.
The official SDKs implement exactly this policy for you — key minted once
per call, reused across retries, 500 and 422 never retried. Reach for the
hand-rolled loop below only if you are not using them:
async function retryableCreate(payload) {
const key = crypto.randomUUID();
for (let attempt = 0; attempt < 5; attempt++) {
try {
return await api.post('/v1/risks', payload, {
headers: { 'Idempotency-Key': key },
});
} catch (err) {
if (err.status === 409) { await sleep(1000 * (attempt + 1)); continue; }
if (err.status >= 500) { await sleep(1000 * (attempt + 1)); continue; }
throw err; // 400/422/other — don't retry
}
}
throw new Error('retry exhausted');
}
Opting in per endpoint
Every write endpoint supports idempotency keys; you opt in per request by sending the header. To opt out, do not send it.
Anti-patterns
- ❌ Caching stateful GET responses in this table. The idempotency interceptor only runs on write methods by design.
- ❌ Relying on idempotency for exactly-once semantics across systems. If your handler calls out to an external API, that external call is only idempotent if the external API itself supports idempotency keys. Use Stripe-style passthrough (client-key → Stripe as
Idempotency-Key) where possible. - ❌ Using the same key for unrelated requests. Keys are per-operation. Don't reuse
user-42-keyfor both "create risk" and "update risk" — they're different operations, use different keys. - ❌ Forgetting to handle 409. 409 means "slow down and retry" — not "the operation failed." Clients that treat 409 as a hard error will surface false negatives to end users.