Idempotency Explained: Building APIs That Survive Retries
Imagine you’re purchasing a product online.
You click the “Pay Now” button.
Nothing happens.
After a few seconds, you assume the request failed, so you click the button again.
And again.
A few minutes later, you discover you’ve been charged three times.
What happened?
From the user’s perspective, the payment seemed to fail. From the server’s perspective, however, it successfully processed every request it received.
This is one of the most common problems in distributed systems, and it’s exactly why idempotency exists.
Whether you’re building payment systems, booking platforms, inventory management software, or any API that changes data, retries are inevitable. Networks fail, clients time out, mobile connections drop, and users double-click buttons.
A well-designed API should survive these retries without creating duplicate side effects.
In this article, we’ll explore what idempotency is, why it matters, and how to implement it in your own APIs.
What Is Idempotency?
In simple terms, an idempotent operation can be performed multiple times without changing the final result beyond the first successful execution.
For example:
1
2
3
Turn on the light.
Turn on the light again.
Turn on the light again.
The light is still ON.
Nothing new happened after the first request.
The final state remains the same.
That’s idempotency.
Now compare it with this:
1
2
3
Deposit $100
Deposit $100
Deposit $100
Your account balance increases by $300.
This operation is not idempotent because every request changes the system.
Why Retries Happen
Many developers assume users only send one request.
Reality is different.
Requests are retried because of:
- Slow internet connections
- Gateway timeouts
- Reverse proxies
- Mobile network interruptions
- Browser refreshes
- Double-clicking buttons
- Client retry mechanisms
- Load balancers
- Microservice communication failures
Imagine this timeline:
1
2
3
4
5
6
7
8
9
10
11
12
13
Client -------- POST /payments --------> API
Payment succeeds
API -------- 200 OK --------X
(Response never reaches client)
Client waits...
Client retries.
POST /payments again.
The client believes the payment failed.
The server already completed it.
Without idempotency…
The payment happens twice.
HTTP Methods and Idempotency
HTTP itself distinguishes between idempotent and non-idempotent methods.
GET
1
GET /users/10
Read the user.
Call it once.
Call it 100 times.
Nothing changes.
Idempotent
PUT
1
2
3
4
PUT /users/10
{
"name": "Billy"
}
Replacing the same resource repeatedly produces the same result.
Idempotent
DELETE
1
DELETE /users/10
Delete the user.
Deleting an already deleted user doesn’t delete them twice.
The final state is still:
1
User does not exist.
Idempotent
POST
1
POST /orders
Create a new order.
Call it twice.
You now have two orders.
Not idempotent
This is why POST requests often require additional protection.
Why Payment APIs Use Idempotency Keys
Payment providers like Stripe popularized the use of Idempotency Keys.
The idea is simple.
The client generates a unique identifier.
Example:
1
2
Idempotency-Key:
6ab89d3b-acde-4d71-b20d-483d8d0ef091
Every retry sends the same key.
1
2
3
4
POST /payments
Idempotency-Key:
6ab89d3b-acde-4d71-b20d-483d8d0ef091
When the server receives the request:
- Check if this key already exists.
- If not, process the payment.
- Save both the key and the response.
- Return the response.
If the same request arrives again with the same key:
Instead of charging the customer again…
Return the previously stored response.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
Client
POST /payments
Key: ABC123
↓
Server
Charge customer
↓
Store
ABC123 → Payment #456
↓
Return success
---
Retry
POST /payments
Key: ABC123
↓
Lookup
ABC123 exists
↓
Return Payment #456
No second charge.
Implementing Idempotency
A common workflow looks like this.
Step 1
Receive request.
1
POST /orders
Headers
1
2
Idempotency-Key:
XYZ987
Step 2
Search database.
1
2
3
SELECT *
FROM idempotency_keys
WHERE key = 'XYZ987'
Found?
Yes.
Return stored response.
Done.
Step 3
Not found?
Create the resource.
1
Create Order
Step 4
Store:
1
2
3
4
5
6
7
Key
Response
Status Code
Timestamp
Now every retry returns the same response.
Example in Node.js (Express)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
app.post("/payments", async (req, res) => {
const key = req.header("Idempotency-Key");
const existing = await Idempotency.findOne({ key });
if (existing) {
return res.status(existing.status).json(existing.response);
}
const payment = await processPayment(req.body);
await Idempotency.create({
key,
status: 201,
response: payment,
});
return res.status(201).json(payment);
});
Python (FastAPI)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
app = FastAPI()
@app.post("/payments")
async def create_payment(request: Request):
key = request.headers.get("Idempotency-Key")
existing = await Idempotency.find_one(key=key)
if existing:
return JSONResponse(
content=existing.response,
status_code=existing.status,
)
body = await request.json()
payment = await process_payment(body)
await Idempotency.create(
key=key,
status=201,
response=payment,
)
return JSONResponse(content=payment, status_code=201)
C# (ASP.NET Core)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
app.MapPost("/payments", async (
HttpRequest request,
IdempotencyStore store,
PaymentService payments) =>
{
var key = request.Headers["Idempotency-Key"].ToString();
var existing = await store.FindAsync(key);
if (existing is not null)
{
return Results.Json(existing.Response, statusCode: existing.Status);
}
var body = await request.ReadFromJsonAsync<PaymentRequest>();
var payment = await payments.ProcessAsync(body!);
await store.CreateAsync(new IdempotencyRecord(key, 201, payment));
return Results.Json(payment, statusCode: StatusCodes.Status201Created);
});
Go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
func createPayment(w http.ResponseWriter, r *http.Request) {
key := r.Header.Get("Idempotency-Key")
existing, err := idempotency.FindOne(r.Context(), key)
if err == nil && existing != nil {
w.WriteHeader(existing.Status)
json.NewEncoder(w).Encode(existing.Response)
return
}
var body PaymentRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
payment, err := processPayment(r.Context(), body)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := idempotency.Create(r.Context(), IdempotencyRecord{
Key: key,
Status: http.StatusCreated,
Response: payment,
}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(payment)
}
Laravel
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Route::post('/payments', function (Request $request) {
$key = $request->header('Idempotency-Key');
$existing = Idempotency::where('key', $key)->first();
if ($existing) {
return response()->json($existing->response, $existing->status);
}
$payment = processPayment($request->all());
Idempotency::create([
'key' => $key,
'status' => 201,
'response' => $payment,
]);
return response()->json($payment, 201);
});
The logic is surprisingly simple.
The complexity comes from storing and managing the keys correctly.
Where Should Idempotency Keys Be Stored?
Options include:
Database
Best for most applications.
Pros:
- Persistent
- Reliable
- Easy to query
Cons:
- Slightly slower
Redis
Excellent for high-volume APIs.
Pros:
- Extremely fast
- TTL support
- Easy expiration
Many APIs automatically expire keys after 24 hours.
In-Memory
Useful only during development.
Not recommended for production.
Restarting the server loses everything.
Common Mistakes
Reusing Keys
Every logical operation should have its own unique key.
Bad:
1
2
3
4
5
ABC123
used today
used tomorrow
Good:
1
2
3
4
5
New checkout
↓
Generate new UUID
Ignoring Request Differences
Suppose the first request is:
1
$50
The retry is:
1
$500
Same key.
Different body.
The server should reject this request because the key is being reused for a different operation.
Never Expiring Keys
Keeping millions of old keys forever wastes storage.
Most APIs expire them after:
- 24 hours
- 48 hours
- 7 days
depending on business requirements.
Real-World Use Cases
Idempotency is valuable anywhere duplicate requests could have costly consequences.
Examples include:
- Payment processing
- Bank transfers
- Order creation
- Hotel reservations
- Flight bookings
- Ticket purchases
- Subscription billing
- Inventory updates
- Webhook processing
- Email sending
- Message queues
If performing the same action twice could create an incorrect outcome, idempotency is worth considering.
When You Don’t Need Idempotency
Not every endpoint needs an idempotency key.
For example:
1
GET /posts
No state changes.
No duplicates.
No problem.
Likewise, endpoints such as:
- Search
- Filtering
- Reading reports
- Viewing profiles
are already naturally idempotent.
Reserve idempotency mechanisms for operations where retries could create unintended side effects.
Final Thoughts
Idempotency isn’t just an implementation detail—it’s a reliability feature.
In distributed systems, retries are normal. Networks are unreliable, users click buttons more than once, and clients retry requests automatically. Instead of hoping those situations never happen, design your APIs to handle them gracefully.
By using idempotency keys, storing responses, validating retries, and choosing the right storage strategy, you can prevent duplicate orders, repeated payments, and other costly errors.
A resilient API isn’t one that never receives duplicate requests.
It’s one that produces the correct outcome even when duplicate requests inevitably arrive.
The next time you design a POST endpoint, ask yourself:
“What happens if this request is sent twice?”
If the answer is “something bad,” it’s probably time to add idempotency.
