Specification
Every implementation of idempotency-keys follows one specification, which covers the hashes, what goes in the store, what any store must guarantee, and the notification that wakes a waiting request. Because of it, servers in different languages on the same store and notifier serve one set of keys, as if they were one server. This page summarizes it for readers, and the specification itself is the reference for writing an implementation.
The specification is at version 1.0. Its version is major.minor, independent of the packages' versions: a major version changes something on the wire, in a store or in a hash, so servers on different majors can't share keys, and a minor version adds something older implementations can ignore. Each implementation exports the version it implements (SPEC_VERSION in TypeScript, SpecVersion in Go).
On the wire
| Status | When |
|---|---|
400 | The Idempotency-Key header can't be read, or the server requires a key for the operation and the request has none. |
409 | A request with the key is still running, and waiting for it ran out. |
422 | The key was used on a different request. |
These are the status codes of the IETF draft The Idempotency-Key HTTP Header Field (draft 07). A replay has the first response's status, headers and body, plus Idempotent-Replayed: true. A request without a key runs as it would without the library.
A key is 1 to 255 characters from 0x20 to 0x7E. A quoted value is a Structured Field String, and the key is its content. Any other value is the key as it is.
Hashes
There are two SHA-256 hashes, each written out as 64 lower-case hex characters:
- The identity of
(scope, operation, key)says which record a request claims. - The fingerprint of the method, the request target, the headers the server names and the body says whether a reused key is on the same request.
Each is a sequence of length-prefixed fields, so no two inputs hash the same by running fields together, and an absent operation never equals an empty one. The target is the path and query as a WHATWG URL serializes them (as fetch's Request.url holds them), so a Go server, which gets the raw target, hashes /a/../notes and /notes as one request, as a TypeScript server does.
What is stored
Each identity has at most one record: running (claimed by one request, with a random token and a lease) or completed (with a response). A request claims it, compares fingerprints, then waits or replays. The request that ran ends its attempt by completing (storing the response) or releasing (deleting the record), and either only applies while the record still holds its token.
In Postgres, the record is a row of one table:
CREATE TABLE idempotency_keys (
identity bytea PRIMARY KEY,
fingerprint bytea NOT NULL,
token text,
lease_expires_at timestamptz,
expires_at timestamptz NOT NULL,
status smallint,
headers jsonb,
body bytea
);In Valkey, it is a hash at <prefix>:<identity>, with fields fingerprint, token and lease while running, and status, headers and body once completed, written by Lua scripts every implementation sends byte for byte.
A response's headers are [name, value] pairs, names in lower case, without the headers a server recomputes for every response (content-length, transfer-encoding, connection, keep-alive). Both layouts store them as a JSON array. A response a replay couldn't send (a status outside 200 to 599, or a body on a 204, 205 or 304) is never stored: ending an attempt with one is an error, and the attempt still holds the key.
Every lease and expiry is measured on one clock all servers agree on. Postgres uses clock_timestamp() and Valkey TIME, so servers whose clocks drift still agree. A store without a clock of its own may use the servers' clocks, and then leaseMs has to cover the slowest handler plus the worst drift between servers.
Any store
A server can bring its own store, as long as it keeps the store contract: each operation is atomic for its identity, reads see every completed write, a record past its ttlMs counts as absent, times come from one clock, a failure is an error rather than an answer, and a store that can lose records says so. Writing a store walks through one. Servers in different languages share keys through such a store only if each implements its layout.
Notifications
After every successful complete or release, a server publishes the identity on one channel (idempotency_keys by default), with PUBLISH in Valkey or pg_notify in Postgres. Servers listening wake their requests waiting on that identity, which claim again from the store. Waiting requests poll regardless, so the store stays the source of truth and a lost notification only slows them down.
Interop
The test vectors pin every hash and every scenario, and each implementation passes them against every store it ships. The interop e2e checks the two together, end to end. It runs a TypeScript server (on handle()) and a Go server (on Keys.Middleware) as child processes on one store and notifier, and sends every request across from one to the other, in both directions.
| Store | Notifiers |
|---|---|
| Postgres | Postgres, Valkey, none |
| Valkey | Valkey, none |
On each, it checks that one server replays the other's response without running the handler; that a duplicate sent to the other server waits and gets the same response, woken by a notification when there is a notifier; that it gets a 409 when its wait runs out; that the key on another body is a 422; that once one server is killed mid-request and its lease runs out, the other runs the request; that a 429 on one frees the key for the other; and that a request target sent raw to one server and normalized to the other is one request.
To run it, with the Postgres and Valkey sidecars of the devcontainer and Go installed:
nx run @leyman/e2e-idempotency-keys-interop:test