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.
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:
| Scenario | What to verify |
|---|---|
| Same key, same payload, sequential requests | One side effect; documented replay or duplicate response |
| Same key, different payload | Explicit conflict or documented original-result behavior; never silently mutate the original intent |
| Different keys, same payload | Two operations may be valid; assert the documented business rule |
| Same key, concurrent requests | Only one operation commits; other callers receive a defined in-progress or replay result |
| Timeout after commit, then retry | One durable operation despite the lost first response |
| Timeout before commit, then retry | One eventual operation or a safe retry according to the contract |
| Retry after key expiry | Documented new-operation behavior; test only in a controlled environment |
| Same key under another tenant or endpoint | No 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.
More from the blog
Read 3 related articles from our latest posts.