How to test API idempotency
Send the identical write twice and count what exists afterwards: curl -X POST http://localhost:8789/v1/orders -H 'Idempotency-Key: 2f8b-order-77' -d '{"sku":"A-1","qty":1}' twice, then curl http://localhost:8789/v1/orders. The second call must answer with the record the first one created, and the count must not move.
Why check this
Clients retry. A mobile app on a dropped connection, a queue worker after a timeout and a load balancer that fails over all resend the same write, and none of them knows whether the first attempt reached the database. Run this on every endpoint that creates or charges something, before release and after any change to the retry policy. The failure it prevents is the customer charged twice for one click.
Prerequisites
- curl 7.0 or later. See the curl manual.
- Node 22 for the target below. Save it as
orders-api.jsand start it withnode orders-api.js. It stores one response per idempotency key and replays it:
const http = require('node:http');
const orders = new Map();
const keys = new Map();
let next = 1;
http.createServer((req, res) => {
const send = (code, body, extra = {}) => {
res.writeHead(code, { 'content-type': 'application/json', ...extra });
res.end(JSON.stringify(body));
};
const id = /^\/v1\/orders\/(\d+)$/.exec(req.url);
if (req.method === 'DELETE' && id) {
if (!orders.delete(Number(id[1]))) return send(404, { title: 'Order not found', status: 404 });
res.writeHead(204).end();
return;
}
if (req.method === 'GET') return send(200, { count: orders.size, ids: [...orders.keys()] });
let raw = '';
req.on('data', (c) => { raw += c; });
req.on('end', () => {
const key = req.headers['idempotency-key'];
if (key && keys.has(key)) {
const prev = keys.get(key);
if (prev.raw !== raw) return send(422, { title: 'Key reused with a different body', status: 422 });
return send(200, prev.body, { 'idempotent-replay': 'true' });
}
const order = { id: next++, ...JSON.parse(raw || '{}') };
orders.set(order.id, order);
if (key) keys.set(key, { raw, body: order });
send(201, order);
});
}).listen(8789, () => console.log('orders-api on 8789'));
- RFC 9110 section 9.2.2 for which methods are required to be idempotent. Stop the target with Ctrl+C when the run is over.
Steps
- Step 1.
Send the same body twice with no idempotency key. This is the baseline, and it should create two records.
for i in 1 2; do curl -s -w ' HTTP %{http_code}\n' -X POST http://localhost:8789/v1/orders -H 'content-type: application/json' -d '{"sku":"A-1","qty":1}'; done{"id":1,"sku":"A-1","qty":1} HTTP 201 {"id":2,"sku":"A-1","qty":1} HTTP 201 - Step 2.
Count the records. The count is the assertion, not the status of the second call.
curl -s http://localhost:8789/v1/orders{"count":2,"ids":[1,2]}Two ids from one intent. A retried checkout looks exactly like this.
- Step 3.
Send the same body twice again, now with one key shared by both calls, and read the status lines.
for i in 1 2; do curl -s -D - -o /dev/null -X POST http://localhost:8789/v1/orders -H 'content-type: application/json' -H 'Idempotency-Key: 2f8b-order-77' -d '{"sku":"A-1","qty":1}' | grep -i -E '^HTTP|^idempotent-replay'; doneHTTP/1.1 201 Created HTTP/1.1 200 OK idempotent-replay: trueThe 201 created the order. The 200 did not, and the marker header says so.
- Step 4.
Repeat the keyed call once more and read the body it returns.
curl -s -X POST http://localhost:8789/v1/orders -H 'content-type: application/json' -H 'Idempotency-Key: 2f8b-order-77' -d '{"sku":"A-1","qty":1}'{"id":3,"sku":"A-1","qty":1}Id 3 is the order the first keyed call created. A replay that returns a new id, or an empty body, breaks a client that reads the id from the response.
- Step 5.
Count again.
curl -s http://localhost:8789/v1/orders{"count":3,"ids":[1,2,3]}Three keyed calls, one new record. Compare with step 2, where two calls made two.
- Step 6.
Reuse the key with a different body. A stored key must not be allowed to answer for a request that is not the same request.
curl -s -w ' HTTP %{http_code}\n' -X POST http://localhost:8789/v1/orders -H 'content-type: application/json' -H 'Idempotency-Key: 2f8b-order-77' -d '{"sku":"A-1","qty":9}'{"title":"Key reused with a different body","status":422} HTTP 422 - Step 7.
Check the method that the specification already requires to be idempotent. Delete the same order twice.
for i in 1 2; do curl -s -o /dev/null -w "DELETE /v1/orders/1 -> %{http_code}\n" -X DELETE http://localhost:8789/v1/orders/1; doneDELETE /v1/orders/1 -> 204 DELETE /v1/orders/1 -> 404Two different statuses, one final state: the order is gone either way. That satisfies RFC 9110, which speaks about the effect on the server and not about the status code.
How to read the result
| What you see | What it means | What to do | | --- | --- | --- | | Second call 200, count unchanged | The key was honoured | Nothing. Record the replay marker header as part of the contract. | | Second call 201, count grew by one | There is no idempotency on this endpoint | Raise it for any endpoint that charges, ships or notifies. | | Second call 409 or 422 | The duplicate was refused, not replayed | Ask the client team. A retry now needs error handling instead of ignoring the answer. | | Second call 500 | A unique constraint fired in the database | Not idempotency. The client cannot tell this from a real failure and retries again. | | Replay returns a different id | The response was regenerated, not stored | The client stored the first id. Two ids for one order corrupt its state. | | Repeated DELETE returns 404 | The effect is idempotent, the status is not | Acceptable. Fix the test, not the API, unless the contract promises 204. |
Common mistakes
What to check next
- How to test API error responses: the 409 or 422 a key conflict returns has to be readable by the client that retried.
- How to test API with invalid input: the other request rule that fails without a visible error.
- How to check HTTP status code: 200 against 201 is the whole signal in step 3.
- How to test API with curl: sending the same body twice with a header, one flag at a time.
- How to check API versioning: the key store is per version, and a version switch can lose it.
FAQ
What does idempotency mean in API testing?
Sending the same request more than once leaves the server in the state one request would have left it in. The test is a count before and after, not a comparison of the two responses. Statuses may differ while the effect stays the same.
Which HTTP methods have to be idempotent?
PUT, DELETE and the safe methods, by RFC 9110 section 9.2.2. POST is not, which is why POST endpoints that matter carry an idempotency key.
How long should an idempotency key be stored?
Long enough to cover every retry the clients perform, which is a documented number rather than a guess. Ask for it, then send the same key just inside the window and just outside it, and confirm the behaviour changes only at the boundary.
Does an idempotency key belong in the body or in a header?
A header. The key describes the attempt and not the resource, and a header survives a body that gets re-serialized differently by a retry helper. Idempotency-Key is the name in common use.
Can I test this against a live third party API?
No. Each attempt writes. Run the target above, or a staging instance you own, and reserve live calls for reading.
Verified
Verified by Maks Vernycurl 8.21.0node 22.23.2
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate12 minpublished updated Maks Verny