Founders

How to Write an Idempotency Key an Agent Can Derive

Antonio Evans8 min read

An AI agent box sends a request that loops back as a retry, while a hash icon turns business fields into one stored key and result row, drawn as a clean flat technical diagram with short labels.
Key points

Key points

  1. 1An agent should derive its idempotency key from business identifiers, not from anything the model wrote, because model output varies between runs.
  2. 2A canonical string plus a SHA-256 hash gives a key that is deterministic, unique per operation, and stable across retries.
  3. 3The server must store the key with the operation result and reject or replay any request that presents the same key.
  4. 4Testing for collisions and replay safety is a separate exercise from testing the model, and it belongs in CI.

Why a model-generated key fails

The standard idempotency pattern is simple: the client picks a key before the first attempt, sends it in a header, and reuses it on every retry. A server that has seen the key skips the work and returns the stored result. Zuplo's implementation guide lays out the mechanics: the key is created before the first request, never during retries, and stored with the status code and response body.

Agents break the first half of that rule, and they do it in a way that is easy to miss. When a language model writes the key, three things go wrong.

Non-determinism. Ask a model for a key twice with the same prompt and you get two different strings. Even with structured outputs and a strict schema, you get schema compliance, not value stability: the structured outputs chapter puts strict-mode compliance near 99.9%, which is a statement about shape, not content. A retry path that re-asks the model produces a new key, and the server treats the retry as a fresh operation.

Partial failures. The agent may call a tool, lose the response, and re-plan from scratch. The re-planned call has different arguments (a different date format, an extra field the model decided to include) and therefore a different key. Temporal's write-up on idempotency notes that activity execution is not atomic because of timeouts, environment failures, and partial success, which is exactly the window where a re-plan happens.

Parallel tool calls. An agent that fans out to several tools at once can issue the same logical operation on two branches. If each branch mints its own key, both writes land. Shopify's idempotency guidance is blunt about the underlying error: generating a new random identifier on every call defeats idempotency entirely, because every request looks distinct.

The fix is not a better prompt. It is to take key generation away from the model and put it in deterministic code that reads the business identifiers already present in the tool call.

The derivation recipe

An idempotency key needs three properties. OneUptime's event key guide names them: deterministic (same input, same key), unique (different events, different keys), and stable (the key does not change on retry). Content-based hashing is the strategy that gets you all three without trusting the caller.

The recipe has four steps.

  1. Pick the identity fields. These are the values that define the operation: tenant, operation name, resource, and any natural id that already exists (an order number, an invoice id, a shipment id). Not the model's reasoning, not a timestamp, not a random uuid.
  2. Build a canonical string. Join the fields with a separator that cannot appear in a field value, and fix the order. tenant_id:operation:resource_id is the minimum. Add an amount or a version only if the business rule says two different amounts are two different operations.
  3. Hash it. SHA-256 over the UTF-8 bytes of the canonical string, hex-encoded.
  4. Send and store. The agent puts the digest in the Idempotency-Key header; the server stores it next to the result.

In TypeScript, with a stable serializer for any payload fields you choose to include:

import crypto from "node:crypto";

function canonical(parts: readonly string[]): string {
  return parts.map((p) => p.normalize("NFC").trim()).join("\u001f");
}

function deriveKey(tenantId: string, op: string, resourceId: string): string {
  const input = canonical([tenantId, op, resourceId]);
  return crypto.createHash("sha256").update(input, "utf8").digest("hex");
}

The \u001f unit separator matters. A naive join with : breaks the moment a field contains a colon, and two different operations can then hash to the same string. Length-prefixing each field is the stricter alternative: tenant.length + ":" + tenant + ....

If you must include payload fields, hash a stable stringify of just the fields that define uniqueness, and drop timestamps, request ids, and anything the model generated.

Choosing what goes into the key

Most duplicate-charge incidents come from a key that includes too much or too little. Too much and retries look like new work; too little and genuinely separate operations collapse into one.

The decision rule: include a field if a change to it means a different business outcome. Exclude it if a change to it is noise from the retry or the model.

FieldInclude?Why
Tenant idAlwaysPrevents cross-tenant collisions on shared resource ids
Operation name (refund.create)AlwaysSeparates operations that share a resource id
Resource id (order, invoice)AlwaysThe natural business identity
Amount, quantityOnly if a partial value is a separate intentA second refund of a different amount is a new operation; a retry of the same refund is not
Model name or prompt hashNeverModel swaps would mint new keys for the same work
Timestamp, random uuidNeverDestroys determinism
Full tool payloadRarelyA field the model adds on re-plan changes the key

The Shopify documentation draws the same line from the other side: two inventory increments with identical variables are still two operations if the client intended them as separate receipts from separate suppliers, and the client is the only party that knows the intent (Shopify). For an agent, "client intent" has to come from the business record, not from the model's narration of its own intent.

That is the rule that survives a model swap. If the key is sha256(tenant:refund.create:order_8812:v2), moving from one model to another changes nothing about the key, so a retry after the swap still hits the stored result.

Server-side rules that make the key real

A derived key is worthless if the server does not enforce it. Five rules cover most of the ground.

Store the result, not just the key. The record needs the key, the request fingerprint, the status, the response body, and a state. Zuplo recommends keeping keys for 24 hours to 7 days, long enough to cover retry windows without growing the table forever. Shopify uses a 24-hour retention window for its idempotent mutations (Shopify).

Reject mismatched reuse. If the same key arrives with a different fingerprint, return an error rather than replaying. This catches a derivation bug before it silently merges two operations.

Serialize concurrent duplicates. Two parallel tool calls with the same key must not both execute. A unique index on the key plus a row lock, or a distributed lock, makes one wait and the other replay (Zuplo). Shopify returns a dedicated concurrent-request error for the in-flight case.

Record in-progress state. Write the key with a pending status before the side effect, then update to succeeded or failed. Without the pending row, a crash between the side effect and the result write leaves no trace, and the next retry repeats the charge.

Scope keys per tenant. Even with a tenant id inside the hash, enforce tenant scoping in the lookup so a key leak cannot read another tenant's stored response.

A minimal table looks like this:

CREATE TABLE idempotency_keys (
  key           text PRIMARY KEY,
  tenant_id     text NOT NULL,
  fingerprint   text NOT NULL,
  status        text NOT NULL,   -- pending | succeeded | failed
  status_code   int,
  response_body jsonb,
  created_at    timestamptz NOT NULL DEFAULT now(),
  expires_at    timestamptz NOT NULL
);

The fingerprint column holds a hash of the canonical request, not the model's text, so a re-plan with cosmetic differences still maps to a clear rejection rather than a mystery.

Writing the key back into the decision log

Agents that re-plan need to know which operations already happened. A decision log, the running record of what was chosen and why, is the natural place, as long as the idempotency key is one of its fields. Addy Osmani's automated decision log pattern asks the assistant to keep a file recording what, why, and how, then verify it (Osmani). For an agent doing side effects, "what" should include the derived key.

A practical entry:

- operation: refund.create
- key: 9f2c...  (sha256 of tenant:refund.create:order_8812:v2)
- result: 201, refund_id rf_5521
- retried: yes, replayed from store

This closes a gap that outcome-only evaluation misses. The log analysis paper from Princeton and collaborators argues that benchmark scores hide how an agent behaved, and that logs are needed to see shortcuts and deployment failure modes that a pass or fail bit conceals. A duplicated refund that returns 200 looks identical to a single refund in an outcome metric. In the log, the second retried: yes, replayed line is the difference.

The same discipline shows up in config-driven systems: OPA's decision logs attach a decision_id and a trace_id to every policy decision so that each result can be traced and replayed (Open Policy Agent). Treat your derived key as that identifier for side effects.

One caution: the log is a record, not the source of truth. A pending entry means the operation may or may not have landed, and the server's key table, not the log, decides what happens next.

Testing for collisions and replay safety

Two properties need separate tests.

Collision testing checks that distinct operations never share a key. Generate a corpus of realistic triples, derive keys, and assert uniqueness. Include adversarial cases: values containing the separator, values differing only by whitespace or Unicode form, and empty strings. A property test is cheap here:

import hypothesis.strategies as st
from hypothesis import given

@given(st.lists(st.text(), min_size=3, max_size=3, unique=True))
def test_distinct_ops_differ(parts):
    keys = {derive_key(*parts) for parts in permutations(parts)}
    assert len(keys) == 6

Replay testing checks that the same operation produces one side effect. Run the agent against a sandboxed tool, then force a retry by dropping the response and re-invoking the tool with the same key. Assert one charge, one refund, one shipment. Braintrust's guide to multi-agent testing makes the same point about shared state: start from a known state, reset between trials, and turn each requirement into a separate scorer so a polished response cannot hide a skipped or repeated action.

Three test cases catch the majority of real bugs:

TestSetupExpected
Model swap mid-flightRun step one on model A, force retry on model BOne side effect, replayed response
Parallel duplicateTwo tool calls with the same key, concurrentOne executes, one gets concurrent error or replay
Crash after side effectKill the process after the write, before the result is storedRetry finds pending, resolves without a second write

The third is the hard one and the one most teams skip. It is also where deriving from business ids pays off, because a deterministic key can be recomputed from the record alone, with no session memory and no model call.

For the broader question of which HTTP methods are already idempotent and which need keys, the REST API primer is a good refresher: GET, PUT, and DELETE are idempotent, POST and PATCH are not and need the header.

Where this fits in a bigger build

The derivation itself is perhaps forty lines of code. The parts that take time are the key table, the locking, the retention policy, and the tests that simulate a crash. If you are wiring an agent to several model providers and want the retry path to be the same regardless of which model answered, Quanta Inference puts text, image, video, audio and embedding models behind one OpenAI-compatible API with prepaid US-dollar pricing, which keeps the key derivation independent of the transport. When the design questions are bigger than the header, a free 30-minute discovery call helps scope the side-effect boundaries before any code is written: Quanta Consulting.

FAQ

FAQ

Why not just use a UUID per tool call?

A UUID is unique but not stable. If the agent re-plans or the model regenerates the call, the new attempt gets a new UUID, and the server has no way to recognize it as a retry. Shopify calls this out as the most common mistake. Use a UUID only when you can guarantee it is minted once per business operation and carried through every retry.

What should happen if the same key arrives with different data?

Reject it. The server has stored a fingerprint of the original request, so a mismatch means either a derivation bug or a genuine second operation reusing a key. Returning an error is safer than replaying the first response to a request that asked for something else. Zuplo's guide describes the same rule.

Does hashing lose the ability to debug?

No, if you store the canonical string alongside the digest, or log it. The key is what the server compares; the canonical string is what a human reads. Keep the string in the decision log entry so an incident review can reconstruct exactly which identifiers produced the key (Osmani).

How long should keys be retained?

Long enough to outlast your retry window, and no longer. Zuplo suggests 24 hours to 7 days; Shopify uses 24 hours. Set the expiry from your actual retry and replay policy, and make the expiry a column so you can adjust it without a migration.

Can one agent derivation serve both API calls and event consumers?

Yes, if both sides read the same business identifiers. The consumer should derive its own key from the event payload's business fields rather than trusting a field the producer wrote, so a redelivered message is caught even if the producer changed. The event idempotency guide treats client-provided and content-derived keys as complementary strategies for exactly this reason.

Sources

Sources

  1. Implementing Idempotency Keys in REST APIs
  2. Ch. 30: Structured Outputs & Function Calling
  3. What is idempotency? And why it matters for durable systems
  4. Implementing idempotency
  5. How to Create Event Idempotency Keys
  6. Automated Decision Logs in AI-Assisted Coding
  7. Log analysis is necessary for credible evaluation of AI agents
  8. Decision Logs
  9. How to test multi-agent systems - Articles - Braintrust
  10. Top 7 Idempotency Key Designs at Planet Scale
  11. Idempotency - What is an Idempotent REST API?
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles