
September 08, 2026
14 min read
By Kokil Thapa | Last reviewed: September 2026
Network timeouts, mobile app retries, and webhook redelivery can turn one user action into two charges, two bookings, or two legal filings. This API Idempotency Keys Implementation Guide explains how to make unsafe HTTP methods safe under retry. You will learn header design, server-side storage, response replay, and the edge cases that break naive implementations. The patterns here apply to Laravel REST APIs, payment gateways, and any endpoint where duplicate side effects cost real money.
What are API idempotency keys and why do you need them?
HTTP defines idempotent methods. GET, PUT, and DELETE should produce the same server state when repeated with identical inputs. POST is not idempotent by default. A client that posts twice may create two orders.
Idempotency keys solve that gap. The client generates a UUID or similar token once per logical operation. It sends that token on every retry of the same intent. The server treats the key as a deduplication handle for that user, route, and payload fingerprint.
On real client projects I have seen payment callbacks arrive three times within seconds. Without idempotency, the ledger shows three credits for one bank transfer. The fix is not "tell users not to double-click." Clients will retry. Networks will drop packets. Load balancers will time out while your database commit still runs.
Common triggers for duplicate writes include mobile apps retrying after a spinner timeout, JavaScript fetch wrappers with automatic backoff, and partner webhooks that assume at-least-once delivery. REST API design best practices in 2026 treat idempotency as mandatory for money-moving endpoints, not an optional nicety.
Where idempotency matters most
- Payment capture, refunds, and wallet top-ups
- Booking confirmations with inventory holds
- Document submission portals where each upload triggers fees
- Webhook handlers that create orders from external events
- Batch imports that clients restart after partial failure
On a legal-tech portal I built, a user could pay a court-fee deposit and upload affidavits in one flow. A dropped connection during checkout looked like failure to the browser. The user paid again. Idempotency keys on the payment endpoint prevented a second ledger entry while still returning the original receipt on retry.
How do you implement idempotency keys in a Laravel API?
Laravel 13 on PHP 8.3 or higher gives you middleware, database transactions, and Redis out of the box. The implementation pattern is consistent across frameworks: accept a key, lock or insert a record, execute once, cache the outcome.
Stripe popularised the Idempotency-Key header. That convention is now the de facto standard. Document it in your OpenAPI spec and enforce it on every non-safe write route that creates billable state.
Step 1: Define the header contract
Require a header such as Idempotency-Key or X-Idempotency-Key. Pick one name and never accept both silently. Keys should be opaque strings between 16 and 255 characters. UUID v4 is the usual choice.
POST /api/v1/orders HTTP/1.1
Host: api.example.com
Authorization: Bearer <token>
Idempotency-Key: 7f3c9a2e-4b1d-4c8a-9e6f-2a1b3c4d5e6f
Content-Type: application/json
{
"product_id": 42,
"quantity": 1
} Scope keys per authenticated principal. A key reused by a different user must return 409 Conflict, not someone else's cached response. Also scope by HTTP method and path, or store the route name in the record.
Step 2: Create the persistence layer
MySQL 9.7 or Redis 8.10 both work. Redis is faster for short TTL caches. MySQL gives durable audit trails. Many production systems use Redis for hot lookups and MySQL for compliance logs.
Schema::create('idempotency_keys', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('key_hash', 64);
$table->string('route', 191);
$table->string('request_hash', 64);
$table->unsignedSmallInteger('response_status');
$table->json('response_body');
$table->string('status', 20)->default('processing');
$table->timestamp('expires_at');
$table->timestamps();
$table->unique(['user_id', 'key_hash', 'route']);
}); Store a SHA-256 hash of the raw key, not the plaintext key, if you want defence in depth. Always hash the request body and compare on replay. Same key with a different payload is an error, not a silent match.
Step 3: Build middleware that gates execution
Register middleware on routes that need protection. The flow below mirrors what I ship on production Laravel applications integrated with API development workflows.
- Read and validate the idempotency header.
- Look up an existing record for user + key + route.
- If status is
completed, return the stored response immediately. - If status is
processing, return409 Conflictor202 Acceptedwith a retry hint. - Insert a
processingrow inside a transaction with a unique constraint. - Run the controller logic.
- Persist status code and JSON body, then mark
completed.
public function handle(Request $request, Closure $next)
{
$rawKey = $request->header('Idempotency-Key');
if (!$rawKey || strlen($rawKey) < 16) {
return response()->json(['error' => 'Missing Idempotency-Key'], 400);
}
$keyHash = hash('sha256', $rawKey);
$route = $request->route()->getName() ?? $request->path();
$bodyHash = hash('sha256', $request->getContent());
$record = IdempotencyKey::where([
'user_id' => $request->user()->id,
'key_hash' => $keyHash,
'route' => $route,
])->first();
if ($record && $record->status === 'completed') {
if ($record->request_hash !== $bodyHash) {
return response()->json(['error' => 'Key reused with different body'], 422);
}
return response()->json($record->response_body, $record->response_status);
}
if ($record && $record->status === 'processing') {
return response()->json(['error' => 'Request in progress'], 409);
}
$record = IdempotencyKey::create([
'user_id' => $request->user()->id,
'key_hash' => $keyHash,
'route' => $route,
'request_hash' => $bodyHash,
'status' => 'processing',
'expires_at' => now()->addHours(24),
]);
$response = $next($request);
$record->update([
'response_status' => $response->getStatusCode(),
'response_body' => json_decode($response->getContent(), true),
'status' => 'completed',
]);
return $response;
} Wrap the handler and the final update in a database transaction when the business logic itself is transactional. If the handler throws, delete or mark the idempotency row as failed so the client can retry with the same key.
Validate JSON payloads with a JSON formatter and linter during development. Mismatched whitespace should not change the request hash if you normalise canonical JSON before hashing.
How should you store, expire, and clean up idempotency key records?
Storage TTL is a product decision tied to retry windows. Payment APIs often keep keys for 24 hours. Booking systems may need 72 hours if clients reconcile overnight batch jobs.
Redis keys with SET key value EX 86400 NX give cheap atomic claims. The NX flag prevents two concurrent requests from both believing they are first. Pair Redis with a Laravel scheduled command that purges expired MySQL rows weekly.
Concurrency and race conditions
Two simultaneous requests with the same key are the hardest edge case. A unique database index on (user_id, key_hash, route) turns the second insert into a catchable exception. Your middleware should then re-read the row and either wait briefly or return 409.
Do not rely on application-level locks alone under PHP-FPM. Multiple workers can enter the same code path at once. Database uniqueness or Redis SET NX is the reliable guard.
What to cache in the stored response
Store enough to reconstruct the HTTP response faithfully: status code, headers that matter (Location for 201 Created), and JSON body. Strip volatile fields like processed_at if they would confuse clients comparing retries, or keep them identical by freezing timestamps at first write.
For large binary responses, store a pointer to an object in S3 rather than the full payload. Replay a redirect or a lightweight JSON envelope that references the stored asset.
What is the difference between idempotency keys and duplicate detection?
Teams often confuse the two patterns. Duplicate detection asks, "Have we seen this business event before?" Idempotency asks, "Have we already executed this specific client attempt?"
Duplicate detection might use a natural key such as gateway_transaction_id from eSewa or Khalti. That works for webhook ingestion. It does not help when your own mobile app retries POST /orders before the gateway ID exists.
| Approach | Key source | Best for | Limitation |
|---|---|---|---|
| Idempotency-Key header | Client-generated UUID | User-initiated POST retries | Requires client SDK discipline |
| Natural business key | Server or partner ID | Webhook deduplication | Unavailable before first success |
| Idempotent HTTP methods | Resource URI + verb | PUT upserts, DELETE | Does not cover POST creates |
| Database unique constraint | Column like reference_no | Internal batch jobs | Exposes DB errors to clients |
Use both layers on payment-heavy systems. The idempotency key protects the HTTP boundary. A unique index on payment_reference protects the ledger even if a buggy client omits the header. OWASP API security checks flag broken object-level authorization when keys are scoped incorrectly, so test cross-user replay explicitly.
How do major payment APIs implement idempotency keys?
Stripe's idempotent requests documentation defines the reference behaviour most teams copy. Send Idempotency-Key on POST requests. Stripe stores the result for 24 hours. Replays return the same status and body, including error responses from the first attempt.
That last detail matters. If the first request failed validation, retries must replay the same 400 response until the client generates a new key. Do not silently succeed on retry after a failed first attempt unless your API contract explicitly allows correction flows.
SDK and client responsibilities
Your public SDK should generate the key once per high-level method call and attach it automatically. Document that users must not reuse keys across different operations. SDK design for public APIs should expose an optional override for testing, but default to safe behaviour.
When integrating outbound calls to Stripe, PayPal, eSewa, or Khalti, pass your own idempotency key upstream if the gateway supports it. On Nepal Gift Card–style Laravel platforms, I forward a deterministic key derived from the internal order UUID so both sides dedupe consistently.
Testing idempotent endpoints
Automated tests should fire two identical requests and assert one database row. Also test payload mismatch, missing header, expired key, and parallel requests. API contract testing with Pact helps ensure clients send the header your middleware expects.
Load testing with rate limiting and throttling enabled verifies that 429 responses do not corrupt idempotency state. A throttled first attempt should leave the record in processing or roll back cleanly.
How do you document and version idempotency behaviour?
Publish idempotency rules in your OpenAPI document under each protected operation. State header name, max length, TTL, and error codes. Link to a worked example with curl. API documentation with Redoc and Swagger UI renders those notes where integrators actually read them.
When you ship API versioning, do not change idempotency semantics silently. If v2 alters which routes require keys, call that out in your deprecation guide. Clients cache SDK behaviour for years.
Queue workers need the same guarantees. If POST /orders dispatches a job, pass the idempotency key into the job payload. The worker should skip processing when the order row already exists. I use this pattern on booking systems like trek management platforms where confirmation emails must not send twice.
For webhook endpoints, store the provider's event ID with a unique index. Return 200 on duplicates so the sender stops retrying. Combine that with internal idempotency when your handler creates downstream POST calls to other services.
Redis 8.10 memory is cheap relative to duplicate refunds. Still, cap stored response bodies. A 2 MB JSON replay bloats cache and slows lookups. Truncate or externalise heavy fields.
Observability closes the loop. Log idempotency hits at info level, conflicts at warn. Dashboard replay ratio alongside custom rate-limit keys. A sudden spike in 409 responses often means a mobile release regressed retry logic.
Security review belongs in the same pass. Keys must not be guessable sequential integers. Treat predictable keys like missing auth. Pair idempotency with token authentication so anonymous callers cannot pollute your key store.
On client portals with document payments, idempotency also protects support staff. Admin tools that "retry payment" must send the same key as the original attempt, not mint a fresh one each click.
Enterprise integrations sometimes ask for idempotency in the JSON body instead of a header. That works for JSON-only clients but breaks file uploads. Standardise on headers unless a legacy constraint forces otherwise. Enterprise application projects benefit from a written integration contract that names the header explicitly.
Composer 2.10 and Laravel 13 migrations make the schema portion straightforward. The hard part is defining failure semantics and testing concurrent retries. Budget time for that in estimates. A middleware skeleton takes an hour. Race-safe behaviour takes a day.
Reference the IETF draft on Idempotency-Key header standardisation when writing public docs. Aligning with the emerging standard reduces friction for integrators who already send Stripe-style headers.
Finally, read Laravel API best practices alongside this guide. Idempotency complements pagination, validation, and cursor pagination as baseline API hygiene, not a special-case patch.
Key Takeaways
- Require a client-generated
Idempotency-Keyheader on every POST that creates money, inventory, or irreversible records. - Scope keys by authenticated user, route, and hashed request body; reject reuse with different payloads.
- Use a unique database constraint or Redis SET NX to win concurrent duplicate requests safely under PHP-FPM.
- Replay the exact stored status and JSON body, including first-attempt errors, for the TTL window.
- Layer natural business unique keys underneath for webhook and ledger protection.
- Document TTL, error codes, and SDK behaviour in OpenAPI, then test parallel retries in CI.
People Also Ask
How long should an idempotency key be stored?
Twenty-four hours covers most client retry and reconciliation windows. Payment processors like Stripe use that default. Extend to 72 hours if batch jobs replay overnight, and purge expired rows with a scheduled task so tables stay bounded.
Should GET requests use idempotency keys?
No. GET is already idempotent by HTTP semantics. Adding keys to safe methods adds noise without benefit. Focus keys on POST, and selectively on PATCH when partial updates create side effects like fees.
What HTTP status code should a replay return?
Return the original status code and body from the first completed request. Do not convert a replay into 200 OK if the first response was 201 Created with a Location header. Clients depend on identical outcomes.
Can the same idempotency key be used for different endpoints?
Only if your server scopes keys per route, which is recommended. The same UUID on POST /orders and POST /refunds should be treated as two independent records, preventing accidental cross-route replay.
Ship retry-safe APIs with confidence
This API Idempotency Keys Implementation Guide gives you the header contract, Laravel middleware pattern, storage choices, and testing checklist production systems need. Start with payment and booking endpoints, add observability, then roll the middleware across write routes. If you want help hardening a Laravel or Symfony API for Nepal payments and international gateways, contact us about API implementation or explore custom software development and ongoing support and maintenance for the systems you already run.
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.

