Skip to content
Muhammet Şafak
tr
Web Development 4 min read

Idempotency in APIs: Safely Repeating the Same Request

Designing API operations that produce no side effects when repeated; idempotency keys and practical implementation at the application layer.


Consider this scenario: a user initiates a payment, the request gets lost in transit, or the client receives a timeout. What should the client do? Should it retry? It has no idea whether the payment went through or not.

This ambiguity is an unavoidable property of systems that communicate over a network. The request was sent but no response arrived — that does not mean “nothing happened.” Maybe the operation completed and the response just never made it back. Maybe it was interrupted halfway. Maybe it never started at all.

The solution is to accept this ambiguity and design retries to be safe. This concept is called idempotency.

What is idempotency?

In mathematics, an operation is idempotent if applying it any number of times with the same input always produces the same result. In the context of APIs: sending the same request more than once should have exactly the same effect as sending it once.

HTTP methods behave differently in this regard:

  • GET, HEAD, OPTIONS, PUT, DELETE — idempotent by nature. Send the same GET /users/5 request ten times; the result is the same.
  • POST — not idempotent by nature. Each POST /payments can create a new payment.

The problem typically arises with POST and with operations that mutate state.

The idempotency key

The core of the solution is this: the client generates a key for each unique operation and sends it along with the request. When the server sees this key, it asks: “Have I already processed a request with this key? If yes, return the same response; if no, perform the operation and store the result under this key.”

If the client retries with the same key, the operation is not executed again — the previously generated response is returned instead.

POST /api/payments
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
Content-Type: application/json

{
  "amount": 9900,
  "currency": "TRY",
  "method": "card"
}

Stripe has been using this approach for a long time, and you can apply the same pattern in your own APIs.

Implementation in Laravel

The basic flow is: when a request arrives, first check whether a record for this key already exists in the cache (or database); if it does, return the stored response; if it does not, run the operation, store the response, and return it.

// app/Http/Middleware/IdempotencyMiddleware.php
class IdempotencyMiddleware
{
    public function handle(Request $request, Closure $next): Response
    {
        $key = $request->header('Idempotency-Key');

        if (!$key) {
            return $next($request);
        }

        $cacheKey = 'idempotency:' . auth()->id() . ':' . $key;

        if ($cached = Cache::get($cacheKey)) {
            return response()->json(
                json_decode($cached['body'], true),
                $cached['status']
            );
        }

        $response = $next($request);

        // Only cache successful responses
        if ($response->getStatusCode() < 500) {
            Cache::put($cacheKey, [
                'body'   => $response->getContent(),
                'status' => $response->getStatusCode(),
            ], now()->addHours(24));
        }

        return $response;
    }
}

A few notes:

Per-user keys. I tie the key to the authenticated user via auth()->id(). This prevents collisions when different users happen to send the same key.

Caching error responses. 5xx errors are not cached; the client should be able to retry. 4xx errors (client errors), however, can be cached: if you resend the same malformed request, you get the same error back.

Cache duration. 24 hours is a reasonable starting point; adjust it based on your business requirements.

Who generates the key?

The client should. If the server generated it, it would be meaningless: the server has already performed the operation and returned the response by the time the key would be generated. The client must have a UUID v4 or similar unique identifier on hand and must preserve that identifier across retries.

On the React Native side:

import { randomUUID } from "expo-crypto";

async function createPayment(amount: number) {
  const idempotencyKey = randomUUID();

  try {
    const response = await api.post(
      "/payments",
      { amount },
      { headers: { "Idempotency-Key": idempotencyKey } }
    );
    return response.data;
  } catch (error) {
    if (isNetworkError(error)) {
      // Retry with the same key
      return retryWithSameKey(idempotencyKey, amount);
    }
    throw error;
  }
}

The key is generated when the payment object is created; the same key is reused on retries. When the server sees the second request, it already knows it has processed it.

Not just payments

The value of idempotency is not limited to payment systems. User registration, email delivery, inventory reservation — any POST operation that has a user-visible side effect can benefit from this pattern.

Mobile applications in particular deal with unreliable network connectivity; users can tap a “Submit” button more than once. Disabling the button on the client side to prevent duplicate submissions is not enough on its own — the server needs to be prepared as well.

Building your design around this assumption — “a request may arrive more than once” — leads to a more resilient API.

Tags: #API
Share:

Comments

Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.

Related Posts

Search the site

Start typing to search posts, projects and pages.

Esc to close Powered by Pagefind