Skip to main content
WritingVideosSpeakingCodeAbout

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.

intro

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 /payments
Idempotency-Key: 9e71e58f-5c5e-4ff2-9cec-e4f58d9e4b45
Content-Type: application/json
{
"customer_id": "cus_123",
"amount": 4900,
"currency": "GBP",
"source": "card_abc"
}

First request:

HTTP/1.1 201 Created
Content-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 Created
Idempotent-Replay: true
Content-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

All guides →