Idempotency keys for AI agents: what shipping ours taught us

A worked example of idempotency keys for AI agents: client-supplied vs server-derived keys, a Redis-down fallback, and a CLI key that only half-works.

An idempotency key is a value the caller generates before making a request and sends alongside it, usually as an Idempotency-Key header. The server remembers the first response it gave for that key and replays it — status code and body — on every retry, instead of doing the work again. That’s the whole mechanism. The interesting part isn’t the definition; it’s what happens when you actually build the thing behind it, which is what this post walks through, using our own API.

Why this matters more for agents than for humans

A person who clicks “submit” and watches a spinner hang will, worst case, click it again once, annoyed. An AI agent calling your API doesn’t have that hesitation, and it doesn’t remember what it already tried across a fresh process. A network blip, a timeout on a slow endpoint, or a coding agent that re-plans a step it already executed will all produce the same shape of problem: the same logical write, fired more than once, with nothing on the caller’s side stopping it. If your API creates a resource, charges a card, or kicks off a monitor every time it sees a POST, “the agent retried” and “the agent asked for five of these” become indistinguishable failures.

We say this plainly in our own engineering guidelines: agents retry on network blips; state transitions must be safe to re-enter. It’s not a hypothetical for us — Prufa’s API is called by a CLI, third-party MCP clients, and CI pipelines, none of which have a human watching the response in real time. Idempotency keys are the mechanism that makes “safe to re-enter” true instead of aspirational.

What we actually shipped

The plumbing lives in one shared module (v1_common.py) that every v1 router imports, so every mutating endpoint gets the same contract instead of five slightly different ones. The shape is close to Stripe’s, deliberately — Stripe’s Idempotency-Key convention is what most agent-facing APIs converge on now, license-free and well understood:

IDEMPOTENCY_TTL_S = 24 * 3600

async def idempotency_lookup(key: str) -> bytes | None:
    try:
        value = await _client().get(_redis_key(key))
        if value is not None:
            return bytes(value)
    except Exception as exc:
        logger.warning("idempotency_redis_unavailable", op="lookup", error=str(exc))
    entry = _memory_cache.get(key)
    ...

Two decisions in that small function are the ones a “just add a UUID header” tutorial skips:

  1. 24-hour expiry. Long enough to outlast any realistic retry storm, short enough to keep the store bounded. We didn’t invent this window — it’s Stripe’s documented default, and we copied it because there’s no reason not to.
  2. A degraded fallback, logged, never silent. If Redis is unreachable, the lookup doesn’t 500 and it doesn’t pretend nothing happened — it falls through to an in-process dict and logs idempotency_redis_unavailable so the degradation shows up in our own monitoring. A cache that works most of the time and fails loud the rest of the time beats one that’s “reliable” until the one moment it silently isn’t. That’s the same “no silent failures” invariant we apply to monitoring that could otherwise pollute your analytics without telling you — a boundary condition should always be visible, never a quiet, undetected downgrade.

Two different problems that look like one

The part we didn’t expect going in: idempotency isn’t one pattern in this system, it’s two, and conflating them would have been a mistake.

Client-supplied keys protect the caller’s own retry behavior. Our free-audit endpoint (POST /api/v1/audits) checks an optional Idempotency-Key header before doing any work and stores the response at every exit point once it’s done. This is the caller’s safety net: if you retry with the same key, you get the same run back, not a second one. We layer a second, independent dedup on top for callers who send no key at all — a short time-windowed check on the same URL and IP — because most first-time callers (a curl one-liner, a browser hitting the free audit) won’t think to generate one. The idempotency key is the agent-facing mechanism; the duplicate-URL window is the fallback for everyone else.

Server-derived keys protect a business invariant regardless of what the caller does. Our billing endpoints (create_billing_checkout, create_credits_checkout) accept an optional Idempotency-Key, but if the caller omits it, the server falls back to a key it derives itself — a workspace ID plus the tier or credit amount being purchased. The point isn’t retry safety for the caller; it’s that we do not want to double-charge a workspace for the same upgrade even if the caller — often an agent hitting the endpoint from a fresh process with no memory of the header it sent last time — forgets to send a key at all. Client-supplied keys are a courtesy you extend to well-behaved callers. Server-derived keys are the floor under everyone, including the ones who never read the docs.

Both live in the same codebase, both use the exact same lookup/store functions, and neither substitutes for the other. A single “add an idempotency key” checklist item hides that these are answers to different questions: “did the caller already ask for this?” versus “could this business action happen twice no matter who’s asking?”

The failure mode that’s easy to miss

Our own CLI is the honest cautionary example. It mints an idempotency key per invocation from the current timestamp: f"cli-audit-{int(time.time() * 1000)}". That protects a single call’s retry — if the underlying HTTP client retries the exact same request object after a timeout, the key is stable across that retry, so the server correctly replays instead of double-creating. What it does not do is dedupe two separate invocations of the CLI a few seconds apart, because the millisecond timestamp changes every time you run the command. If a wrapper script calls our CLI twice by mistake, both calls get a fresh key and both go through.

That’s the mistake a generic “just send a UUID” post glosses over, because it never gets specific enough to have an opinion: the key has to be stable across retries of the same logical operation, not regenerated on every attempt. A fresh UUID (or a fresh timestamp) per call is not an idempotency key at all — it’s a fingerprint that happens to look like one.

Honest limits

This pattern has real edges, and the SERP for this topic is generous with idealized advice and short on admitting them:

  • There is no ratified HTTP standard. The IETF’s draft-ietf-httpapi-idempotency-key-header has circulated since at least 2021, but as of July 2026 it’s still an expired Internet-Draft, not an RFC. What everyone implements — us included — is Stripe’s convention, not a spec.
  • This doesn’t replace a saga or outbox pattern. Idempotency keys make a single request safe to retry. They don’t coordinate a multi-step workflow that spans several services; if your operation touches three systems and the second one fails, you need a different pattern on top of this one, not instead of it.
  • This post is scoped to our own v1 HTTP API — the same surface our CLI, and any third-party MCP client, calls into. It doesn’t describe the internals of our separate open-source prufa-mcp project, which we didn’t re-verify for this piece.

Where this fits

None of this is exotic — Stripe shipped the pattern years ago, and there’s a reasonable argument it’s table stakes for any API a machine calls without a human in the loop. What’s less common is seeing the two variants (client-supplied and server-derived) side by side in one system, or a team admitting where their own client’s key scheme falls short. If you’re building — or evaluating — an agent-facing API or an MCP server, that’s the level of detail worth checking for, not just whether an Idempotency-Key header is documented. The same instinct that makes us verify a mutation is safe to retry is what makes our audits verify a claim with browser evidence instead of an LLM’s opinion: a deterministic check beats a plausible-sounding one, whether the thing being checked is a login flow or a POST request.

Frequently asked questions

Does an idempotency key need to be a UUID?

No — Stripe recommends a V4 UUID or any random string with enough entropy to avoid collisions, but the format isn't the contract. What matters is that the same logical operation reuses the same key across retries. A deterministic key derived from stable identifiers (a workspace ID plus an action name, for example) works just as well, and for server-side fallbacks it works better, because it doesn't depend on the caller remembering to generate anything.

What happens if two requests use the same idempotency key but a different body?

Stripe's documented behavior is to treat that as an error — the idempotency layer compares incoming parameters to the original request and rejects a mismatch, to prevent a reused key from silently returning the wrong cached result for a different operation. A minimal implementation that just replays on key match without checking the body is a real footgun: it will happily return run A's response to a request for run B.

Should idempotency keys expire?

Yes — an unbounded key store grows forever and eventually a legitimate reuse (a client that recycles key strings after months) collides with stale data. 24 hours is the common default (Stripe uses it; we do too) because it comfortably outlasts any retry storm — a caller stuck retrying for 24 hours has a bigger problem than idempotency — while keeping the store bounded.

Is there an official HTTP standard for idempotency keys?

Not a ratified one, as of July 2026. The IETF HTTPAPI working group has circulated draft-ietf-httpapi-idempotency-key-header since at least 2021, currently at revision 07, but it remains an expired Internet-Draft, not an RFC. In practice, Stripe's Idempotency-Key convention became the de facto standard everyone else copied — including us — which is worth knowing before you assume you're implementing a spec rather than a widely-copied convention.