An idempotency key names an intended action, not a transmission attempt. When a service supports it, the client sends the same key when retrying that action after an uncertain result. Generating a new key for every retry tells the server something different: this is another action, which may legitimately produce another business effect.

Create once, retain across attempts

Generate the key before the first attempt and keep it with the operation’s parameters and target. If a job can resume after a client restart, its operation record needs to retain that identity too. An identifier held only in a temporary network connection cannot reliably connect later attempts to the original intent.

Use a new key for a genuinely new action, even when the parameters happen to match a previous purchase. A body hash alone cannot distinguish “buy the same item again” from “retry the first purchase.” Keys and request fingerprints have different jobs: one identifies the action, while the other detects incompatible reuse.

Follow the provider’s format and scope. Stripe suggests random identifiers such as UUIDv4 and warns against sensitive data in keys. A key is not authentication, and possession of it must not replace authorization to access a stored result. Do not put bearer credentials or personal details into an identifier intended for correlation.

The receiver must implement the promise

Merely adding an Idempotency-Key request header does not implement deduplication. The service must recognize the identifier under its documented contract. That contract should explain which parameters are compared, which results are retained, what happens while execution is in progress, and how long records remain available.

Stripe documents saving the first executed request’s status and body, including a 500 response. It does not save results for validation failures or conflicts with concurrently executing requests when endpoint execution has not begun. Therefore, “every duplicate waits for the first result” is not a universal rule.

Nor does every provider replay identical response bytes. EC2 documents that a successful retry avoids additional action while its result can contain updated information, such as creation status. RFC 9110’s definition concerns intended effect; a provider’s result-replay policy is an additional contract.

Keep meaning stable

Reusing one key with incompatible parameters should follow the service’s explicit error behavior, not silently create a different operation or overwrite the saved result. JSON field ordering, defaults, numerical representation, and omitted fields complicate comparisons. Do not claim that arbitrary serialized bodies are semantically equivalent merely because one small example canonicalizes them.

The example accepts exactly a product string and a positive integer quantity. Sorting these two fields defines its comparison rule; it is not a general JSON equivalence algorithm. Its 409 conflict is a fixture policy, not a status guaranteed by every idempotent API.

When it breaks

A timeout does not prove that execution failed. Retrying with a fresh key can turn a lost successful reply into a duplicate purchase. Similarly, switching keys to escape a remembered error can create new work while the original outcome remains uncertain. Resolve the original action using the provider’s documented recovery path.

Retention also bounds the promise. Stripe permits pruning keys after they are at least twenty-four hours old and treats reuse after pruning as a new request. Do not read that as permanent identity storage or an exact universal expiration timer. Align client recovery windows with the actual service contract.

Check it quickly

Save the block as intent_check.py and run python3 intent_check.py with Python 3.10 or newer. It ran with Python 3.14.4 and requires only the standard library. It writes one intent record inside a temporary directory, reads it back, and removes the directory on exit. The API and response loss are explicit sequential simulations, not network requests.

import json
from pathlib import Path
from tempfile import TemporaryDirectory
from uuid import uuid4


def new_intent(parameters):
    return {"key": str(uuid4()), "parameters": dict(parameters)}


class FixtureAPI:
    # One already authenticated scope, sequential execution, no network service.
    def __init__(self):
        self.saved = {}
        self.orders = []
        self.attempts = []

    def execute(self, intent):
        key, parameters = intent["key"], intent["parameters"]
        self.attempts.append(key)
        if (set(parameters) != {"product", "quantity"}
                or not isinstance(parameters["product"], str) or not parameters["product"]
                or type(parameters["quantity"]) is not int or parameters["quantity"] < 1):
            return 400, '{"error":"invalid parameters"}'
        fingerprint = json.dumps(parameters, sort_keys=True, separators=(",", ":"))
        if key in self.saved:
            original, response = self.saved[key]
            return response if original == fingerprint else (409, '{"error":"key reused with other parameters"}')
        self.orders.append(dict(parameters))
        response = (201, json.dumps({"order_id": len(self.orders)}, separators=(",", ":")))
        self.saved[key] = (fingerprint, response)
        return response


def deliver(api, intent, lose_response=False):
    response = api.execute(intent)
    if lose_response:
        raise TimeoutError("simulated response loss after the fixture saved its result")
    return response


api = FixtureAPI()
parameters = {"product": "notebook", "quantity": 2}
intent = new_intent(parameters)

with TemporaryDirectory(prefix="idempotency-intent-") as directory:
    record = Path(directory) / "intent.json"
    with record.open("x", encoding="utf-8") as stream:
        json.dump(intent, stream)
    try:
        deliver(api, intent, lose_response=True)
    except TimeoutError:
        pass
    else:
        raise AssertionError("expected simulated response loss")
    assert len(api.orders) == 1
    with record.open(encoding="utf-8") as stream:
        recovered = json.load(stream)
    assert recovered == intent
    first_known_result = deliver(api, recovered)
    assert first_known_result == (201, '{"order_id":1}')
    assert deliver(api, recovered) == first_known_result
    assert len(api.orders) == 1 and api.attempts[:3] == [intent["key"]] * 3
    print("one persisted intent survives serialization and three attempts produce one order")

    reordered = {"key": intent["key"], "parameters": {"quantity": 2, "product": "notebook"}}
    assert deliver(api, reordered) == first_known_result
    changed = {"key": intent["key"], "parameters": {**parameters, "quantity": 3}}
    assert deliver(api, changed)[0] == 409 and len(api.orders) == 1
    assert deliver(api, recovered) == first_known_result
    print("equivalent field ordering replays; changed parameters conflict without replacing the saved result")

    mistaken_retry = new_intent(parameters)
    assert mistaken_retry["key"] != intent["key"]
    assert deliver(api, mistaken_retry) == (201, '{"order_id":2}')
    assert len(api.orders) == 2
    print("generating a new key for an uncertain retry creates a second order")

    invalid = new_intent({"product": "notebook", "quantity": 0})
    assert deliver(api, invalid)[0] == 400
    assert invalid["key"] not in api.saved and len(api.orders) == 2
    corrected = {**invalid, "parameters": {"product": "notebook", "quantity": 1}}
    assert deliver(api, corrected) == (201, '{"order_id":3}')
    assert len(api.orders) == 3
    print("this fixture does not save pre-execution validation failures; the corrected request can execute")

The fixture saves the first result before simulating a timeout. Reading the saved intent and reusing its key gives three attempts but one order. Reordered fields replay the same result; changed quantity conflicts without replacing it. A fresh key for the same uncertain operation creates order two.

A validation failure is not retained in this fixture, so correcting its invalid quantity can execute with that still-unused key. That illustrates this stated policy, not permission to change an already executed request.

The file round trip verifies identity preservation through serialization. It does not test an actual process restart, crash-safe client storage, durable server receipts, concurrent execution, real transport loss, or provider APIs. Those boundaries must be validated before treating an application deployment as retry-safe.

Further reading

Sources

  • Stripe: Idempotent requests, stable keys, result retention, parameter checks, and pre-execution failures, checked 2026-09-08.
  • EC2 API idempotency, client-token semantics and results that can reflect updated status, checked 2026-09-08.
  • RFC 9110, section 9.2.2 defines intended effects and conditions for nonidempotent-method retries, checked 2026-09-08.