sz

Sejan

Blog

Designing Idempotent APIs and Webhook Consumers in Laravel: Eliminating Race Conditions and Duplicate Charges

Home › Laravel 12 › Designing Idempotent APIs and Webhook Consumers in Laravel: Eliminating Race Conditions and Duplicate Charges
Designing Idempotent APIs and Webhook Consumers in Laravel: Eliminating Race Conditions and Duplicate Charges

In distributed web applications, network communication is inherently unreliable. Mobile devices switch Wi-Fi networks mid-request, payment gateway webhooks retry failed deliveries multiple times, and frontend users inadvertently double-click the "Submit Order" button. Without defensive architecture, these duplicate transmissions lead to severe production bugs: double billing, duplicate inventory deductions, and corrupted database records.

The solution is Idempotency: ensuring that an identical request can be received and processed one, ten, or a thousand times while producing the exact same side-effects and response as the initial successful execution.

In this guide, we dive deep into the mechanics of building production-grade idempotent REST APIs and webhook consumers in Laravel using the Idempotency-Key HTTP standard, Redis distributed mutex locks, and event deduplication tables.


HTTP Methods and Idempotency Guarantees

According to the HTTP/1.1 specification (RFC 7231 / RFC 9457), standard HTTP verbs possess distinct idempotency contracts:

HTTP Method Naturally Idempotent? Behavior on Repeated Execution
GET, HEAD, OPTIONS Yes (Safe & Idempotent) Read-only; produces zero state modifications on the server.
PUT Yes (Idempotent) Replaces the entire target entity state; repeating it results in the same state.
DELETE Yes (Idempotent) Removes the entity; subsequent calls simply return 404 or 204 without side effects.
POST, PATCH No (Non-Idempotent) Creates new child resources or triggers transactions. Requires application-level idempotency protection.

1. The Idempotency-Key Protocol Architecture

The standard pattern popularized by Stripe and formalized in IETF drafts utilizes an Idempotency-Key header sent by the client:

  1. Request Arrival: The client supplies a unique UUID v4 in the Idempotency-Key header.
  2. Cache Inspection: The server checks Redis for an existing record associated with idempotency:{userId}:{key}.
  3. Cache Hit (Completed): If a recorded response exists, the server returns the cached HTTP status, headers, and body immediately—bypassing controller business logic and database writes.
  4. In-Flight Conflict: If the key exists with status PROCESSING, another worker is actively executing the task. The server rejects duplicate parallel requests with 409 Conflict.
  5. Cache Miss (New Request): The server acquires an atomic lock, processes the controller action, records the response payload in Redis with a 24-hour TTL, and returns the response.

2. Implementing Idempotency Middleware in Laravel

Step 1: The Atomic Middleware

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redis;
use Symfony\Component\HttpFoundation\Response;

class EnsureIdempotency
{
    public function handle(Request $request, Closure $next): Response
    {
        // Only enforce on state-mutating requests
        if (!in_array($request->method(), ['POST', 'PATCH'])) {
            return $next($request);
        }

        $idempotencyKey = $request->header('Idempotency-Key');

        if (!$idempotencyKey) {
            return $next($request);
        }

        $userId = $request->user()?->id ?? 'guest:' . $request->ip();
        $cacheKey = "idempotency:{$userId}:{$idempotencyKey}";
        $lockKey = "lock:{$cacheKey}";

        // 1. Check if previously completed
        $cached = Redis::get($cacheKey);
        if ($cached) {
            $payload = json_decode($cached, true);
            return response($payload['content'], $payload['status'])
                ->withHeaders(array_merge($payload['headers'], [
                    'X-Cache-Lookup' => 'HIT-IDEMPOTENT',
                ]));
        }

        // 2. Acquire atomic distributed lock (5 second timeout)
        $acquired = Redis::set($lockKey, '1', 'EX', 5, 'NX');
        if (!$acquired) {
            return response()->json([
                'error' => 'Conflict',
                'message' => 'A request with this Idempotency-Key is currently being processed.',
            ], 409);
        }

        try {
            /** @var Response $response */
            $response = $next($request);

            // 3. Only cache successful or client-correct responses (2xx / 4xx)
            if ($response->getStatusCode() >= 200 && $response->getStatusCode() < 500) {
                Redis::setex($cacheKey, 86400, json_encode([
                    'status' => $response->getStatusCode(),
                    'content' => $response->getContent(),
                    'headers' => [
                        'Content-Type' => $response->headers->get('Content-Type'),
                    ],
                ]));
            }

            return $response;
        } finally {
            Redis::del($lockKey);
        }
    }
}

3. Building Resilient Webhook Consumers

When third-party services (e.g., Stripe, PayPal, Twilio, GitHub) send webhook events, they guarantee at-least-once delivery. If your server takes 5.1 seconds to respond due to a transient database blip, the webhook provider will re-send the identical event 30 seconds later.

Step 1: Webhook Event Deduplication Migration

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('processed_webhook_events', function (Blueprint $table) {
            $table->id();
            $table->string('provider', 32); // 'stripe', 'github', etc.
            $table->string('event_id', 128);
            $table->string('event_type', 64);
            $table->timestamp('processed_at');

            // Unique composite index guarantees atomic database-level rejection
            $table->unique(['provider', 'event_id']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('processed_webhook_events');
    }
};

Step 2: Safe Webhook Listener with Database Atomic Locks

<?php

namespace App\Jobs;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;

class ProcessStripeWebhookJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(public array $payload) {}

    public function handle(): void
    {
        $eventId = $this->payload['id'] ?? null;
        $eventType = $this->payload['type'] ?? 'unknown';

        if (!$eventId) return;

        DB::transaction(function () use ($eventId, $eventType) {
            // Attempt to insert event record. If duplicate, insert fails immediately
            $inserted = DB::table('processed_webhook_events')->insertOrIgnore([
                'provider' => 'stripe',
                'event_id' => $eventId,
                'event_type' => $eventType,
                'processed_at' => now(),
            ]);

            // If 0 rows were inserted, this event was already processed by another worker
            if ($inserted === 0) {
                return;
            }

            // Execute actual business logic safely
            $this->fulfillCustomerOrder($this->payload['data']['object']);
        });
    }

    protected function fulfillCustomerOrder(array $orderData): void
    {
        // Business logic here (invoice creation, email dispatch)
    }
}

Production Checklist for Idempotent Systems

  • ✓ Always scope Idempotency Keys to User ID: Never share idempotency namespaces across different authenticated tenants.
  • ✓ Set strict TTLs on response caches: 24 to 48 hours is optimal for standard financial transactions.
  • ✓ Never cache 5xx Internal Server Errors: If an unhandled exception occurred, allow subsequent retries to re-execute after bug fixes or database recovery.
  • ✓ Enforce Unique Constraints at the DB Layer: Application memory locks can expire during unexpected network partitions; database unique keys remain the final source of truth.

Conclusion

Designing for idempotency transforms fragile APIs into fault-tolerant distributed systems. By implementing the Idempotency-Key header standard on customer-facing endpoints and atomic deduplication tables on webhook consumers, your Laravel applications can withstand network retries, race conditions, and duplicate traffic with mathematical certainty.

Comments (0)

Leave a Reply ↓

No comments yet. Be the first to start the discussion!

Leave a Comment