Skip to content
Muhammet Şafak
tr
Web Development 5 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.

Cover — a brushed metal card engraved KEY: X5F-912-Q8J slid into a violet-lit server bay labelled IDEMPOTENCY

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.

The definition lives in HTTP’s own specification. In the words of RFC 9110, a method is idempotent “if the intended effect on the server of multiple identical requests with that method is the same as the effect for a single such request”; of the methods the specification defines, PUT, DELETE and the safe request methods are idempotent. 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 distinction matters in practice for a specific reason: the RFC says an idempotent request can be repeated automatically if the connection drops before the client reads the response, but adds that a client should not automatically retry a non-idempotent method. Making retries safe on POST is therefore our job.

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"
}

The best-known implementation of this pattern is Stripe. Per its own documentation, the client generates the key, Stripe suggests V4 UUIDs for it, and keys may be removed from the system once they are at least 24 hours old. 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);

        // Cache everything below 5xx — 4xx included
        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. In the code above, 5xx errors are not cached; the client should be able to retry. 4xx errors (client errors) are cached: if you resend the same malformed request, you get the same error back.

That is a deliberate choice, and it differs from Stripe’s: Stripe saves the status code and body of the first request for a given key regardless of whether it succeeded or failed — subsequent requests with the same key get back even 500 errors verbatim. Which one you want depends on your answer to a single question: should a retry be able to get past a server-side error? If you expect transient faults on the server, don’t cache 5xx, as I do here; if you want a retry to be absolutely incapable of producing a new side effect, Stripe’s behaviour is the safer one.

Cache duration. 24 hours is a reasonable starting point — Stripe likewise prunes keys once they are at least a day old. 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. Stripe describes it the same way: the client generates the key and the server uses it to recognise subsequent retries of the same request. 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.


Sources

Experiments on this topic

A swipe-based true/false trivia game; the real subject is not the game but making a client-computed score verifiable through a signature.

What it does today

The swipe-based true/false quiz runs end to end in its core loop as a mobile app; the score is produced on the client but verified server-side with a server_nonce and an HMAC signature. swipenor.com is the app's landing page, not the game itself.

Mobile app Laravel PHP PostgreSQL +6 more
July 2026 — Ongoing
Tags: #API
Expertise: Backend Developer
Share:

Last updated:

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