Skip to content

Idempotency

How it works

Pick a unique key per operation (a UUID, or your own id such as an order number) and send it as Idempotency-Key. For 24 hours, the same key on the same route answers the first result again, with Idempotent-Replayed: true, instead of doing the work again.

  • The same key with a different body answers 422 idempotency_conflict.
  • The same key while the first request is still running answers 409 conflict: wait and retry.
  • A request that failed is not stored, so retrying it with the same key does the work.
  • Keys are scoped to the workspace, the caller, the route and its path. The caller is the API key (or, in the app, the signed-in member): two API keys that send the same Idempotency-Key never replay each other's answers, and a rolled API key is a new caller. The same key on two different codes' routes doesn't collide either.
  • PATCH and DELETE don't take a key: repeating them leaves the same state.

Replays never carry a secret

The stored answer never includes a secret. A POST that returns one — registering a webhook endpoint, and creating or rolling an API key in the app — carries the secret only in the first answer, with secretAlreadyShown: false. A replay within the 24 hours answers the same object with secret: null and secretAlreadyShown: true.

If the first answer was lost, the object still exists (its id is in the replay). Rotate the endpoint's secret in the app (or delete the endpoint and register it again), or roll the API key, to get a new secret.

Replay of POST /v1/webhooks (excerpt)
{  "id": "whe_3k8m2q9x7c4v1b6n",  "url": "https://hooks.example.com/dotwick",  "secret": null,  "secretAlreadyShown": true}

Retrying safely

examplePOST /v1/codes
import { randomUUID } from "node:crypto";// one key per logical operation, reused on every retry of itconst idempotencyKey = randomUUID();for (let attempt = 1; attempt <= 5; attempt++) {  const res = await fetch("https://api.dotwick.com/v1/codes", {    method: "POST",    headers: {      Authorization: `Bearer ${process.env.DOTWICK_API_KEY}`,      "Content-Type": "application/json",      "Idempotency-Key": idempotencyKey,    },    body: JSON.stringify({ name: "Spring menu", content: { type: "url", url: "https://example.com/menu" } }),  });  if (res.ok) break; // a replay answers the first result with Idempotent-Replayed: true  if (res.status !== 429 && res.status < 500) throw new Error((await res.json()).error.message);  const wait = Number(res.headers.get("Retry-After") ?? 2 ** attempt);  await new Promise((r) => setTimeout(r, wait * 1000));}

Routes that take a key

Every POST except the multipart upload POST /v1/assets.

MethodPathScopePurpose
POST/v1/codescodes:writeCreate a codeidempotent
POST/v1/codes/movecodes:writeMove codes to a folderidempotent
POST/v1/codes/move-domaincodes:writeMove codes to another domainidempotent
POST/v1/codes/tagscodes:writeAdd or remove tags on many codesidempotent
POST/v1/codes/{id}/duplicatecodes:writeDuplicate a codeidempotent
POST/v1/codes/bulkcodes:writeCreate up to 500 codes as one runidempotent
POST/v1/bulk-imports/{id}/cancelcodes:writeCancel a queued or running runidempotent
POST/v1/bulk-imports/{id}/retrycodes:writeRetry the failed rows of a runidempotent
POST/v1/analytics/exportsanalytics:readScans as CSV, prepared in the backgroundidempotent
POST/v1/reportsanalytics:readRender a PDF or CSV analytics reportidempotent
POST/v1/exportscodes:readExport many codes as one ZIPidempotent
POST/v1/tagstags:writeCreate a tagidempotent
POST/v1/foldersfolders:writeCreate a folderidempotent
POST/v1/templatestemplates:writeCreate a templateidempotent
POST/v1/webhookswebhooks:writeRegister an endpoint and event typesidempotent
POST/v1/webhooks/{id}/testwebhooks:writeSend a webhook.test eventidempotent
POST/v1/webhooks/{id}/deliveries/{deliveryId}/redeliverwebhooks:writeSend a delivery againidempotent
POST/v1/webhooks/{id}/recoverwebhooks:writeResend failed deliveriesidempotent
POST/v1/hookswebhooks:writeSubscribe a hook to one eventidempotent
POST/v1/cardscards:writeCreate a business cardidempotent
POST/v1/cards/{id}/deactivatecards:writeDeactivate a business cardidempotent
POST/v1/conversionsconversions:writeRecord a conversionidempotent
POST/v1/nfc/tagscodes:writeRegister an NFC tag on a codeidempotent