Agent infrastructure
API strategy
API idempotency for AI agents: a 7-step practical guide to idempotency keys, safe retries for tool calls, request hashing, and at-least-once execution.

Agents retry. That's the default behaviour, not a bug. A model times out mid-tool-call, a network blips, an orchestrator reruns a step after a partial failure — and the same POST request lands on your API twice. Without idempotency, the second call creates a duplicate invoice, sends a second refund, or books a second meeting. With idempotency, it returns the original result and moves on.
By the end of this guide you'll know how to add idempotency keys to a JSON API, how to design agent tools that generate and reuse them correctly, and how to handle the failure modes that break naive implementations. Prerequisites: a REST or RPC-style API you own, a request-scoped datastore (Postgres, Redis, or similar), and an agent calling your API through a tool definition. Time required: about a day for a working implementation, longer if you're retrofitting.
Not every endpoint needs one. Idempotency keys are for non-idempotent operations: POSTs that create resources, endpoints that trigger side effects (send email, charge a card, publish an event), and mutations where a duplicate call causes real damage.
GET, HEAD, and PUT are already idempotent by HTTP semantics — same input, same result, no key needed. DELETE is idempotent in spec but often not in practice; audit yours.
Build the list before writing code:
Expected output: a short table of endpoints that require idempotency support. Everything else is out of scope for this work.
Pick the shape of the key before writing the handler. Two decisions:
Where the key lives. Standard practice is a request header — Idempotency-Key is the widely-recognised name, popularised by Stripe and PayPal and adopted in the IETF draft. Use that header. Don't invent a new one.
What a valid key looks like. Accept any client-supplied opaque string up to 255 characters. Recommend (in your docs) that clients use a UUID v4 or similar high-entropy value. Reject empty strings and keys longer than your limit with 400 Bad Request.
Scope. A key is scoped to (authenticated principal, endpoint, key value). Two different users sending the same key value must not collide. Two different endpoints with the same key must not collide either.
Document this contract in your OpenAPI spec so agent SDK generators and human developers see the same rules. See our guide on OpenAPI specification best practices for what agents actually need in a spec.
The naive implementation caches responses by key alone. That breaks the moment a client reuses a key with a different body — either accidentally (bug) or maliciously. You must detect the mismatch.
On every request to an idempotent endpoint:
(principal_id, endpoint, key) → (request_hash, status, response_body, created_at).Behaviour:
200 (or the original status). This is the safe-retry path.422 Unprocessable Entity with a body explaining the conflict. Do not execute. Do not overwrite. This is a client bug and you want it loud. (Some APIs, including Stripe, return 409 for this case instead; 422 more precisely signals a semantic payload problem versus a state conflict — either is defensible as long as you're consistent.)Example storage row:
CREATE TABLE idempotency_records (
principal_id TEXT NOT NULL,
endpoint TEXT NOT NULL,
key TEXT NOT NULL,
request_hash BYTEA NOT NULL,
status_code INT,
response_body JSONB,
locked_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (principal_id, endpoint, key)
);
The execution has three phases. All three must be safe against concurrent retries.
locked_at = now(). Use INSERT ... ON CONFLICT DO NOTHING RETURNING *. If the insert succeeds, you own the execution. If it doesn't, another request already claimed the key — read the existing row and branch on its state.status_code, response_body, and completed_at.If the handler throws, clear the row (or mark it failed with a short TTL) so the client can retry with the same key and actually re-execute. A permanent negative cache would be worse than no idempotency at all — the agent would be stuck.
The hardest case: an agent sends request A, gets no response before its timeout, and sends request A' with the same key while A is still executing. Your row exists, is locked, but has no completed_at.
Two acceptable answers:
409 Conflict with a Retry-After header. Cheap to implement, forces the client to back off. Works well when your P99 handler latency is well under the agent's retry timeout.completed_at). Only worth it if handlers are slow enough that clients would retry mid-flight regularly.Pick one. Document it. Most teams should ship the 409 version first and only add blocking if telemetry shows it matters.
Idempotency records grow forever if you let them. A common choice is 24 hours (matching Stripe's v1 API window) — long enough to cover any reasonable retry budget, short enough that the table stays cheap. The IETF draft itself doesn't mandate a window, and some providers (including Stripe's newer v2 namespace) retain for much longer.
created_at to the primary key's neighbours and an index for cleanup.If you can't retain for 24 hours (extreme write volume), retain for at least 10x your P99 client retry budget. Anything shorter and you'll see duplicates.
Server-side idempotency only helps if the client sends the same key on retry. This is where most agent implementations fail.
Three rules for the tool definition:
Where this sits architecturally matters. The tool runtime — the layer that actually invokes the API — is the right place to own key generation and retry policy. See orchestrator vs tools layer for why this responsibility belongs there rather than in the orchestrator or the model. Combine this with a circuit breaker so retries don't hammer an already-unhealthy endpoint.
500, don't cache it as the idempotent response. The retry should re-execute. Only cache successful outcomes and deterministic 4xx client errors (like 422 for a mismatched hash).422. If you skip this check, a buggy agent that mutates arguments mid-retry will corrupt data silently.Idempotency isn't a nice-to-have when your consumers are agents. It's the contract that lets retries be safe, and safe retries are what lets an agent recover from partial failure without a human in the loop. Ship it before the first duplicate charge shows up in support.
Stay up to date on the ever changing agentic landscape.