The server side has one correct shape: claim the key atomically, do the work, store the response in the same transaction, and replay it on every repeat. The atomic claim is what makes the whole thing race free. A SELECT then INSERT leaves a window where two concurrent retries both believe they are first, which is the classic time-of-check to time-of-use pattern OWASP documents for shared state (OWASP). A unique constraint on the key plus INSERT ... ON CONFLICT DO NOTHING closes that window in one statement (backendbytes.com).
A schema that supports the full contract:
CREATE TABLE idempotency_keys (
key VARCHAR(128) PRIMARY KEY,
request_hash VARCHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'processing',
response_code INT,
response_body JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL
);
request_hash is the fingerprint. If the same key arrives with a different body, reject it rather than replaying a response for different arguments; MDN describes servers storing a hash of the request alongside the key exactly for this purpose (MDN).
The response shape should be stable and self-describing, so the agent runtime can tell a replay from a first execution and the test can assert on it.
{
"ok": true,
"idempotent_replay": true,
"original_response": {
"ticket_id": "T-4471",
"created_at": "2026-02-11T09:14:03Z"
}
}
Monday returns the cached body with an Idempotency-Replayed: true header and a 409 Conflict plus Retry-After when a duplicate arrives while the first is still processing (monday.com). Copy that split. A repeat after completion gets the stored body and the replay marker. A repeat during processing gets a conflict, so the caller polls instead of piling on work.
Two decisions deserve a rule of their own.
First, what to cache. The failure mode that bites teams is caching every response, including 500s, so a transient internal error becomes the permanent answer for that key (Mathildaduku). For agent tools, cache definitive outcomes (success, validation failure, business rejection) and leave an unresolved technical failure marked processing or failed_retryable so a later retry can re-attempt without creating a second side effect. Stripe's documented behavior is the stricter public reference point: it saves the resulting status code and body of the first request regardless of success, and subsequent requests with the same key return the same result (Stripe). Pick one and document it, because your test will encode the choice.
Second, expiry. Keys must outlive your longest realistic retry chain. Monday caches for 30 minutes, Stripe keeps keys for at least 24 hours, and general guidance lands between 24 hours and 7 days (monday.com, backendbytes.com, Zuplo). For agent workflows, size it to your slowest recovery path, including a human restarting a crashed orchestrator the next morning. Storage cost is small next to a duplicate refund.
If the tool calls an external provider, the guarantee must reach that provider's own idempotency contract. A local ledger proves intent; only the service that owns the effect can prevent a second charge, and where the mutation and its deduplication record share a transactional store they should commit atomically (Formation, backendbytes.com).