Idempotent signed webhook receiver
Accept webhooks with an HMAC signature and a timestamp window, and process each event id exactly once using a unique-key insert.
LaravelWebhookswebhookhmacidempotencyqueue
Code
<?php
namespace App\Http\Controllers;
use App\Jobs\ProcessWebhookEvent;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class ReceiveWebhook extends Controller
{
public function __invoke(Request $request, string $source): JsonResponse
{
$secret = (string) config("services.webhooks.{$source}.secret");
$timestamp = (int) $request->header('X-Timestamp');
$body = $request->getContent();
$expected = hash_hmac('sha256', $timestamp.'.'.$body, $secret);
abort_unless($secret !== '' && hash_equals($expected, (string) $request->header('X-Signature')), 401);
abort_if(abs(time() - $timestamp) > 300, 401, 'Stale timestamp');
$event = json_decode($body, true, 16, JSON_THROW_ON_ERROR);
$eventId = (string) ($event['id'] ?? '');
abort_if($eventId === '', 422, 'Missing event id');
// Atomic dedupe: the unique (source, event_id) index lets exactly one request insert.
$isNew = DB::table('webhook_events')->insertOrIgnore([
'source' => $source,
'event_id' => $eventId,
'payload' => $body,
'received_at' => now(),
]) === 1;
if ($isNew) {
ProcessWebhookEvent::dispatch($source, $event);
}
return response()->json(['received' => true, 'duplicate' => ! $isNew]);
}
}
<?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('webhook_events', function (Blueprint $table) {
$table->id();
$table->string('source', 40);
$table->string('event_id', 120);
$table->longText('payload');
$table->timestamp('received_at');
$table->unique(['source', 'event_id']); // the idempotency key
});
}
public function down(): void
{
Schema::dropIfExists('webhook_events');
}
};
How it works
What it solves
Senders retry. A timeout on your side means the same event arrives two or three times, and a naive handler credits a commission twice. This receiver proves the request is genuine, then makes the event id the idempotency key so duplicates are acknowledged but never processed again.
How it works
The sender signs timestamp.body with a shared secret and sends both in headers. The controller recomputes the HMAC from the raw body, compares it with hash_equals, and refuses timestamps outside a five minute window so a captured request cannot be replayed later.
Next comes the dedupe step. A webhook_events table has a unique index on (source, event_id). insertOrIgnore returns 1 for a new row and 0 for a repeat, which is atomic even when two copies arrive at the same instant. Only the first one dispatches the job; repeats still get a 200 so the sender stops retrying.
Handling happens in a queued job, so the HTTP response stays fast and a crash can be retried from the stored payload.
Adapting it
Rename the header names to match your sender, load the secret per source from config, and replace ProcessWebhookEvent with your own job. Add a processed_at update inside the job if you want an audit trail.
Pitfalls
- Sign and verify the raw body, not decoded JSON.
- Do not delete event rows too early; keep them longer than the sender's retry window.
- Return a 2xx for duplicates, never a 409.
How to use it
- Run the migration and add the controller under
app/Http/Controllers. - Put the shared secret in
config/services.phpaswebhooks.secret, read from your environment. - Route it:
Route::post('/hooks/{source}', ReceiveWebhook::class)->withoutMiddleware(['web']). - Create a queued
ProcessWebhookEventjob taking(string $source, array $event). - Test by signing:
hash_hmac('sha256', $timestamp.'.'.$body, $secret)sent asX-SignaturewithX-Timestamp.
Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.