Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

Idempotency Keys for Safe Retries

By Kokil Thapa | Last reviewed: September 2026

Your client retries a failed API call. The server already processed the first attempt, but the response never arrived. Without idempotency keys for safe retries, the same payment, booking, or order can post twice. That is not an edge case on flaky mobile networks. It is the default on real checkout flows. This guide explains how to design, send, and enforce idempotency keys in REST API development so retries stay safe under load.

What Are Idempotency Keys and Why Do Retries Need Them?

An idempotency key is a client-generated unique token sent with a mutating API request. The server uses it to recognise duplicate submissions. If the same key arrives again, the server returns the stored response instead of repeating the side effect.

HTTP GET and PUT are idempotent by design. POST is not. Most payment, checkout, and webhook handlers use POST. That is where duplicates hurt.

On a production Laravel application I maintained, a mobile client retried a booking POST after a timeout. The trek was reserved twice until we added key-based deduplication. The fix was small. The business impact was not.

Idempotency Keys for Safe RetriesClientUUID keyAPI LayerCheck keyStoreRedis or DBResponseCached replayRetry with same keyNo duplicate chargeSame HTTP status + bodyFirst request runs logic; retries replay stored outcome
How idempotency keys for safe retries prevent duplicate side effects when clients resubmit after timeouts.

Idempotency differs from generic retry and backoff strategies. Backoff controls timing. Idempotency controls outcome. You need both on any mutating endpoint that clients may retry.

How Should Clients Generate and Send Idempotency Keys?

The client owns key generation. The server owns enforcement. That split keeps mobile apps, SPAs, and partner integrations consistent.

Key format and header placement

Use a cryptographically random string. UUID v4 works well. Stripe popularised the Idempotency-Key header. Many teams follow that convention because developers already know it.

POST /api/v1/orders HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJ...
Content-Type: application/json
Idempotency-Key: 7f3c9a2e-4b1d-4c8a-9f0e-1d2b3c4d5e6f

{
  "product_id": 42,
  "quantity": 1,
  "payment_method": "khalti"
}

Generate the key once per logical user action. Store it in memory until the server returns a definitive response. Reuse the exact same key on every retry for that action.

Client-side rules that prevent bugs

  1. Generate a new key only when the user starts a new checkout or form submission.
  2. Do not rotate the key between retries of the same submission.
  3. Persist the key in session storage if the page might reload mid-checkout.
  4. Clear the key only after a 2xx response or a non-retryable 4xx error.
  5. Log the key in client error reports so support can trace duplicate disputes.

For JavaScript frontends bundled with Vite 8.x, a small helper keeps behaviour predictable:

function getIdempotencyKey(action) {
  const storageKey = `idempotency:${action}`;
  let key = sessionStorage.getItem(storageKey);
  if (!key) {
    key = crypto.randomUUID();
    sessionStorage.setItem(storageKey, key);
  }
  return key;
}

async function submitOrder(payload) {
  const key = getIdempotencyKey('checkout');
  const response = await fetch('/api/v1/orders', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Idempotency-Key': key,
    },
    body: JSON.stringify(payload),
  });
  if (response.ok) sessionStorage.removeItem('idempotency:checkout');
  return response;
}

Pair this pattern with solid API authentication so keys cannot be replayed across different user sessions.

How Do You Implement Idempotency Keys on the Server in Laravel?

Laravel 12 and Laravel 13 do not ship a built-in idempotency middleware. You add a thin layer in front of mutating controllers. Redis 8.10 is a strong default store because lookups are fast and TTL is native.

Middleware skeleton

Create middleware that runs before your controller on POST, PUT, and PATCH routes that need protection:

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Symfony\Component\HttpFoundation\Response;

class EnsureIdempotency
{
    public function handle(Request $request, Closure $next): Response
    {
        $key = $request->header('Idempotency-Key');

        if (! $key || strlen($key) < 16 || strlen($key) > 255) {
            return response()->json(['error' => 'Invalid Idempotency-Key'], 422);
        }

        $cacheKey = sprintf(
            'idempotency:%s:%s:%s',
            $request->user()?->id ?? 'guest',
            $request->path(),
            hash('sha256', $key)
        );

        if ($cached = Cache::get($cacheKey)) {
            return response($cached['body'], $cached['status'])
                ->withHeaders($cached['headers'] ?? []);
        }

        $lock = Cache::lock($cacheKey . ':lock', 30);

        if (! $lock->get()) {
            return response()->json(['error' => 'Request in progress'], 409);
        }

        try {
            if ($cached = Cache::get($cacheKey)) {
                return response($cached['body'], $cached['status'])
                    ->withHeaders($cached['headers'] ?? []);
            }

            $response = $next($request);

            if ($response->getStatusCode() < 500) {
                Cache::put($cacheKey, [
                    'status' => $response->getStatusCode(),
                    'body' => $response->getContent(),
                    'headers' =gt; ['Content-Type' => 'application/json'],
                ], now()->addHours(24));
            }

            return $response;
        } finally {
            $lock->release();
        }
    }
}

Register the middleware on payment and order routes only. Do not apply it globally. Read-heavy GET endpoints do not need it.

Database-backed storage for audit trails

Some teams prefer a dedicated table for compliance. That works well on legal-tech portals where you must prove what the server returned on each attempt.

Schema::create('idempotency_records', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->nullable()->constrained();
    $table->string('route', 191);
    $table->string('key_hash', 64)->unique();
    $table->unsignedSmallInteger('status_code');
    $table->json('response_body');
    $table->timestamp('expires_at');
    $table->timestamps();
    $table->index(['user_id', 'route', 'key_hash']);
});

Wrap the business logic in a database transaction. Persist the idempotency record in the same transaction as the order row. That avoids a race where the key is stored but the order rolls back.

For deeper patterns, see the companion API idempotency keys implementation guide. It covers edge cases this article references but does not repeat.

Server Idempotency DecisionRequest arrivesKey missing?Return 422Key cached?Replay responseLock held?Return 409Run handlerStore resultRelease lockReturn to clientNever execute business logic twice for the same key
Server decision flow when enforcing idempotency keys for safe retries on mutating API endpoints.

What Happens When the Same Idempotency Key Has a Different Request Body?

This is the most common server-side mistake. A client reuses a key but changes the payload. Your API must reject that mismatch.

Hash the normalised request body alongside the key. Compare on every request. If the key exists and the body hash differs, return 422 Unprocessable Entity with a clear error message.

$bodyHash = hash('sha256', json_encode($request->all(), JSON_SORT_KEYS));

$record = IdempotencyRecord::where('key_hash', hash('sha256', $key))->first();

if ($record && $record->body_hash !== $bodyHash) {
    return response()->json([
        'error' => 'Idempotency-Key reused with different payload',
    ], 422);
}

Document this behaviour in your OpenAPI spec. Partner integrations break silently when the rule is undocumented.

On eCommerce flows, tie the key scope to cart version or line-item totals. A user who edits quantity after a failed retry should get a fresh key. That prevents stale payloads from slipping through. Projects like Quick And Easy Nepalese Grocery depend on this kind of guard during local delivery checkout.

How Long Should You Store Idempotency Keys and What Status Codes Should You Cache?

TTL choice balances storage cost against how long clients might retry. Most payment gateways recommend 24 hours. Some teams use 72 hours for B2B batch uploads.

ApproachTTLStore 5xx?Best for
Stripe-style24 hoursNo — allow retryPayments, subscriptions
Short cache1 hourNoHigh-volume internal APIs
Long audit7–30 daysSometimesLegal, finance, compliance
Queue handoffUntil job completesNoAsync processing with dead-letter queues

Never cache 5xx responses under an idempotency key. The first attempt may have failed before committing. A retry deserves a fresh execution attempt.

Do cache 4xx responses that reflect validation failures. The client should not hammer the server with the same invalid payload. Return the identical error body on replay.

Combine idempotency with API rate limiting so abusive clients cannot flood your store with unique keys. Rate limits protect capacity. Idempotency protects correctness.

Unsafe Retry vs Idempotent RetryWithout KeysTimeout on attempt 1Retry creates duplicateDouble chargeSupport ticketsRefund workflowWith KeysTimeout on attempt 1Retry replays cacheSingle chargeConsistent responseHappy customerIdempotency keys for safe retries turn uncertain networks into predictable outcomes
Side-by-side outcome comparison showing why idempotency keys for safe retries matter on payment and booking APIs.

Where Do Idempotency Keys Fit in Webhooks, Queues, and Third-Party APIs?

Outbound webhooks to partners should include an idempotency key in the payload or as a header. Receivers deduplicate on their side. Inbound webhooks from payment gateways like eSewa or Khalti need the same treatment in reverse.

When you hand work to a queue, pass the idempotency key as part of the job payload. The worker checks the key before processing. This pairs naturally with Laravel queues on PHP 8.3 or PHP 8.5.

dispatch(new ProcessPaymentJob(
    orderId: $order->id,
    idempotencyKey: $request->header('Idempotency-Key'),
))->afterResponse();

Third-party APIs often provide their own idempotency support. Stripe's idempotent request documentation is the reference many teams copy. Pass your client key through to Stripe so both layers stay aligned.

For client portals that collect document fees, like platforms in the Mijar Law Associates mould, idempotency on payment endpoints prevents duplicate ledger entries. Users double-click. Networks stall. The API must stay correct anyway.

Async flows need a clear state machine. Mark a key as processing when work starts. Move to completed when done. Retries during processing should return 409 Conflict or poll a status URL.

Idempotency Key LifecyclenewKey receivedprocessingLock heldcompletedCached 24hfailed 5xxNot cachedRetry rules by statecompleted → replay | processing → 409 | failed → re-executeTTL expiry → treat as new request
Lifecycle states for idempotency keys for safe retries across sync and queued API handlers.

Testing idempotency before production

Write feature tests that send the same key twice and assert identical responses. Send the same key with a different body and assert 422. Simulate a slow handler and confirm concurrent requests get 409 or a single execution.

Use the JSON formatter tool to inspect cached response bodies during debugging. Broken serialisation in cached payloads is a subtle bug that only shows up on retry.

Load testing matters too. Redis lock contention under burst traffic can spike latency. Monitor p99 on idempotent endpoints separately from read endpoints. Testing and optimization should include retry storms, not just happy-path CRUD.

Key Takeaways

  • Generate one idempotency key per user action; reuse it on every retry of that same submission.
  • Store successful and 4xx responses; never cache 5xx under the key so failed attempts can rerun safely.
  • Reject keys reused with a different request body hash to prevent silent payload swaps.
  • Use Redis with locks for fast deduplication; use a database table when audit trails are required.
  • Apply middleware only on mutating routes that have financial or inventory side effects.
  • Document header requirements in OpenAPI and enforce them in CI contract tests.

People Also Ask

What is the difference between idempotency and deduplication?

Idempotency guarantees the same outcome no matter how many times a request is sent with the same key. Deduplication is a broader term that may drop duplicate messages without returning the original response. Idempotency keys for safe retries always replay the first result to the client.

Should GET requests use idempotency keys?

No. GET requests should not change server state. They are already safe to retry without keys. Adding keys to GET adds overhead with no benefit unless your GET incorrectly performs writes.

Can two different users share the same idempotency key?

The key should be unique per user or tenant scope. Include the authenticated user ID or API key identity in the cache key namespace. Otherwise user A could replay user B's cached response.

What HTTP header name should I use for idempotency keys?

Idempotency-Key is the de facto standard used by Stripe and many REST APIs. Some teams use X-Idempotency-Key. Pick one, document it, and stay consistent across all mutating endpoints.

Ship APIs That Survive Real Networks

Idempotency keys for safe retries are not optional on payment, booking, or order endpoints. They are baseline correctness for any API your clients will retry. Start with one high-risk POST route, add middleware, write three feature tests, and expand from there.

If you are building checkout flows, partner integrations, or client portals on Laravel 12 or Laravel 13, get the idempotency layer right before launch. It is cheaper than reconciling duplicate charges after the fact.

Need help designing retry-safe APIs for an eCommerce or legal-tech platform? Review the eCommerce development and custom software services, browse the Adventure Third Pole Trek booking work, or contact us to talk through your API design.

Frequently Asked Questions

A client-generated unique token sent with mutating API requests. The server stores the first result and replays it on duplicate submissions instead of repeating the payment, order, or booking side effect.

Use Idempotency-Key, the de facto standard popularised by Stripe. Document your choice in OpenAPI and keep it consistent across every mutating endpoint your clients call.

No. GET is already idempotent by design and should not change server state. Adding keys to GET adds overhead with no benefit unless your GET incorrectly performs writes.

POST requests for payments, checkouts, and bookings are not idempotent like GET or PUT. When a mobile client retries after a timeout, the server may have already processed the first attempt even though the response never arrived. Without a key, each retry looks like a new submission. That produces double charges, duplicate trek reservations, or twin ledger entries. This is normal on flaky networks, not a rare edge case. Idempotency keys control outcome while backoff only controls retry timing.

The client owns key generation; the server owns enforcement. Generate one cryptographically random string per logical user action, commonly UUID v4, and send it in the Idempotency-Key header. Reuse the exact same key on every retry for that submission. Persist it in session storage if the checkout page might reload. Generate a fresh key only when the user starts a new checkout or form submission. Clear the key after a 2xx response or a non-retryable 4xx error, and log it in client error reports so support can trace duplicate disputes.

Laravel 12 and Laravel 13 do not ship built-in idempotency middleware. Add custom middleware before mutating controllers on payment and order routes only, not globally on read-heavy GET endpoints. Validate Idempotency-Key length between 16 and 255 characters. Build a cache key scoped by authenticated user ID, request path, and a SHA-256 hash of the key. Check Redis 8.10 for a cached response and replay it. Acquire a Cache lock to block concurrent duplicate execution, then cache responses with status codes below 500 for 24 hours.

Your API must reject that mismatch, which is the most common server-side mistake in idempotency implementations. Hash the normalised request body alongside the key using JSON_SORT_KEYS and compare on every request. If the key exists but the body hash differs, return 422 Unprocessable Entity with a clear error message. Document this behaviour in OpenAPI so partner integrations do not break silently. On eCommerce checkout flows, tie key scope to cart version or line-item totals so a user who edits quantity after a failed retry must generate a fresh key.

TTL choice balances storage cost against how long clients might retry. Most payment-style flows follow a 24-hour window, similar to common gateway recommendations. High-volume internal APIs sometimes use one hour. Legal and finance teams may retain records for 7 to 30 days in a database table for compliance audit trails. Async queue handoffs should keep the key until the job completes. Never cache 5xx responses under the key, because the first attempt may have failed before the transaction committed and a retry deserves a fresh execution attempt.

No. The first attempt may have failed before the order or payment row committed. Caching a 5xx under the key would block legitimate retries from running again. Do cache 4xx validation failures so the client receives the identical error body on replay instead of hammering the server with the same invalid payload. Cache successful 2xx responses and definitive client errors with status codes below 500. Combine this with API rate limiting so abusive clients cannot flood your store with unique keys while idempotency protects correctness.

Idempotency guarantees the same outcome no matter how many times a request arrives with the same key. The server always replays the stored first result back to the client. Deduplication is a broader term that may simply drop duplicate messages without returning the original response. Retry and backoff strategies control when a client resubmits after failure. Idempotency controls what happens when that resubmission arrives. You need both on any mutating endpoint that clients will retry under real network conditions on checkout, booking, or payment flows.

No. The key must be unique within user or tenant scope. Include the authenticated user ID or API key identity in your server-side cache key namespace, such as combining user ID, route path, and a hash of the client key. Otherwise user A could receive user B's cached response, which is both a correctness failure and a security risk. Pair idempotency keys with solid API authentication so keys cannot be replayed across different sessions. Generate one key per logical user action and enforce that scope on every mutating endpoint.

Redis 8.10 is the strong default on Laravel applications because lookups are fast and TTL is native. Use Cache locks in middleware to handle concurrent duplicate requests and return 409 while the first execution is in progress. Choose a dedicated idempotency_records database table when audit trails are required, such as on legal-tech portals where you must prove what the server returned on each attempt. Wrap business logic and idempotency persistence in the same database transaction as the order row to avoid storing a key when the order rolls back.

When handing work to a queue on PHP 8.3 or PHP 8.5, pass the idempotency key as part of the job payload so the worker checks it before processing. Dispatch after the HTTP response when appropriate. Mark a key as processing when work starts and return 409 Conflict or a status poll URL for retries during processing. Outbound webhooks to partners should include the key in the payload or as a header so receivers deduplicate on their side. Inbound webhooks from payment gateways like eSewa or Khalti need the same reverse treatment on your server.

Write feature tests that send the same key twice and assert identical responses. Send the same key with a different body and assert 422. Simulate a slow handler and confirm concurrent requests receive 409 or result in a single execution. Inspect cached response bodies during debugging because broken serialisation in stored payloads is a subtle bug that only appears on retry. Load test idempotent endpoints separately from read endpoints, since Redis lock contention under burst traffic can spike p99 latency. Include retry storms in testing, not just happy-path CRUD operations.

Return 422 for a missing or invalid Idempotency-Key header and when a client reuses a key with a different normalised payload hash. Return 409 Request in progress when a concurrent duplicate arrives while the first request still holds the lock, or during async processing before the job completes. For genuine duplicate retries with a matching body, replay the original stored status code and response body, typically 2xx or a cached 4xx validation error. Document all three behaviours in your OpenAPI spec and enforce them in CI contract tests so partner integrations handle retries correctly.

Share this article

0 Comments

Leave a comment

Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

Quick Contact Options
Choose how you want to connect me: