
September 12, 2026
11 min read
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 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
- Generate a new key only when the user starts a new checkout or form submission.
- Do not rotate the key between retries of the same submission.
- Persist the key in session storage if the page might reload mid-checkout.
- Clear the key only after a 2xx response or a non-retryable 4xx error.
- 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.
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.
| Approach | TTL | Store 5xx? | Best for |
|---|---|---|---|
| Stripe-style | 24 hours | No — allow retry | Payments, subscriptions |
| Short cache | 1 hour | No | High-volume internal APIs |
| Long audit | 7–30 days | Sometimes | Legal, finance, compliance |
| Queue handoff | Until job completes | No | Async 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.
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.
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
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.

