Quick answer: An idempotent endpoint returns the same result and creates the same single resource no matter how many times the same request is sent, and you cannot prove that with one successful POST. The test must send the identical request twice with the same idempotency key, then assert that only one resource exists and both responses point to it. The happy-path call proves the endpoint works. The duplicate call proves it is safe to retry.
Why idempotency needs its own test
Network failures do not tell you whether the server handled the request. A client can time out after the server has already created the resource, then retry and create a second one. The point of an idempotency key is to make that retry collapse onto the original work.
That behavior is invisible in a single-request test. A route that ignores the key and creates a new row every time will pass a normal happy-path test with a 201. The defect only shows up when the exact same request is replayed.
The assertions that actually prove it
A real idempotency test checks the resource count and the response identity, not just the status code on the second call.
| Assertion | Expected result | Failure it catches |
|---|---|---|
| First POST returns a resource id | 200 or 201 with an id | Endpoint not creating at all |
| Second POST, same key, same body | Same resource id, no new row | Duplicate creation on retry |
| Resource table count | One record, not two | Key being ignored silently |
| Response shape across calls | Equivalent state | Two divergent representations |
| Different key, same body | A second resource | Keys collapsing unrelated requests |
The last row is easy to overlook. The key must protect retries of one logical request without merging two genuinely distinct requests that happen to carry the same body.
How to structure the test
Keep the two calls genuinely identical. Reusing the same key is the whole point, so build the request once and send it twice rather than reconstructing it, which can introduce a subtle difference in serialization.
- Send the POST with a unique idempotency key and capture the response, including the resource id.
- Without waiting for any cleanup, send the same payload again using the exact same key.
- Assert the second response references the same id and did not return a newly created one.
- Query the collection or database to confirm a single resource exists.
- Send a third POST with the same body but a fresh key and assert a separate resource is created, proving the server distinguishes requests by key.
State from earlier tests is the main source of false results here. Run against an isolated database or clean the target resource before the test, so a count assertion is not counting records left by another run. Replaying the exact stored request through a tool such as Powerduck also removes the risk that the second call is serialized slightly differently from the first.
The edge cases that hide defects
The basic duplicate test is the floor. Production failures cluster around the less obvious replays.
- Concurrent duplicates. Fire the same key twice at the same time instead of in sequence. A correct implementation holds a lock or unique constraint so both calls resolve to one row; a naive check-then-insert can race and create two.
- Replayed after a delay. Send the second call well after the first, near the key's storage time limit. Keys that expire too quickly start treating retries as new requests.
- Same key, different body. A changed payload with a reused key is a client error, not a silent overwrite. Assert the server refuses it rather than picking one body arbitrarily.
- In-flight retry. Replay while the first request is still processing. The server should make the second call wait for the first result rather than starting parallel work.
Frequently asked questions
What status should the second POST return? It varies by design. Some APIs return the original 201, others return 200 with the stored result. The important assertion is that it returns the same resource and creates no duplicate, not the exact status code.
Do GET and PUT need idempotency keys? No. GET, PUT, and DELETE are idempotent by HTTP semantics when implemented correctly. Keys mainly protect POST, where a retry is otherwise ambiguous.
Where should the idempotency key be stored? The server persists it alongside the request result, with a unique constraint so concurrent duplicates cannot both insert. The client only generates and resends the key; it does not track state.
How long should a key remain valid? Long enough to cover the realistic retry window, commonly many hours. Document the limit, because a key that expires before a delayed client retries defeats the purpose.
The bottom line
A single 201 tells you nothing about idempotency, because the entire mechanism exists for the request you cannot see clearly, the timed-out call that the client retries. Build the test around the duplicate, not the original: same key, same body, one resource, and a fresh key proving distinct requests still work. Add a concurrent duplicate to catch the check-then-insert race that sequential tests miss. If you only assert the happy path, the first network blip in production will quietly double-charge or double-create, and no unit test will have warned you. The broader method lives in the API testing guide.

