Skip to content
Chapter 2 of 7 · Tool design — semanticsDeriving an idempotency key

17 min read

Deriving an idempotency key

On this page

An idempotency key is a value that lets a server recognise a second request as a repeat of the first and commit the effect at most once. The mechanism is simple enough to describe in a sentence, and almost all of the engineering in it is in one question: where does the value come from. Get that wrong and every other part still works — the unique index, the replay path, the tests — while protecting nothing at all.

The reason the question is harder for an agent than for an ordinary API client is that the client has no reliable memory. A model's context may be compacted between attempts, meaning summarised down to fit the context window so that the exact text of earlier turns is gone; the harness may restart; the agent may be resumed on a different process an hour later. Anything the caller was supposed to carry across attempts may not survive, which rules out the two designs that work everywhere else — a value generated per request, and a counter held by the client.

The key has to be a function of what the caller wants, not of when it asked

Recovering the property that matters means asking what stays constant across the attempts you want deduplicated. The transport does not: connection, request id, timestamp and socket all differ. The model's reasoning does not necessarily either, since a retry after a timeout often carries slightly different phrasing in the surrounding text. What does stay constant is the intent — this charge, this amount, this refund — because that is the thing the model is trying to accomplish and the thing it re-emits when it tries again.

So the key is computed, not generated. Take the arguments the model emitted, select a stable subset of them, hash that subset with a version tag and a tool name, and use the result as the key. The same intent produces the same key on every attempt without anything being carried across attempts.

Hashing is the right operation for one specific reason, and it is not compression. A hash is a function: identical input gives identical output, every time, on any machine, with no shared state between the callers. That is exactly the property the mechanism turns on, because the whole thing depends on the original attempt and the retry presenting the same value, and the retry may be computed hours later in a process that never saw the first one. A function of the intent can be recomputed anywhere; a value generated at the moment of sending cannot be recomputed at all. The rest of what a hash gives you — fixed length, opacity, no way to read the charge id back out of the key — is convenient for a column and an index and is not why it is there.

python
import hashlib
import json

KEY_VERSION = "v1"

def refund_idempotency_key(args: dict[str, object]) -> str:
    """Derive a stable key from the intent expressed by the model's arguments.

    Only the fields that define *which refund this is* participate. Anything
    the model may reword between attempts (reason text, notes, locale) is
    excluded, or a retry would hash differently and defeat the mechanism.
    """
    intent = {
        "charge_id": args["charge_id"],
        "amount_minor": int(round(float(args["amount"]) * 100)),
        "currency": str(args["currency"]).upper(),
    }
    canonical = json.dumps(intent, sort_keys=True, separators=(",", ":"))
    digest = hashlib.sha256(f"create_refund:{KEY_VERSION}:{canonical}".encode()).hexdigest()
    return f"rfnd_{digest[:32]}"

The amount_minor conversion is not decoration. A model that emits 129.99 on the first attempt and 129.990 on the retry has expressed the same intent, and a naive hash of the raw string would treat those as two refunds. Normalising every field into a canonical form before hashing — minor units for money, uppercase for currency codes, sorted keys for the JSON — is where most of the practical work in key derivation lives, and each normalisation you skip is a way for a retry to slip past the guard and commit twice.

Following that example through the function shows why the conversion is placed where it is. A hash is deliberately sensitive to every byte it is given, which is the property that makes it useful and also the property that makes it fragile here: 129.99 and 129.990 are different strings, so they hash to unrelated values, and the guard sees two intents where there was one. Parsing each to a number and multiplying by a hundred collapses both onto the same integer, and the rounding is there because binary floating point cannot represent every decimal amount exactly, so the multiplication can land a hair under or over. Upper-casing the currency code does the same job for gbp and GBP, and sorting the keys does it for two JSON objects that hold the same fields in a different order. Each of those is one way a model can reword an identical intent, and the general rule underneath them is that the hash must be taken over a canonical form rather than over whatever arrived — one representative per equivalence class, chosen before anything is hashed.

Who computes it, and why the model must not

The key is computed application-side, in the tool dispatch layer, after the model has produced its arguments and before the request leaves for the payments service. It is never a parameter in the tool schema, and the model is never asked to supply it.

The reason is a general property of language models rather than a quirk of any particular one. A value that appears nowhere in the conversation and has no derivation the model can follow is a value the model has to invent, and an invented value is exactly what changes between two generations. Ask for idempotency_key: string in the schema and you will get plausible-looking tokens — refund-2026-03-10-a, then refund_129_99_retry, then idem_7f3 — that differ across attempts in exactly the situation where they must match. Worse, they sometimes do match by accident across genuinely different requests, because a model asked twice for "an idempotency key for a refund" will occasionally produce the same string, and that collision silently swallows a real second refund. Neither failure mode is visible in testing, because in testing you pass the key yourself.

The reason the schema makes this look safe is worth naming, because it is what gets the field added in the first place. Every payment API that documents idempotency documents it as a header the client supplies, so a parameter called idempotency_key reads as the standard integration, and a required field in a schema reads as a field that will be filled correctly. Both readings hold for a client that is a program. They fail for a caller that produces its arguments by generation, because a required field is filled by producing tokens for it, and producing tokens for a value with no derivation available is inventing. The schema can force the field to be present and cannot force it to be the same value twice, and being present is not the property the mechanism needs.

Where the key comes fromSurvives a timeout retryDistinguishes two genuinely different requestsFails how
uuid4() per HTTP attemptNo — every attempt differsYesSilently permits duplicates; looks correct in unit tests that pass the key in twice
Model supplies it as a tool argumentSometimes, depending on the generationSometimes, and collides at randomBoth directions at once, and only under load or after context compaction
Hash of a chosen subset of the argumentsYes, deterministicallyOnly if the chosen subset differsBlocks legitimate identical repeats — a real cost, discussed below
Client-side counter or sequence numberOnly if the client persists it across restarts and process movesYesRequires durable state in the agent harness, which is the thing that most often gets restarted

The last row is worth noting because it is how idempotency was originally achieved in systems with a well-behaved client. Sequence numbers in TCP, message ids in AMQP, the Advanced Message Queuing Protocol, the request identifiers Stripe introduced for card charges in 2014 — all of them assume a client that owns durable state and can present the same identifier on every retransmission. An agent harness is a poor client by that standard. Deriving the key from the payload rather than from client memory is the adaptation that survives a caller with no reliable memory.

Recording it inside the transaction that commits the effect

Having the same key on both attempts is half of it. The other half is that the key and the side effect commit together, so that no interleaving of two concurrent attempts can produce a refund without a key or a key without a refund.

sql
BEGIN;

INSERT INTO refunds (id, charge_id, amount_minor, currency, status, created_at)
VALUES ('rf_01HZ', 'ch_4471', 12999, 'GBP', 'succeeded', now())
RETURNING id;

-- Same transaction. The unique index on idempotency_key is what makes the
-- second attempt lose deterministically rather than racing.
INSERT INTO refund_idempotency (idempotency_key, refund_id, request_fingerprint, created_at)
VALUES ('rfnd_9a3c...e21', 'rf_01HZ', 'sha256:41c0...', now());

COMMIT;

Writing the key in a second transaction after the refund commits reopens the whole hole in a narrower window: a crash between the two commits leaves a refund that no key points at, and the retry creates another one. The window is small enough that it will not show up in testing and large enough that it will show up eventually, which is the worst combination of properties a race can have.

The read path then has three outcomes rather than two, and the third is the one people forget:

Sequence: Model → Tool dispatch (create_refund(charge_id, 129.99, GBP)); Tool dispatch → Tool dispatch (key = sha256(intent)); Tool dispatch → Refund service (POST /refunds Idempotency-Key: rfnd_9a3c); Refund service → Refund service (commit refund + key (28.4s)); Refund service → Tool dispatch (response lost, client times out at 30s); Tool dispatch → Model ({"error":"timeout","retryable":true}); Model → Tool dispatch (create_refund(charge_id, 129.99, GBP)); Tool dispatch → Tool dispatch (key = sha256(intent) — same value); Tool dispatch → Refund service (POST /refunds Idempotency-Key: rfnd_9a3c); Refund service → Refund service (key found, refund rf_01HZ already committed); Refund service → Tool dispatch (200 {refund_id: rf_01HZ, idempotent_replay: true}); Tool dispatch → Model (original result, no second refund).

The third outcome is a key that exists but whose refund has not committed yet, which happens when two attempts overlap — a retry fired by an impatient timeout while the first request is still executing. Returning "already done" there is a lie, and creating a second refund defeats the point, so the server takes the row lock, finds the in-flight marker and returns 409 with retryable: true and a short retry_after. That is a transient failure in the sense the grid drew, and it is the one case in this design where telling the agent to try again is the correct answer.

The reason neither of the other two answers works there is the same reason the key exists at all. Replying "already done" would be a statement about a refund that has not committed and may yet roll back, so the model would report a refund to the customer that the ledger might never contain. Creating a second refund defeats the mechanism outright, since two attempts of one intent are exactly what the key exists to collapse. What is left is to say that the answer is not yet knowable and that it will be shortly, which is true, and to make it actionable by attaching a delay. The lock matters here too: the second attempt takes the row lock on the key before deciding, so it either waits for the first transaction to finish and then sees a committed refund, or finds the in-flight marker and declines. Without the lock the two attempts read the same absence at the same moment and both proceed, which is the race the unique index was put there to lose deterministically.

The same key arriving with a different body is not a retry

The request_fingerprint column in that insert is doing a job the key itself cannot do. Because the key hashes a chosen subset, two requests that differ only in an excluded field — the refund reason, an internal note, the customer-facing description — arrive carrying the same key and different payloads. Storing a digest of the full normalised body alongside the key separates the retry the mechanism was built for from a second request that merely collides with it, at the cost of one string comparison against a row the server has already fetched. When the digests disagree, replaying the original result is a lie in a direction that is hard to detect afterwards, because the model reports to the user that a refund was issued for the reason it supplied while the refund on the books carries someone else's.

json
{
  "error": "idempotency_key_conflict",
  "message": "This request matches refund rf_01HZ on charge_id, amount and currency but differs in: reason, metadata.order_ref. Read rf_01HZ with get_refund, or issue a distinct refund by supplying line_item_id.",
  "existing_refund_id": "rf_01HZ",
  "differing_fields": ["reason", "metadata.order_ref"],
  "retryable": false
}

Payment processors that expose keys as public API surface behave this way, returning a 4xx for a reused key whose parameters have changed rather than quietly serving the earlier response, and the reasoning transfers directly to a tool whose caller is a model. Naming the differing fields is what makes the response usable, since the model can weigh them against what it believed it sent and decide whether this is its own retry with reworded prose or a new request needing a distinguishing argument.

How long a key has to outlive the call

The retention window on refund_idempotency is set by the longest interval over which a retry can plausibly arrive, and that interval is not the 30-second client timeout. An agent run suspended for human approval and resumed the following morning re-emits its pending tool call some 14 hours after the original attempt; a harness that replays a failed run from a checkpoint can do the same thing days later. The storage argument does not push back at all, since a row of key, refund id, fingerprint and timestamp is about 120 bytes with its index, so a ledger handling 1.4 million refunds a month holds 30 days of keys in roughly 170 MB. Choosing 24 hours because a well-known API documents 24 hours, when your own harness resumes runs up to a week later, is choosing to allow a duplicate refund in exactly the case the mechanism exists for.

That storage figure is one multiplication and worth performing rather than accepting, because it is what ends the argument. 1.4 million rows at about 120 bytes each is something under a hundred and seventy megabytes, and 30 days is one month's worth of refunds, so the window and the volume line up with no further arithmetic required. Set that against the cost of a single duplicate refund — a customer paid twice, a reconciliation, an afternoon of somebody's finance team — and storage carries no weight in the decision at all. When one side of a trade is orders of magnitude cheaper than the other, the honest move is to stop treating it as a trade and let the retry horizon set the window on its own.

The expiry job deletes key rows and never touches refunds, which sounds obvious and is worth stating because the two live in tables that a naive cascade would link. What the window cannot give you is a signal when it has been set too short: a retry that arrives after expiry hashes to the same key, finds nothing, and creates a second refund that looks entirely ordinary in every log. The observable proxy is the age distribution of the replays you do catch, which the server can record as the gap between the original commit and the repeat. If that distribution has a p99 of 40 seconds — 99 replays in every 100 arriving inside that gap — against a 24-hour window you have three orders of magnitude of margin, and if replays are clustering within a factor of two of the boundary the window is already too tight.

The margin is a division rather than an impression: a 24-hour window is somewhere over eighty thousand seconds, and eighty thousand over 40 is a factor in the low thousands, which is the three orders of magnitude. The reason to express it that way rather than as "a day is much longer than a minute" is that it makes the warning sign quantitative. A margin of thousands is comfortable. A margin of two is not, because the observed p99 is a description of the retries you have seen and the next resumed run is drawn from a distribution whose far tail you have not seen. And the asymmetry decides which way to err: a window set too long costs rows in a table that has already been shown to be cheap, while a window set too short costs a duplicate refund that looks ordinary in every log it touches.

The KEY_VERSION tag carries the same problem on a shorter timescale, because changing the hashed subset changes every key the dispatcher computes, and a deploy landing between an original attempt and its retry then produces two keys for one intent. The rollout that avoids it computes both derivations for the length of the retention window, sending the new key and passing the old one as a secondary lookup the server checks on a miss.

The subset you choose is a policy decision about legitimate repeats

Hashing charge_id, amount and currency means two refunds of £129.99 against charge ch_4471 can never both exist. Most of the time that is exactly right and is the whole reason the mechanism works. Occasionally it is wrong: a customer disputes two separate line items of identical value on the same charge, an operator refunds one, and the agent's second refund comes back as a replay of the first with idempotent_replay: true and a success status. The books are now short by £129.99 and every log line says the call succeeded.

There is no derivation that avoids this cleanly, because the property you want — "the same request twice means once, two different requests mean twice" — depends on information that is not in the arguments. What you can do is decide where the ambiguity sits and make it explicit. Adding a coarse time bucket to the hashed subset, such as the current five-minute window, means a genuine second identical refund succeeds six minutes later while a retry inside the timeout window is still caught; the cost is that a retry which straddles a bucket boundary slips through, so the window has to be comfortably longer than your worst-case retry horizon. Adding a caller-supplied line-item id, where the domain has one, removes the ambiguity outright and is the better answer whenever the domain supplies such a field. Leaving the subset narrow and documenting that identical refunds require an operator is a legitimate choice too, as long as the response says so rather than reporting a replay as a fresh success.

The bucket's own failure is worth seeing directly, because it is what makes that option a trade rather than a fix. A bucket divides the clock into fixed intervals, and two attempts fall in the same bucket only when no boundary passes between them. An attempt made shortly before a boundary and its retry made shortly after hash to different keys, so the guard sees two intents and commits twice. The chance of that happening is roughly the retry gap divided by the bucket length, which is why lengthening the bucket shrinks the hole — and lengthening it far enough reinstates the very problem the bucket was added to solve, since a bucket longer than the interval between two legitimate identical refunds blocks the second one again.

This is why key derivation is a design review item and not a utility function someone drops in. The subset defines an equivalence class over requests, and everything inside that class is one intent by fiat. Choosing it in a hurry means choosing which real business operations your system will silently refuse.

A key that survives a retry is one the dispatcher can recompute from the arguments alone, on a machine that may never have seen the first attempt, and that the server stores in the same transaction as the effect it guards. That combination is what makes a repeat harmless: the same intent hashes the same way however many times it is emitted, the unique index makes the second writer lose deterministically, and the fingerprint keeps a genuinely different request from being served someone else's result.

None of this makes the tool safe in general. It makes repeats harmless, which is one corner of the grid and nothing else. A refund rejected because the charge is already fully refunded, or because the amount exceeds what remains, is deterministic and will fail identically on every attempt no matter how carefully the key is derived — and the response that comes back from those cases is still {"error": "Refund failed"}, which leaves the model to guess its way forward.

Tool design — semantics — read the current version at https://daviddominguez.dev/learn/en/tool-design-semantics/deriving-an-idempotency-key

Back to top