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

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'));

Steps

  1. 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
  2. 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.

  3. 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'; done
    
    HTTP/1.1 201 Created
    HTTP/1.1 200 OK
    idempotent-replay: true

    The 201 created the order. The 200 did not, and the marker header says so.

  4. 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.

  5. 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.

  6. 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
  7. 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; done
    
    DELETE /v1/orders/1 -> 204
    DELETE /v1/orders/1 -> 404

    Two 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

Sign: The retry test passes and production still creates duplicates.Cause: The test client generates a new key for every call. The key has to be created once per user action and reused by every retry of that action, so generating it inside the request helper defeats the whole mechanism.
Sign: Duplicate writes are refused, so the endpoint is called idempotent.Cause: A unique index that rejects the second insert produces an error, not a replay. The retrying client receives a failure for work that succeeded, and usually retries again or shows the user an error over a completed order.
Sign: The second call replays a response for a request that never finished.Cause: The key was stored before the work committed. The replay then reports success for a write that was rolled back. Test it by killing the target between the two calls and checking the stored record.
Sign: GET is assumed idempotent, so nothing is tested.Cause: A GET that increments a view counter or extends a session changes state. The method makes a promise, the handler keeps it or does not, and only counting before and after shows which.

What to check next

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.

intermediate12 minpublished updated Maks Verny