All posts
API Testing

Idempotency Testing: Prevent Duplicate Orders and Payments in Your API

Test how an API handles retries, key reuse, concurrent requests, timeouts, and the real side effects behind each response.

Idempotency Testing: Prevent Duplicate Orders and Payments in Your API illustration
API Test Lab10 min read

API idempotency testing checks whether retrying an operation with the same idempotency key causes one business effect rather than multiple ones. It does not mean every repeated request must return byte-for-byte identical responses: an API may replay the original result, or return a documented indication that the operation was already processed. The essential promise is about effects and the key's defined scope.

Why retries need a contract

A client can time out after the server commits an order but before the response reaches the client. The client cannot know whether the operation happened, so it retries. Without deduplication, two POST requests can create two orders or charge twice. An idempotency key gives the server a stable identity for that logical operation.

The API documentation should define which operations accept a key, how long it is retained, how payload conflicts behave, which response is replayed, and what happens during processing. Keys should be scoped by tenant and operation, for example `(tenant_id, POST:/v1/orders, key)`, so another customer's request or a different operation cannot collide with it.

Build the baseline request

Use a unique key for one logical create operation and reuse it only when retrying that same operation:

POST /v1/orders HTTP/1.1
Host: api.example.test
Authorization: Bearer TEST_TOKEN
Content-Type: application/json
Idempotency-Key: order-demo-7f3a

{"sku":"sku_basic","quantity":1}

Save the response, then send the exact same request again with the same key. Verify both responses follow the contract and, independently, that only one order exists. Do not infer no duplicate effect just because both replies have status 201.

Exercise key reuse and concurrency

Test the key behavior as a matrix:

ScenarioWhat to verify
Same key, same payload, sequential requestsOne side effect; documented replay or duplicate response
Same key, different payloadExplicit conflict or documented original-result behavior; never silently mutate the original intent
Different keys, same payloadTwo operations may be valid; assert the documented business rule
Same key, concurrent requestsOnly one operation commits; other callers receive a defined in-progress or replay result
Timeout after commit, then retryOne durable operation despite the lost first response
Timeout before commit, then retryOne eventual operation or a safe retry according to the contract
Retry after key expiryDocumented new-operation behavior; test only in a controlled environment
Same key under another tenant or endpointNo cross-tenant or cross-operation collision

Also inspect the persisted operation record, emitted message, order count, inventory reservation, and payment test ledger where available. For payments, use the provider's sandbox and understand whether its own idempotency key is also required.

Why atomic persistence matters

The server usually needs a unique constraint or atomic insert for the scoped key and a durable record of request identity and outcome. A read-then-write sequence is unsafe: two concurrent requests can both observe “key absent” and both perform the operation. The key claim and operation transition need a concurrency-safe design, often with a database transaction, uniqueness constraint, and recovery state for in-progress work.

The request fingerprint should include the meaningful operation and payload fields, while excluding irrelevant transport details. Store sensitive values carefully; avoid logging raw payloads or keys. On a crash, the service must be able to reconcile a claimed key with whether the business operation committed.

Runnable local demonstration

This small Node.js server shows the sequential behavior only. Save it as `idempotency-demo.mjs` and run `node idempotency-demo.mjs`. It stores state in memory, has no atomic cross-process persistence, and is unsuitable for production concurrency or multiple application instances. It is an educational fixture for checking request and side-effect assertions.

import { createServer } from 'node:http';
import { createHash } from 'node:crypto';

const results = new Map();
const orders = [];
const server = createServer(async (req, res) => {
  if (req.method !== 'POST' || req.url !== '/v1/orders') {
    res.writeHead(404).end(); return;
  }
  const key = req.headers['idempotency-key'];
  if (!key) { res.writeHead(400).end('Idempotency-Key required'); return; }
  let raw = '';
  for await (const chunk of req) raw += chunk;
  let body;
  try { body = JSON.parse(raw); } catch {
    res.writeHead(400).end('Invalid JSON'); return;
  }
  const fingerprint = createHash('sha256').update(JSON.stringify(body)).digest('hex');
  const stored = results.get(key);
  if (stored) {
    if (stored.fingerprint !== fingerprint) {
      res.writeHead(409).end('Key reused with a different payload'); return;
    }
    res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(stored.response));
    return;
  }
  const response = { id: `order-${orders.length + 1}`, sku: body.sku, quantity: body.quantity };
  orders.push(response);
  results.set(key, { fingerprint, response });
  res.writeHead(201, { 'content-type': 'application/json' }).end(JSON.stringify(response));
});
server.listen(8787, () => console.log('Listening on http://localhost:8787'));

Run these commands in another terminal. The first two requests use the same body and key; the last changes the body but keeps the key:

curl -i http://localhost:8787/v1/orders -H 'Content-Type: application/json' -H 'Idempotency-Key: demo-1' --data '{"sku":"basic","quantity":1}'
curl -i http://localhost:8787/v1/orders -H 'Content-Type: application/json' -H 'Idempotency-Key: demo-1' --data '{"sku":"basic","quantity":1}'
curl -i http://localhost:8787/v1/orders -H 'Content-Type: application/json' -H 'Idempotency-Key: demo-1' --data '{"sku":"pro","quantity":1}'

The demo returns the same order ID on the first two calls and a conflict on the changed payload. To inspect its `orders` array while experimenting, add a temporary debug route locally; do not expose such a route in a real service. A production test should query an authorized read endpoint or database fixture to prove exactly one effect exists.

Test expiry and recovery carefully

An expired key may be treated as new, rejected, or retained longer for safety. Read the contract and use a short test retention window or controllable clock in a non-production environment. Test retries around the expiry boundary. Also simulate a worker crash after claiming a key and before finishing: the system needs a recovery policy that does not lose the operation or execute it twice.

For broader mutation checks see how to test REST APIs, API regression testing, and API authentication testing.

References

Use API Test Lab to repeat the same POST request with the same test key, then verify the service's stored side effects independently.

Share

Start testing your APIs

Try API Test Lab free. No credit card required.

Start free

More from the blog

Read 3 related articles from our latest posts.