Skip to main content
CMDashCybersecurity Maturity Dashboard
DocsDevelopersIdempotency Keys — Developer Guide

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:

code
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:

bash
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

ScenarioHTTPMeaning
Key format invalid400Server rejected the key shape before storing anything. Client should generate a new key.
Same key in flight (concurrent retry)409 ConflictA 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 body422 Unprocessable EntityThe 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

  1. Lookup — on receipt of Idempotency-Key, the interceptor queries the idempotency_records table 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).
  2. Reserve — atomically insert a PROCESSING row. If a concurrent reservation loses the race (Prisma P2002 unique violation), the loser gets 409.

  3. Complete — on handler success, the interceptor stores statusCode + responseBody and flips the row to COMPLETE.

  4. 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-Key on a route that does not support idempotency is refused with IDEMPOTENCY_UNSUPPORTED_ROUTE (422) rather than processed with the key quietly ignored.

The official SDKs

Both official SDKs send the header for you.

ClientHow
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:

typescript
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-key for 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.

This page is the product manual, published from the repository at docs/developers/idempotency.md. Customers read the same text inside CMDash under Help. Last updated October 9, 2026.

Idempotency Keys — Developer Guide — CMDash Docs