DEV Community

Aqib Javaid
Aqib Javaid

Posted on

API Idempotency Keys: Stopping Duplicate Writes in Laravel

Introduction: "Just Retry" Is Where Duplicate Charges Come From

A mobile client on a flaky connection sends POST /api/wallet/topup, the request reaches the server, the charge goes through, and the response never makes it back before the connection drops. The client, doing exactly what every retry guide tells it to do, sends the same POST /api/wallet/topup again. From the server's point of view, this is indistinguishable from a second, legitimate top-up — two identical requests, two successful 200s, one confused customer looking at a wallet balance that's twice what they expected and a bank statement with two charges instead of one.

Nothing about that sequence involved a bug in the retry logic. Retrying a failed or timed-out request is the correct thing for a client to do — the alternative is a UI that just hangs forever on a dropped connection, which is worse. The actual gap is on the server: a POST endpoint that performs a side effect has no way to tell "this is the same request I already handled" apart from "this is a new request that happens to look the same." Without that distinction, every retry-safe client is quietly relying on the server never having a bad network day, which is exactly backwards — retries exist because networks have bad days.

I've had to close this gap on products where a duplicate write isn't a cosmetic bug, it's a real financial or operational error: SwapPad, the bulk SIM activation platform inside CelleUp's dealer back office, where a retried activation request against a carrier API that charges non-refundable wholesale cost per SIM means a dealer's wallet gets debited twice for one physical SIM; MindWrite AI, the subscription AI writing tool, where a retried checkout request against Stripe has to resolve to exactly one subscription, not one per network hiccup during signup; SafetySpace, the AI safety platform I run as CTO, where a retried "generate this SWMS document" request against a slow AI provider shouldn't burn a second generation credit and produce two documents for one click; and Expreco, the logistics quoting platform, where a retried quote-acceptance request can't silently create two bookings for the same shipment.

This is a breakdown of how to build idempotency keys into a Laravel API properly: a client-supplied key that scopes a request, a database-backed lock that makes concurrent duplicate requests wait instead of race, a cached response so a retry gets back the original result instead of re-running the side effect, and the specific places this pattern earns its keep versus where it's overkill.

Architecture: What an Idempotency Key Actually Has to Guarantee

The API-level pattern (an Idempotency-Key header a client generates once per logical operation and replays on every retry of that operation) is well established — Stripe popularized it, and most payment and infrastructure APIs now expect it. Implementing it correctly in Laravel comes down to three guarantees that are easy to get half-right: the same key must never execute the side effect twice, two requests carrying the same key that arrive concurrently must not both slip through before either has finished, and a retried request must get back the exact original response, not a fresh recomputation that might legitimately differ.

1. The idempotency key scopes the whole request, not just an ID

A key on its own isn't enough — it has to be tied to the tenant that issued it and, ideally, a hash of the request body, so a client can't accidentally (or a malicious actor can't deliberately) reuse a key across two different operations and have the second one silently return the first one's response.

Schema::create('idempotency_keys', function (Blueprint $table) {
    $table->id();
    $table->foreignId('tenant_id')->constrained();
    $table->string('key'); // client-supplied, e.g. a UUID
    $table->string('request_hash'); // sha256 of method + path + body
    $table->string('status')->default('processing'); // processing, completed
    $table->unsignedSmallInteger('response_status')->nullable();
    $table->json('response_body')->nullable();
    $table->timestamp('locked_until')->nullable();
    $table->timestamps();

    $table->unique(['tenant_id', 'key']);
});
Enter fullscreen mode Exit fullscreen mode

The unique constraint on (tenant_id, key) is the actual source of truth — everything else in this pattern exists to use that constraint correctly under concurrency, not to replace it.

2. A middleware that locks, checks, and replays — in that order

The middleware has three jobs, and they have to run in this exact sequence: try to acquire the key (atomically, so two concurrent requests can't both think they got there first), check whether it was already completed (and if so, hand back the stored response without touching the controller at all), and otherwise let the request through and capture what the controller produces.

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

        if (! $key) {
            return $next($request); // not every endpoint requires one — see below
        }

        $tenant = $request->user()->tenant;
        $requestHash = hash('sha256', $request->method() . '|' . $request->path() . '|' . $request->getContent());

        // Atomic acquire: only one request for this (tenant, key) pair gets past this line
        $acquired = DB::table('idempotency_keys')->insertOrIgnore([
            'tenant_id' => $tenant->id,
            'key' => $key,
            'request_hash' => $requestHash,
            'status' => 'processing',
            'locked_until' => now()->addSeconds(30),
            'created_at' => now(),
            'updated_at' => now(),
        ]);

        $record = DB::table('idempotency_keys')
            ->where('tenant_id', $tenant->id)
            ->where('key', $key)
            ->first();

        if (! $acquired) {
            return $this->resolveExisting($record, $requestHash);
        }

        try {
            $response = $next($request);
        } catch (Throwable $e) {
            $this->release($tenant->id, $key);

            throw $e;
        }

        // Laravel's router converts most exceptions (validation errors, aborts, 500s)
        // into responses before they reach this middleware, so a failed attempt usually
        // arrives here as a non-2xx response rather than a thrown exception.
        // Only cache genuine successes — failures must stay retryable.
        if (! $response->isSuccessful()) {
            $this->release($tenant->id, $key);

            return $response;
        }

        DB::table('idempotency_keys')
            ->where('tenant_id', $tenant->id)
            ->where('key', $key)
            ->update([
                'status' => 'completed',
                'response_status' => $response->status(),
                'response_body' => $response->getContent(),
                'updated_at' => now(),
            ]);

        return $response;
    }

    protected function release(int $tenantId, string $key): void
    {
        // Release the key on failure — a genuinely failed attempt should be retryable,
        // not permanently stuck behind a lock from the attempt that failed
        DB::table('idempotency_keys')
            ->where('tenant_id', $tenantId)
            ->where('key', $key)
            ->delete();
    }

    protected function resolveExisting($record, string $requestHash)
    {
        if ($record->request_hash !== $requestHash) {
            abort(422, 'Idempotency-Key was already used with a different request body.');
        }

        if ($record->status === 'completed') {
            return response($record->response_body, $record->response_status)
                ->header('Idempotency-Replayed', 'true');
        }

        // Still processing — a genuinely concurrent duplicate, not a retry after failure.
        // Poll briefly rather than immediately erroring, since the original is likely
        // to finish within a second or two on most write endpoints.
        for ($i = 0; $i < 10; $i++) {
            usleep(300_000);
            $fresh = DB::table('idempotency_keys')->where('id', $record->id)->first();
            if ($fresh && $fresh->status === 'completed') {
                return response($fresh->response_body, $fresh->response_status)
                    ->header('Idempotency-Replayed', 'true');
            }
        }

        abort(409, 'Request with this Idempotency-Key is still processing.');
    }
}
Enter fullscreen mode Exit fullscreen mode

insertOrIgnore against the unique index is what makes the acquire step actually atomic — two requests arriving in the same millisecond both attempt the insert, the database's unique constraint ensures exactly one of them succeeds, and the loser reads back the winner's row instead of racing it. This is the same "let the database be the lock" discipline that matters in queue job concurrency — a ShouldBeUnique job and an idempotency key are solving the same underlying problem, one at the queue layer and one at the API layer.

3. Failed attempts must release the lock; successful ones must cache the response

The distinction between these two matters more than it looks. If a request fails partway — a validation error, a timeout talking to the carrier API, a database exception — the client should be able to retry with the same key and have it actually run again, because nothing that mattered actually happened. If it succeeds, a retry with the same key should return the original result without running anything again, because the thing that mattered already happened once. Conflating the two is the most common bug in home-grown idempotency implementations: either failures get permanently stuck behind a lock nobody releases, or successes get silently re-executed because the "already done" check only looked at whether a row existed, not whether it reached completed.

Step-by-Step: Wiring It Into a Real Endpoint

Step 1: Require the header only on endpoints where a duplicate write is actually costly. GET requests are naturally idempotent and don't need this at all; most POST/PATCH endpoints that just update display preferences don't either. Reserve it for the ones with real consequences — payments, wallet debits, subscription creation, document generation that burns a credit, booking creation:

Route::post('/wallet/topup', [WalletController::class, 'topup'])
    ->middleware(['auth:sanctum', EnsureIdempotentRequest::class]);
Enter fullscreen mode Exit fullscreen mode

Step 2: Make the header required, not optional, on the endpoints that need it. An idempotency scheme a client can simply omit protects nothing. On SwapPad's carrier activation endpoint, a missing Idempotency-Key is a 400, not a silently-unprotected request:

class TopUpWalletRequest extends FormRequest
{
    protected function prepareForValidation(): void
    {
        if (! $this->header('Idempotency-Key')) {
            abort(400, 'Idempotency-Key header is required for this endpoint.');
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Have the client generate a UUID once per logical operation, at the moment the user takes the action — not once per HTTP attempt. This is the detail that makes the whole scheme work: the key has to be generated before the first network call and reused on every retry of that same click, which means it lives in the client's request-building code, not inside its retry/interceptor logic where a new key would get minted on every attempt and defeat the entire purpose.

// client side — generated once when the user taps "Top Up", reused on every retry
const idempotencyKey = crypto.randomUUID();

async function topUp(amount: number, attempt = 1): Promise<Response> {
  try {
    return await fetch('/api/wallet/topup', {
      method: 'POST',
      headers: { 'Idempotency-Key': idempotencyKey, 'Content-Type': 'application/json' },
      body: JSON.stringify({ amount }),
    });
  } catch (e) {
    if (attempt < 3) return topUp(amount, attempt + 1); // same key, every retry
    throw e;
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Expire keys on a schedule that matches how long a retry is actually plausible, not forever — an idempotency_keys table that never gets pruned is unbounded growth for a safety mechanism that only needs to matter for as long as a client might reasonably still be retrying:

class PruneIdempotencyKeys extends Command
{
    protected $signature = 'idempotency:prune';

    public function handle(): void
    {
        DB::table('idempotency_keys')->where('created_at', '<', now()->subHours(24))->delete();
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 5: Wrap the controller's actual side effect in a database transaction, so a failure partway through the handler can't leave a half-applied write behind that the "release the lock on failure" path then makes retryable against inconsistent state:

class WalletController extends Controller
{
    public function topup(TopUpWalletRequest $request)
    {
        return DB::transaction(function () use ($request) {
            $wallet = $request->user()->tenant->wallet()->lockForUpdate()->first();

            $wallet->increment('balance_cents', $request->integer('amount') * 100);

            $transaction = $wallet->transactions()->create([
                'type' => 'topup',
                'amount_cents' => $request->integer('amount') * 100,
            ]);

            return response()->json(['balance' => $wallet->balance_cents, 'transaction_id' => $transaction->id]);
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 6: Test both the replay path and the concurrent-request path explicitly. An idempotency implementation that's only ever tested by calling the endpoint once will never exercise the exact scenario it exists for:

public function test_retried_request_with_same_key_does_not_double_charge(): void
{
    $tenant = Tenant::factory()->has(Wallet::factory())->create();

    $first = $this->actingAs($tenant->owner)
        ->postJson('/api/wallet/topup', ['amount' => 50], ['Idempotency-Key' => 'abc-123']);

    $second = $this->actingAs($tenant->owner)
        ->postJson('/api/wallet/topup', ['amount' => 50], ['Idempotency-Key' => 'abc-123']);

    $first->assertOk();
    $second->assertOk()->assertHeader('Idempotency-Replayed', 'true');

    $this->assertEquals(5000, $tenant->wallet->fresh()->balance_cents); // charged once, not twice
}

public function test_same_key_with_different_body_is_rejected(): void
{
    $tenant = Tenant::factory()->has(Wallet::factory())->create();

    $this->actingAs($tenant->owner)
        ->postJson('/api/wallet/topup', ['amount' => 50], ['Idempotency-Key' => 'reused-key'])
        ->assertOk();

    $this->actingAs($tenant->owner)
        ->postJson('/api/wallet/topup', ['amount' => 999], ['Idempotency-Key' => 'reused-key'])
        ->assertStatus(422);
}
Enter fullscreen mode Exit fullscreen mode

Real-World Pitfalls to Avoid

Using the primary resource ID as the idempotency key. A client-generated UUID created before the resource exists is the whole point — if the key were, say, the wallet transaction ID, it wouldn't exist yet on the first attempt, which makes it useless for exactly the request that needs protecting.

Checking "does a row exist" instead of "did it reach completed." This is the bug that lets a request which is still mid-flight get treated as already done, or one that failed get treated as permanently locked. The status field, not row existence, is what the replay logic has to branch on.

Caching error responses as if they were completions. In Laravel, most exceptions thrown inside a controller are rendered into responses before they reach your middleware, so a try/catch alone won't see them. If the middleware marks every returned response as completed, a request that failed validation or hit a 500 gets its error permanently replayed on every retry. Check $response->isSuccessful() before caching.

Minting a new key on every retry attempt inside the client's own retry loop. If the interceptor that handles retries is also the thing generating the key, every retry looks like a brand-new operation to the server, and the entire mechanism does nothing. The key has to be created once, upstream of the retry logic, and threaded through every attempt.

No response caching, only a completed/failed flag. A middleware that correctly refuses to re-run the side effect but then returns some generic "already processed" message instead of the original response body still breaks any client logic that depended on reading the transaction ID or confirmation number out of the response — the replay has to be indistinguishable from what the first successful call returned.

Applying this to every endpoint, including read-only or genuinely low-stakes ones. Idempotency keys add real complexity — a table, a middleware, client-side key management — and forcing it onto endpoints where a duplicate write costs nothing just adds friction without protecting anything that mattered. Reserve it for the write paths where "this happened twice" is an actual incident.

No expiry, or an expiry that's shorter than a plausible retry window. Mobile clients in particular can retry hours later after regaining connectivity — an idempotency key that expires in five minutes protects against exactly the scenario least likely to need it (an immediate retry) while leaving the delayed-retry case, arguably the more common one, completely unprotected.

Forgetting the concurrent case and only handling sequential retries. Two identical requests arriving forty milliseconds apart — a double-tap on a slow UI, or a client's own retry firing before the first attempt's response has returned — will both pass a naive "check then insert" implementation, because there was no atomic step preventing it. The unique constraint plus insertOrIgnore is what closes that gap; a SELECT followed by an INSERT in application code does not.

Key Takeaways

Idempotency keys turn "the client retried and it happened to be safe" into "the client retried and it was guaranteed to be safe" — the difference between hoping a duplicate write never lands and structurally preventing it from being possible.

  • A client-generated key, created once per logical operation and reused across every retry, scoped to the tenant and hashed against the request body so it can't be silently reused across two different operations
  • An atomic acquire — a unique database constraint plus insertOrIgnore, not a check-then-insert — so concurrent duplicate requests can't both slip through before either finishes
  • Failed attempts release the lock so a genuine retry can actually retry; successful attempts cache the full response so a replay returns exactly what the first call returned
  • Reserved for endpoints where a duplicate write has a real cost — payments, wallet debits, subscription creation, credit-consuming generation — not applied blanket across a whole API
  • Expired on a schedule that matches realistic retry windows, including delayed retries from clients that were offline, not just immediate ones

The endpoints change from product to product; the requirement that "the client retried" and "it happened twice" never mean the same thing doesn't.

How are you handling duplicate writes in your APIs? I'd love to hear other approaches in the comments.


Originally published on aqibjavaid.site. I'm Aqib Javaid, a senior full-stack engineer building Laravel and Next.js SaaS platforms — more case studies and architecture write-ups at aqibjavaid.site.

Top comments (1)

Collapse
 
_firelinks profile image
Mike Dabydeen •

The failure window I would make more explicit is between the external side effect and the completed update. In the middleware shown, $next($request) can reach a carrier or payment provider, then the process can die before the idempotency row stores the response. Releasing that row on retry may call the external provider again, even though the first charge or activation succeeded. I would carry the same operation key to the provider or use an outbox with a reconciler, then test the crash window between provider success and response caching. How should processing plus an expired locked_until distinguish a dead worker from a request that is still running?