SupportWriter space ↗
← Back to the journal
Laravel

Creating Idempotent Webhooks in Laravel with Inbox Patterns

Verification of payload signatures, deduplication of events with unique indexes, and business change processes in transactions.

DAdministrator · 13 Oct 2026 · 4 min read

You are reading a translated version.

The service provider sent a payment event, then did not receive a response because the connection was lost. A few seconds later the same event is sent again. If the handler adds to the balance on each request, the customer's balance doubles. Repeated submissions are a normal condition in webhook integration.

This case study uses the inbox pattern in Laravel 13: save event identities uniquely and apply business changes in the same transaction. An example of using an artificial HMAC contract to clarify the flow. Customize algorithms, header formats, and timestamp rules with actual provider documentation.

Payload contracts and tables

The example payload has id, type worth payment.settled, and order_idHeader X-Webhook-Timestamp contains Unix timestamps, while X-Webhook-Signature contains upper HMAC-SHA256 hex timestamp.rawBody. Secret is configured on services.payment.webhook_secret, not stored in the controller.

Tables webhook_events require provider, event_id, payload_hash, timestamps, and a combined unique index provider,event_id. In the example, orders have provider_order_id unique status, and paid_at. This service only marks orders as paid; the production must match the merchant, the amount in the smallest units, the currency, and the identity of the order before the change is applied.

Verify before touching the database

This piece is placed in the controller method that receives Illuminate\Http\Request. Import facade Validator and DB. The webhook route does not use a buyer login session. Register on the route API or create a CSRF exception that is restricted to the right endpoint of the webhook according to the Laravel configuration of the application.

$rawBody = $request->getContent();
$timestamp = (string) $request->header('X-Webhook-Timestamp', '');
$signature = (string) $request->header('X-Webhook-Signature', '');
$secret = (string) config('services.payment.webhook_secret');

abort_if($secret === '', 503);
abort_unless(ctype_digit($timestamp), 401);
abort_if(abs(time() - (int) $timestamp) > 300, 401);

$expected = hash_hmac('sha256', $timestamp.'.'.$rawBody, $secret);
abort_unless(hash_equals($expected, $signature), 401);

$data = Validator::make($request->json()->all(), [
    'id' => ['required', 'string', 'max:150'],
    'type' => ['required', 'in:payment.settled'],
    'order_id' => ['required', 'string', 'max:150'],
])->validate();

Calculate signatures from raw bytes, not re-encoded JSON. Changing the space or order of properties changes the signature. A five-minute window limits old replays; a unique index handles replays within that window. The provider's contract should specify whether each retry gets a new timestamp and signature.

Inbox and business effects must commit together

DB::transaction(function () use ($data, $rawBody): void {
    $inserted = DB::table('webhook_events')->insertOrIgnore([
        'provider' => 'example-payment',
        'event_id' => $data['id'],
        'payload_hash' => hash('sha256', $rawBody),
        'created_at' => now(),
        'updated_at' => now(),
    ]);

    if ($inserted === 0) {
        $storedHash = DB::table('webhook_events')
            ->where('provider', 'example-payment')
            ->where('event_id', $data['id'])
            ->value('payload_hash');
        abort_unless($storedHash === hash('sha256', $rawBody), 409);

        return;
    }

    $order = DB::table('orders')
        ->where('provider_order_id', $data['order_id'])
        ->lockForUpdate()
        ->first();

    abort_if($order === null, 503);
    if ($order->status === 'paid') {
        return;
    }
    abort_unless($order->status === 'awaiting_payment', 409);

    DB::table('orders')->where('id', $order->id)->update([
        'status' => 'paid',
        'paid_at' => now(),
        'updated_at' => now(),
    ]);
});

return response()->noContent();

If the order is not yet available or the update fails, the exception cancels the inbox insert so that the retry can try again. Unique index, not checking exists() before insert, prevents two requests from processing the same event identity. In MySQL, insertOrIgnore can also ignore some errors other than duplicates; input validation and hash comparison ensure that these conditions are not immediately considered successful.

Pattern and testing limits

Two different events for the same payment may still be coming in. Therefore, the order status transition must also be idempotent. If the business writes a ledger, use a unique index based on the provider's transaction identity. Refund and cancellation events require their own state machine; don't force the entire event to be paid.

Wrong signature test, expired timestamp, same event twice, same event ID with different payload, missing order, and failure before commit. Do not log secrets or entire sensitive payloads. For heavy work, inboxes need separate processing and worker statuses; new successful responses mean saved events, not all work is completed.

API Reference: Insert statements and Database transactions.

Comments 0

Share your questions, experiences, or feedback about this article.

No comments yet. Be the first to share your feedback.

← Explore more notes