Guide 4 of 7 · Writing data
Idempotency keys for safe retries
Let clients safely retry non-idempotent calls without accidentally creating duplicates. Essential for payments and critical operations.
Use it when
Clients may retry requests and you want to avoid duplicate side effects.
Skip it when
The operation is naturally idempotent or read-only.
Idempotency keys let clients safely retry non-idempotent calls (like POST /charges) without accidentally creating duplicates. The client sends an Idempotency-Key header that uniquely identifies “this logical operation”. Your API stores the result keyed by that value and returns the same response for subsequent attempts.
Example: create a payment with idempotency.
POST /paymentsIdempotency-Key: 9e71e58f-5c5e-4ff2-9cec-e4f58d9e4b45Content-Type: application/json
{ "customer_id": "cus_123", "amount": 4900, "currency": "GBP", "source": "card_abc"}First request:
HTTP/1.1 201 CreatedContent-Type: application/json
{ "id": "pay_456", "status": "confirmed", "amount": 4900, "currency": "GBP", "customer_id": "cus_123"}Second request (retry with same key, same body):
HTTP/1.1 201 CreatedIdempotent-Replay: trueContent-Type: application/json
{ "id": "pay_456", "status": "confirmed", "amount": 4900, "currency": "GBP", "customer_id": "cus_123"}Trade-offs
Pros
-
Protects against duplicate charges and orders from retries and “double taps”.
-
Gives clients confidence to retry on 5xx or timeouts.
Cons
-
Requires server-side storage keyed by idempotency key plus route and payload hash.
-
You must define a clear TTL for stored results.
Implementation details
-
The key should be opaque to the server; treat it as a token, not data.
-
Guard against mismatched payloads reusing the same key: either reject or treat as a new logical operation.
-
Be explicit in docs: which endpoints support idempotency, how long results are retained, and what headers are used.
In Laravel
Middleware is the right seam, because the point is to answer before the controller runs at all.
final class Idempotent{ public function handle(Request $request, Closure $next): Response { $key = $request->header('Idempotency-Key');
if (! $key) { return $next($request); }
$cacheKey = sprintf( 'idem:%s:%s:%s', $request->user()->id, $request->route()->getName(), $key, );
$fingerprint = hash('sha256', $request->getContent());
if ($stored = Cache::get($cacheKey)) { if ($stored['fingerprint'] !== $fingerprint) { return response()->json([ 'type' => 'https://apiguide.dev/errors/idempotency-key-conflict', 'title' => 'Idempotency key conflict', 'status' => 409, 'detail' => 'This key was already used with a different request body.', ], 409, ['Content-Type' => 'application/problem+json']); }
return response($stored['body'], $stored['status']) ->withHeaders(['Idempotent-Replay' => 'true']); }
$response = $next($request);
if ($response->isSuccessful()) { Cache::put($cacheKey, [ 'fingerprint' => $fingerprint, 'status' => $response->getStatusCode(), 'body' => $response->getContent(), ], now()->addHours(24)); }
return $response; }}Four decisions in that, each of which is a bug if you get it wrong:
The cache key is scoped, not just the header. User, route and key together. A bare Idempotency-Key as the cache key means one tenant’s retry can collide with another’s, and the same key used against two different endpoints returns the wrong endpoint’s response.
The fingerprint is over the raw body, and mismatches are a 409. This is the difference between an idempotency key and a cache key. Same key plus different body is a client bug, and silently returning the first result hides it in a place nobody will look. hash_equals is unnecessary here, since neither side is a secret.
Only successful responses are stored. Cache a 500 and the client can never retry its way out of a transient failure, which defeats the entire purpose.
The TTL is explicit and documented. Twenty-four hours is a common choice. Whatever you pick, it belongs in your docs, because a client cannot reason about a window it does not know.
One caveat worth stating: this is not safe across concurrent duplicates on its own. Two identical requests arriving simultaneously both miss the cache and both run. If the operation moves money, take a lock on the cache key for the duration of the request, or enforce uniqueness in the database where the write happens. See receiving webhooks in Laravel for the unique-index version of the same idea.
And the caveat that saves you the work entirely: an endpoint whose POST is a pure function has nothing to make idempotent. Validating a document, converting a payload, looking something up. Calling it twice already produces the same result and changes no state. The rule is not “every write needs a key”, it is “know which of your writes have effects”.
Read next
Related guides
Bulk updates via async jobs
Move heavy bulk operations out of the request/response cycle with job resources. Enqueue work, return quickly, let clients poll for status.
Intermediate
IntegrationReceiving webhooks in Laravel
Verify before you touch the database, store the raw payload, process asynchronously, and dedupe on the sender event id. Plus the Cashier ordering trap that makes listeners fire too early.
Intermediate