# Idempotency-Key Header: Safe POST Retries

> Idempotency-Key lets clients retry POST and PATCH without duplicating work. The IETF draft, Stripe behavior, and how to design the server side.

Source: https://howhttpworks.com/headers/idempotency-key
Last reviewed: 2026-10-04

> **TL;DR:** The client generates a unique `Idempotency-Key` per logical operation and resends the same value on every retry. The server stores the key with a fingerprint of the request and the response, and replays that response instead of repeating the side effect. It is an IETF draft, not an RFC.

## The problem it solves

A client sends `POST /payments`, the connection drops after the server committed the charge, and the client never sees a response. Retrying a POST is unsafe because POST is not idempotent (RFC 9110 section 9.2.2). `PUT` and `DELETE` are, so retrying them is fine. `Idempotency-Key` gives POST and PATCH the same safety.

```http
POST /v1/payments HTTP/1.1
Host: api.example.com
Content-Type: application/json
Idempotency-Key: "8e03978e-40d5-43e8-bc93-6894a57f9324"

{"amount": 4200, "currency": "usd", "customer": "cus_123"}
```

The draft defines the field as an Item Structured Header whose value must be a String (RFC 8941 section 3.3.3), which is why its examples show the value in double quotes. Stripe's docs show it unquoted (`Idempotency-Key: KG5LxwFBepaKHyUD`). If you build a server, decide which forms you accept and document it. The draft recommends a UUID or similar random identifier.

## Status of the specification

The current document is `draft-ietf-httpapi-idempotency-key-header`, revision 07 (October 2025, authors J. Jena and S. Dalal), a Standards Track draft of the HTTPAPI working group. The datatracker marks it expired as of April 2026. Cite it as work in progress and do not depend on details such as quoting rules staying fixed.

## Status codes the draft recommends

- Key required but missing: `400 Bad Request`.
- Same key, different request payload: `422` (Unprocessable Content).
- Retry arrives while the original is still running: `409 Conflict`.

Real APIs vary. Stripe documents that its idempotency layer errors when parameters differ from the original request but does not tie that to 422, so follow your own documentation and pick one convention across all endpoints.

## Server design

Store one record per (account, key):

```sql
CREATE TABLE idempotency_keys (
  account_id    bigint      NOT NULL,
  key           text        NOT NULL,
  fingerprint   bytea       NOT NULL,  -- hash of method, path, body
  status        text        NOT NULL,  -- 'in_progress' or 'completed'
  response_code int,
  response_body bytea,
  created_at    timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (account_id, key)
);
```

The handling sequence:

1. Insert the row as `in_progress`, letting the primary key do the locking. If the insert conflicts, the key was seen before.
2. Seen before and `in_progress`: return `409`, ideally with `Retry-After`.
3. Seen before and the fingerprint differs: return `422`. Never run the new payload under an old key.
4. Seen before, `completed`, fingerprint matches: return the stored status and body without executing anything.
5. New key: run the operation, then write the response and mark `completed`, in the same transaction as the side effect when you can.

Scope keys per authenticated account, otherwise one tenant can collide with or probe another's keys. Fingerprint the method, path and canonicalised body, not volatile headers. Expire rows on a documented schedule; the draft says the server should publish its expiry policy. Stripe's is 24 hours.

A crash between executing the side effect and recording the response leaves an `in_progress` row forever. Give those rows a lease (a `locked_until` timestamp) so a later retry can take over, and make the underlying operation itself safe to resume, for example by passing the same key to the downstream payment provider.

## Client rules

- Generate the key once per logical operation, before the first attempt, and persist it so a crash and restart reuses it.
- Reuse the key only for byte-identical retries. A changed amount needs a new key.
- Do not put emails or other personal data in the key; Stripe gives the same advice.

## Verify

```bash
KEY=$(uuidgen)
curl -si https://api.example.com/v1/payments -H "Idempotency-Key: $KEY" -d '{"amount":4200}'
curl -si https://api.example.com/v1/payments -H "Idempotency-Key: $KEY" -d '{"amount":4200}'   # replayed
curl -si https://api.example.com/v1/payments -H "Idempotency-Key: $KEY" -d '{"amount":9900}'   # mismatch
```

## Related

- [Idempotent (glossary)](https://howhttpworks.com/glossary/idempotent), [POST](https://howhttpworks.com/methods/post), [PATCH](https://howhttpworks.com/methods/patch)
- [409 Conflict](https://howhttpworks.com/status-codes/409), [422 Unprocessable Content](https://howhttpworks.com/status-codes/422), [400 Bad Request](https://howhttpworks.com/status-codes/400)
