Idempotency keys
A network can fail after your request arrives and before the response reaches you. When that happens your code cannot tell a request that never landed from one that succeeded silently, and retrying blindly creates a second timer. An idempotency key removes the ambiguity: send the same key again and you get the original result back rather than a duplicate.
Where it is required
Two endpoints require an Idempotency-Key header, and they are the two that create a billable resource.
| Endpoint | Header | Why |
|---|---|---|
POST /timers | Required | Creates a new timer and consumes one from your monthly allowance. |
POST /timers/{id}/duplicate | Required | A duplicate is a new timer, so it consumes an allowance too. |
Nothing else needs one. GET and DELETE are already idempotent by definition, and PATCH is protected differently — it uses If-Match with a revision, which solves a related but distinct problem. See update and delete.
missing_field. That is a deliberate design choice: the duplicate-timer failure is silent and expensive, and an opt-in safety mechanism is one almost nobody opts into.How a replay is resolved
The key is stored with a fingerprint of the request body and scoped to your account and environment. When a request arrives carrying a key that has been seen before, one of three things happens.
New key
201 CreatedThe timer is created normally and the key is recorded against it.
Same key, same body
201 with the originalNo second timer, no second charge against your allowance. You get exactly what the first call produced.
Same key, different body
409 idempotency_conflictA key that identifies one request cannot also identify a different one. Nothing is created.
Request
curl -X POST https://countdownshare.com/api/v1/timers \
-H "Authorization: Bearer $COUNTDOWNSHARE_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order_18823_hold" \
-d '{
"name": "Reservation for order 18823",
"type": "duration",
"duration_seconds": 900
}'Same key, different body
HTTP/1.1 409 Conflict
{
"error": {
"code": "idempotency_conflict",
"message": "This Idempotency-Key was already used with a different request body"
},
"request_id": "7b82b7f7-4d13-497b-9f20-58d46fd7a510"
}Because keys are scoped per account and per environment, a key used in Sandbox does not collide with the same key in Production, and no other account can affect yours.
Choosing a key
This is where the mechanism is usually defeated. The key must be identical across every attempt at the same logical operation, and different for genuinely different ones. A key generated at the moment of the call satisfies neither.
Derive it from what the timer represents
Works
// Derive the key from the thing the timer represents.
// Same order, same key — forever, across retries and process restarts.
const key = `order_${order.id}_hold`;
await call("/timers", {
method: "POST",
headers: { "Idempotency-Key": key },
body: JSON.stringify({
name: `Reservation for order ${order.id}`,
type: "duration",
duration_seconds: 900,
}),
});Does not work
// Every one of these defeats the mechanism.
// Regenerated on each attempt — a retry looks like a new request.
headers: { "Idempotency-Key": crypto.randomUUID() }
// Changes between the first call and the retry a second later.
headers: { "Idempotency-Key": `timer-${Date.now()}` }
// Shared by every timer you ever create.
headers: { "Idempotency-Key": "create-timer" }The test to apply: if your process crashes and restarts, will it produce the same key for the same operation? A key derived from an order ID, an invitation ID, or a subscription ID survives that. A UUID generated inline does not — which is why a retry after a crash creates a duplicate.
| Scenario | A key that works |
|---|---|
Cart hold for an order | order_18823_hold |
Trial countdown per customer | trial_customer_8f21c |
Auction lot closing | auction_lot_4471 |
Nightly job creating one timer per campaign | campaign_912_2030-01-14 |
A queued job with its own ID | job_{job.id} |
order_, trial_ — costs nothing and makes accidental collisions between unrelated workflows essentially impossible.Retrying safely
With a stable key, a retry loop stops being risky. The pattern below retries on the failures worth retrying — 429, 5xx, and network errors — and leaves everything else alone.
Retry with a stable key
async function createTimerWithRetry(body, idempotencyKey) {
const backoff = [0, 1000, 3000, 8000];
for (let attempt = 0; attempt < backoff.length; attempt++) {
if (backoff[attempt]) await sleep(backoff[attempt]);
try {
// Safe to repeat: the key makes attempt 4 return
// whatever attempt 1 created, if attempt 1 got through.
return await call("/timers", {
method: "POST",
headers: { "Idempotency-Key": idempotencyKey },
body: JSON.stringify(body),
});
} catch (error) {
const retryable =
error.status === 429 || error.status >= 500 || error.name === "FetchError";
if (!retryable || attempt === backoff.length - 1) throw error;
}
}
}Do not retry a 400. A validation error will fail identically on every attempt, and retrying it just burns rate limit. The distinction is worth encoding once: 4xx other than 429 means your request is wrong, 5xx and 429 mean try again.
quota_exhausted is a 429 that will not clear by waiting a few seconds — the monthly allowance is spent until the cycle resets. Read the code, not just the status, before backing off. Both are covered under error codes.Resolving idempotency_conflict
A 409 with this code means the key has already been used for a materially different request. It is a bug signal, not a transient failure, and retrying will not clear it.
A shared constant key
Something like "create-timer" is reused for every timer. The second call ever made conflicts. Derive the key from the resource instead.
A key that is too coarse
One key per user, when a user can have several timers. Add what distinguishes them — the plan, the campaign, the date.
The body genuinely changed
The same operation retried after your own code recalculated a duration or a name. Compute the body once, then retry that exact body.
A real second operation
You do want a second timer. Use a different key — this is the mechanism working as intended.