Add idempotency keys to your POST endpoints
When a client times out and retries a POST, did the first one work? An idempotency key lets your API recognize the retry and return the original result instead of doing the work twice.
A partner calls your API to create a payment. Their request reaches you, the payment is created, and then their connection drops before your response arrives. From their side, the request failed. They retry, and now there are two payments.
They can't know whether the first request worked, so they can't safely decide whether to retry. The fix has to come from your API.
The idea
The client generates a unique key, usually a GUID, for each logical operation and sends it in a header:
POST /payments
Idempotency-Key: 6f1c2f4e-0d5b-4a8e-9a77-3e3c0f5d8b21
Content-Type: application/json
The first time your API sees that key, it does the work and saves the response alongside the key. If the same key arrives again, it skips the work and returns the saved response. Retries become safe, however many there are.
Payment providers have worked this way for years, and there's an IETF draft standardizing the Idempotency-Key header.
Store keys in your database
public class IdempotencyRecord
{
public required string Key { get; init; } // primary key
public required string RequestHash { get; init; }
public int? StatusCode { get; set; }
public string? ResponseBody { get; set; }
public DateTimeOffset CreatedAt { get; init; }
}
The flow for each request:
- No key? Reject with
400, or process normally if keys are optional for this endpoint. - Try to insert a record with the key and a hash of the request body. The primary key on
Keymakes this atomic: if two identical retries arrive at the same moment, only one insert succeeds. - Insert succeeded: do the work, then store the status code and response body on the record.
- Insert failed because the key exists: - Response saved: return it as-is. - No response yet: the original request is still running. Return
409 Conflictso the client tries again shortly. - Different request hash: the client reused a key for a different request. Return422 Unprocessable Content.
Putting the record insert and the business work in the same database transaction keeps them consistent: if the work fails and rolls back, the key isn't left behind as "in progress".
Practical details
- Scope keys per client, for example by combining them with the caller's ID, so two clients can't collide.
- Expire old keys after a day or so. Retries happen within seconds or minutes, not weeks.
- Only cache meaningful outcomes. If the request failed with a
500, don't store it; let the retry try again. - Document it. Tell clients to generate one key per operation and reuse it only for retries of that operation.
Takeaway
Any POST that creates something, especially money movements or orders, should accept an idempotency key. Claim the key atomically, store the response with it, and return the saved response on retries. Your clients can then retry safely, and duplicates stop being your problem.