Idempotency keys are minted on the client
Aliases: Idempotency-Key · request fingerprint · client nonce
What it is
To fold two arrivals into one intent, both sides need a name that already exists. That name is an idempotency key. It is generated on the client, before the first send, and every retry carries the same one. The server looks the key up: done, return the first result; not done, do it and remember. If the server mints the key, the first reply can drop before the client ever sees it, and the second send can only open a new ticket.
Client generation is not an implementation detail. It is the condition under which the uncertainty window can close. UI anti-duplicate guards the entry; the key is the identity of the intent.
Why it happens
The network offers “best effort”, not “exactly once”. Exactly once is a protocol: the sender names the intent, the receiver dedupes on the name. The name must exist before entering the uncertainty window — the beat of the press, written into a local queue. After that, process-kill, a radio change, a retry are redeliveries of the same name. The server table is “name → result”: hit and short-circuit, miss and execute.
A key issued in the server’s response is named at the far end of the window. If that first response drops, the client is nameless and must ask for another name; two names now map to one intent, and dedupe fails. A key taken from a content hash (amount + account + timestamp-to-the-minute) will smash two legitimate identical transfers into one, or treat a retry that changed the amount as new. So the key is a client random or monotonic identity bound to this user act, not to this tuple of field values.
Where it stops holding
Read-only GET can usually key on the URL. True once-only intents (pay, create an order, send mail, create a resource) need a client key. Server-side “same user, same body, five seconds” will injure two deliberate identical messages and miss a retry that lands after five seconds. Key lifetime must outlast the window in which the user may still retry (hours to a day). Too short and an overnight retry becomes a new ticket; too long and storage grows — an operations problem, not solved by shrinking lifetime until retries become two charges. Two devices tapping the same business at once may be two intents or one: two phones each “pay” should be two keys; one payment page restored into two webviews should be one — the key follows the intent’s vessel (that page, that local queue item), not the process.
Applying it
- Mint the key on the press, write it locally, then send. Retry, refresh, process restore all read this key; they do not mint another.
- The key arrives in a header or body. The server unique-constrains on it; a duplicate arrival returns the first result rather than executing again.
- Do not key on “the fields look the same”. A retry that changed the amount is a new intent and wants a new key; a timeout retry of the same attempt is the old intent and must be the old key.
- How to check: capture the key on a payment request. Drop the response and let the client retry, automatically or by hand. The second request must carry the same key; the server must land one ledger row. Then wipe the local key to simulate “wrongly minting again”: two ledger rows should appear — proof that once minting is pushed to the server or to retry time, the protection is gone.