What it means
An idempotency key is a unique string the client attaches to a write request. If the client never hears back and sends the request again with the same key, the server recognises the key, skips the work, and replays the response it produced the first time. One logical operation, one effect, no matter how many times the bytes travel.
The problem it solves is narrower and nastier than "networks are unreliable". When a request times out, the client learns exactly one thing: it did not get a response. It cannot distinguish between three very different worlds. The request never arrived. The request arrived, was fully applied, and the response was lost on the way back. The request arrived and was rejected. Two of those three want a retry; one of them wants silence. Without a key, the client has to guess, and the usual guess (retry) is the one that double-charges the card.
HTTP already gives you this property for most methods. RFC 9110 section 9.2.2 defines GET, HEAD, PUT, DELETE, OPTIONS and TRACE as idempotent: sending the request twice has the same intended effect on the server as sending it once. POST is deliberately excluded, because POST means "process this payload however you see fit", and creating a resource twice creates two resources. The idempotency key is the industry's retrofit: a header that lets a POST behave like a PUT without the client having to invent the resource identifier up front.
The header and the value
The header name is Idempotency-Key. It is specified in the IETF HTTP API working group draft "The Idempotency-Key HTTP Header Field", which standardises what Stripe, Adyen, PayPal and a long tail of payment and provisioning APIs had already converged on independently. It is a draft, not a published RFC, so treat it as a strong convention rather than a guarantee that any given vendor implements it identically.
The value should be:
- Client-generated. The client is the only party that knows two attempts are the same operation.
- Unique per logical operation, not per HTTP request. Generate it once, before the first attempt, and hold it for the life of the operation including every retry.
- Opaque and unguessable. A UUIDv4 or a ULID is the normal choice. 128 bits of randomness makes accidental collision a non-issue.
- Not derived from the payload. This is the mistake worth spelling out. Two identical charges of 10 dollars a minute apart are two real operations, and a key that is a hash of the body would collapse them into one and quietly lose the second payment.
What the server has to do
The client side is one header. All of the difficulty lives on the server, and the correct algorithm is more than "have I seen this key". Reading in order:
- Compute a fingerprint of the request: method, path, and a hash of the raw body. This is what catches a client reusing a key with a changed payload.
- Attempt an atomic insert of
(principal, key, fingerprint, state = in_progress)into a table with a unique constraint on(principal, key). The uniqueness has to be enforced by the database, not by a read-then-write in application code, because two concurrent retries will both pass the read. - If the insert succeeded, run the work. On completion, store the status code and the response body against the key and set the state to
completed. - If the insert conflicted and the stored state is
in_progress, answer 409 Conflict. The first attempt is still running; a second run would be the exact duplication the key exists to prevent. - If it conflicted, the state is
completed, and the fingerprint matches, replay the stored status and body. No work runs. - If it conflicted and the fingerprint does not match, refuse. The draft's guidance is 422; Stripe answers 400 with an idempotency error type. Either is fine. Silently running the new request is not.
Two details make the difference between a correct implementation and one that looks correct in testing.
Scope the key namespace to the authenticated principal. A key store with a global unique index on the key alone means one tenant can, by collision or by malice, receive another tenant's stored response body. Key the table on (api_key_id, idempotency_key) and the whole class of cross-tenant leak disappears.
Store the response, not a boolean. A store that only records "this key is done" leaves the retrying client with a 200 and no body, which means it still cannot learn the id of the thing it created. Persist the status code and the serialised body. That is the entire point: the retry has to be able to recover the result the first attempt lost.
How long to keep the key
The retention window has to be longer than the longest retry ladder that can reach the endpoint. This sounds obvious and is routinely wrong in production. If your client library backs off for up to 24 hours and your key store expires at six hours, the last three retries in that ladder find no key and re-run the work. The key store silently stops being a safety net exactly when the outage has gone on long enough to need one.
Stripe retains keys for 24 hours. A reasonable rule is to take your ladder's total span, double it, and round up. For a delivery pipeline like the Pinlyx Data API's watch deliveries, which retries across 1m, 5m, 30m, 2h, 6h, 12h and 24h for a total span of 44 hours 36 minutes, a receiver that de-duplicates on the event id should keep those ids for seven days, not for one.
Where the Data API needs one, and where it does not
The Pinlyx Data API is read-mostly: 103 of its 127 endpoints are GET, and RFC 9110 has already made those safe to repeat. Most of the 24 POST endpoints are POST for a boring reason, which is that they need a request body rather than because they create anything: a bulk account check accepts up to 50 handles at once, and the analysis tools take a parameter object. Repeating one of those costs budget and returns the same answer. It cannot corrupt anything.
The endpoints that genuinely create state solve the problem a different way, with natural-key idempotence rather than a header:
POST /data-api/v2/watch/endpointsregisters a webhook destination and mints its signing secret. Registering a URL that is already registered on the account returns 422 rather than reissuing the secret, precisely because reissuing it would break the integration already using the old one. The URL is the natural key.POST /data-api/v2/watchregisters an account to watch. Posting the same account to the same endpoint again updates its thresholds instead of creating a second watch. The pair (account, destination) is the natural key.
That is the design lesson worth taking away. An idempotency key is the general-purpose answer for operations with no natural identity, such as "charge this card". When the operation does have a natural identity, an upsert on that identity is simpler, needs no key store, and cannot expire.
Related concepts
- Exponential backoff: the retry strategy the key makes safe to use.
- Webhook replay: the same problem on the receiving side, keyed on the event id.
- Retry-After: the server's instruction about when the next attempt should happen.
- Webhook: the delivery mechanism whose at-least-once guarantee makes receiver-side idempotence mandatory.
How Pinlyx handles it
Every Data API response carries X-Request-Id, echoed in meta.request_id, which identifies the attempt rather than the operation. Write endpoints are idempotent on their natural keys as described above, and outbound webhook deliveries carry a stable event id so a receiver can de-duplicate a re-delivery against its own store. The pattern is consistent: the server never asks the client to trust that a retry is safe, it makes the retry safe.