Idempotency
A network timeout does not tell you whether the request succeeded. It tells you that you stopped waiting for the answer. The write may have landed, or it may not, and retrying blind means possibly doing it twice.
Idempotency-Key removes the ambiguity: retry with the same key and you get
the original result back instead of a second write.
Using it
Send the header on any write:
curl -X POST https://api.agentency.com/v1/chatbots \
-H "Authorization: Bearer <YOUR_KEY>" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: create-support-bot-2026-01-14" \
-d '{"name": "Support Assistant"}'The key must be 16–128 characters of letters, digits, - or _. A UUID is a
good default; so is a stable identifier from your own system, like
order-4821-refund.
Responses tell you which happened:
Idempotency-Replayed: false # this request did the work
Idempotency-Replayed: true # this is the original result, replayedThe rule
Use the **same** key when retrying the **same** request. Use a **new** key for a genuinely new one. A key reused with a **different** payload is rejected with `422 IDEMPOTENCY_KEY_REUSED` — the second request is never performed under the first one's key. (Deployments that predate the payload check replay the first result instead; do not rely on either outcome.)
Deriving keys from your own data usually beats generating random ones, because the derived key survives a process restart:
// Good: stable across retries and restarts, unique per intention.
const key = `refund-${order.id}-${attemptWindow}`;
// Risky: a new key on every restart means a retry creates a duplicate.
const key = crypto.randomUUID();Chat turns most of all
A chat turn spends a message credit. A timeout followed by a blind retry is two credits and two entries in the conversation.
curl -X POST https://api.agentency.com/v1/chat \
-H "Authorization: Bearer <YOUR_KEY>" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: turn-9f2c7ab1" \
-d '{"chatbot_id": 1, "message": "Where is my order?"}'POST /v1/chat/stream is protected too, with one difference: an SSE body
cannot be replayed frame by frame, so a retry answers with a JSON summary
of the original turn plus Idempotency-Replayed: true. You recover query_id
and session_id, and you are not billed twice.
While the first attempt is still running
If a request with the same key is in flight, the retry gets:
{
"message": "A previous request with this key is still being processed. Please try again shortly.",
"error": {
"code": "IDEMPOTENCY_IN_FLIGHT",
"message": "A previous request with this key is still being processed. Please try again shortly.",
"details": []
}
}with 409 and a Retry-After. Wait and try again — this is one of the few
409s that is retryable. It exists so two concurrent attempts cannot both
execute.
What gets replayed
Only a successful response is stored. A 4xx or 5xx releases the key, so
you can fix the request and reuse the same key without being stuck with the
failure.
The replay window is limited. After it expires the key is forgotten and a retry executes normally, which is why the window is comfortably longer than any sensible retry schedule.
`INVALID_IDEMPOTENCY_KEY` means the header did not match the required format — usually too short. It is not a validation error on the body, so check the header before you go looking at the payload.
Batches
Where an endpoint accepts several items at once, one key covers the whole
batch. Uploading five files as files[] with one key means a retry returns the
original five rather than creating ten.
Endpoints that do not need it
Reads are naturally idempotent — GET twice, same answer, no side effect.
A handful of POSTs are reads in disguise (semantic search, the removal
preview, URL validation) and need no key either; they compute an answer and
change nothing.
A retry loop that behaves
- Generate the key once, before the first attempt. Never inside the retry loop.
- Send it on every attempt of that request.
- Retry on 429, 5xx and IDEMPOTENCY_IN_FLIGHT. Nothing else.
- Back off exponentially with jitter, honouring Retry-After when present.
- Give up after a bounded number of attempts and surface the request_id.
const key = `create-bot-${crypto.randomUUID()}`; // once, outside the loop
for (let attempt = 0; attempt < 5; attempt++) {
const res = await fetch("https://api.agentency.com/v1/chatbots", {
method: "POST",
headers: {
Authorization: "Bearer <YOUR_KEY>",
"Content-Type": "application/json",
"Idempotency-Key": key,
},
body: JSON.stringify({ name: "Support Assistant" }),
});
if (res.ok) return res.json();
const code = (await res.clone().json().catch(() => ({})))?.error?.code;
const retryable = res.status >= 500 || code === "RATE_LIMITED" || code === "IDEMPOTENCY_IN_FLIGHT";
if (!retryable) throw new Error(code ?? `HTTP ${res.status}`);
const wait = Number(res.headers.get("Retry-After") ?? 2 ** attempt);
await new Promise(resolve => setTimeout(resolve, wait * 1000 + Math.random() * 250));
}Common mistakes
- Generating the key inside the loop. Every attempt becomes a new request, which defeats the whole mechanism.
- Reusing one key for different requests. The second is rejected with
422 IDEMPOTENCY_KEY_REUSED(or, on older deployments, silently returns the first result) — either way it never does what you meant. - Only using it on "important" writes. Chat turns are the expensive ones.
- Treating
IDEMPOTENCY_IN_FLIGHTas fatal. It means "wait", not "stop".
What's next
- Errors — which failures to retry at all.
- Rate limits — backing off correctly.
- Chat — where this matters most.