DEV Community

Billy Okeyo
Billy Okeyo

Posted on • Originally published at billyokeyo.dev on

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:

Turn on the light.
Turn on the light again.
Turn on the light again.
Enter fullscreen mode Exit fullscreen mode

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:

Deposit $100
Deposit $100
Deposit $100
Enter fullscreen mode Exit fullscreen mode

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:

Client -------- POST /payments --------> API

             Payment succeeds

API -------- 200 OK --------X

(Response never reaches client)

Client waits...

Client retries.

POST /payments again.
Enter fullscreen mode Exit fullscreen mode

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

GET /users/10
Enter fullscreen mode Exit fullscreen mode

Read the user.

Call it once.

Call it 100 times.

Nothing changes.

Idempotent


PUT

PUT /users/10
{
   "name": "Billy"
}
Enter fullscreen mode Exit fullscreen mode

Replacing the same resource repeatedly produces the same result.

Idempotent


DELETE

DELETE /users/10
Enter fullscreen mode Exit fullscreen mode

Delete the user.

Deleting an already deleted user doesn't delete them twice.

The final state is still:

User does not exist.
Enter fullscreen mode Exit fullscreen mode

Idempotent


POST

POST /orders
Enter fullscreen mode Exit fullscreen mode

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:

Idempotency-Key:
6ab89d3b-acde-4d71-b20d-483d8d0ef091
Enter fullscreen mode Exit fullscreen mode

Every retry sends the same key.

POST /payments

Idempotency-Key:
6ab89d3b-acde-4d71-b20d-483d8d0ef091
Enter fullscreen mode Exit fullscreen mode

When the server receives the request:

  1. Check if this key already exists.
  2. If not, process the payment.
  3. Save both the key and the response.
  4. Return the response.

If the same request arrives again with the same key:

Instead of charging the customer again...

Return the previously stored response.

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.
Enter fullscreen mode Exit fullscreen mode

Implementing Idempotency

A common workflow looks like this.

Step 1

Receive request.

POST /orders
Enter fullscreen mode Exit fullscreen mode

Headers

Idempotency-Key:
XYZ987
Enter fullscreen mode Exit fullscreen mode

Step 2

Search database.

SELECT *
FROM idempotency_keys
WHERE key = 'XYZ987'
Enter fullscreen mode Exit fullscreen mode

Found?

Yes.

Return stored response.

Done.


Step 3

Not found?

Create the resource.

Create Order
Enter fullscreen mode Exit fullscreen mode

Step 4

Store:

Key

Response

Status Code

Timestamp
Enter fullscreen mode Exit fullscreen mode

Now every retry returns the same response.


Example in Node.js (Express)

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);
});
Enter fullscreen mode Exit fullscreen mode

Python (FastAPI)

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)
Enter fullscreen mode Exit fullscreen mode

C# (ASP.NET Core)

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);
});
Enter fullscreen mode Exit fullscreen mode

Go

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)
}
Enter fullscreen mode Exit fullscreen mode

Laravel

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);
});
Enter fullscreen mode Exit fullscreen mode

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:

ABC123

used today

used tomorrow
Enter fullscreen mode Exit fullscreen mode

Good:

New checkout

↓

Generate new UUID
Enter fullscreen mode Exit fullscreen mode

Ignoring Request Differences

Suppose the first request is:

$50
Enter fullscreen mode Exit fullscreen mode

The retry is:

$500
Enter fullscreen mode Exit fullscreen mode

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:

GET /posts
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)