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.
{ "id": "whe_3k8m2q9x7c4v1b6n", "url": "https://hooks.example.com/dotwick", "secret": null, "secretAlreadyShown": true}Retrying safely
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.
| Method | Path | Scope | Purpose |
|---|---|---|---|
| POST | /v1/codes | codes:write | Create a codeidempotent |
| POST | /v1/codes/move | codes:write | Move codes to a folderidempotent |
| POST | /v1/codes/move-domain | codes:write | Move codes to another domainidempotent |
| POST | /v1/codes/tags | codes:write | Add or remove tags on many codesidempotent |
| POST | /v1/codes/{id}/duplicate | codes:write | Duplicate a codeidempotent |
| POST | /v1/codes/bulk | codes:write | Create up to 500 codes as one runidempotent |
| POST | /v1/bulk-imports/{id}/cancel | codes:write | Cancel a queued or running runidempotent |
| POST | /v1/bulk-imports/{id}/retry | codes:write | Retry the failed rows of a runidempotent |
| POST | /v1/analytics/exports | analytics:read | Scans as CSV, prepared in the backgroundidempotent |
| POST | /v1/reports | analytics:read | Render a PDF or CSV analytics reportidempotent |
| POST | /v1/exports | codes:read | Export many codes as one ZIPidempotent |
| POST | /v1/tags | tags:write | Create a tagidempotent |
| POST | /v1/folders | folders:write | Create a folderidempotent |
| POST | /v1/templates | templates:write | Create a templateidempotent |
| POST | /v1/webhooks | webhooks:write | Register an endpoint and event typesidempotent |
| POST | /v1/webhooks/{id}/test | webhooks:write | Send a webhook.test eventidempotent |
| POST | /v1/webhooks/{id}/deliveries/{deliveryId}/redeliver | webhooks:write | Send a delivery againidempotent |
| POST | /v1/webhooks/{id}/recover | webhooks:write | Resend failed deliveriesidempotent |
| POST | /v1/hooks | webhooks:write | Subscribe a hook to one eventidempotent |
| POST | /v1/cards | cards:write | Create a business cardidempotent |
| POST | /v1/cards/{id}/deactivate | cards:write | Deactivate a business cardidempotent |
| POST | /v1/conversions | conversions:write | Record a conversionidempotent |
| POST | /v1/nfc/tags | codes:write | Register an NFC tag on a codeidempotent |