Idempotency is easiest to add when duplicate requests are treated as normal traffic. A user can double-click. A mobile client can retry after a timeout. A queue can redeliver. A webhook provider can send the same event twice.
The goal is not to make every handler clever. The goal is to give risky operations a stable request identity and a predictable replay result.
Define the API contract
For commands that create side effects, accept an idempotency key and scope it to the authenticated actor:
POST /api/exports
Idempotency-Key: exp_20260611_01
Authorization: Bearer ...
Content-Type: application/json
{
"reportId": "r_123",
"format": "pdf"
}
The server should store:
tenant id or API credential
idempotency key
normalized request fingerprint
status: reserved | running | succeeded | failed
response summary
timestamps
The key should not be global across all customers. Two tenants may generate the same key string without meaning the same operation.
Reserve before doing the side effect
The safest flow is two-step:
const reservation = await idempotency.reserve({
scope: tenant.id,
key: request.headers["idempotency-key"],
fingerprint: fingerprintExportRequest(body),
});
if (reservation.type === "replay") {
return reservation.storedResponse;
}
if (reservation.type === "conflict") {
return conflict("Idempotency key was used with a different request.");
}
const result = await createExportJob(body);
return idempotency.storeSuccess(reservation.id, result);
The handler stays readable. The helper owns reservation, fingerprint comparison, in-progress behavior, and response persistence.
Handle in-progress requests deliberately
If the same request arrives while the first one is still running, do not start a second side effect. Either wait briefly for completion or return a retryable response:
409 Conflict
Retry-After: 2
{
"error": "operation_in_progress",
"message": "A request with this idempotency key is still running."
}
This is much better than silently creating two export jobs or sending two emails.
Fingerprint the parts that change the effect
Normalize the request before hashing so harmless formatting changes do not create false conflicts. Include fields that change the side effect.
function fingerprintExportRequest(body: ExportRequest) {
return hash({
reportId: body.reportId,
format: body.format,
filters: normalizeFilters(body.filters),
});
}
A changed amount, recipient, report ID, plan, or template should not reuse the previous result.
Test the boring cases
The core tests are small but important:
same key + same payload returns stored result
same key + different payload returns conflict
two concurrent requests create one side effect
retry after success returns the original response
downstream timeout leaves enough state to inspect
If a payment provider times out after accepting a charge, logs must include the operation key and external reference. Otherwise support cannot tell whether to replay, retry, or escalate.
Choose retention by business impact
An idempotency record does not need to live forever. It needs to outlive the retry window. A short export job may need hours. Payments, provisioning, and webhooks may need days.
Do not store full sensitive payloads unless the replay path requires them. A fingerprint, final status, response summary, and timestamps are usually enough.
Good idempotency makes retries boring. Good observability explains why retries are happening: replay rate, conflict rate, operation duration, and stuck in-progress records.