The first webhook bug I shipped looked harmless: a payment provider retried an event after my endpoint took too long to answer, and the same order got marked as "paid" twice. Nothing crashed. The logs were green. The accountants found it three weeks later.
Webhooks are the most common integration pattern in business software. Payment providers, shipping companies, e-invoicing gateways, CRMs and marketplaces all push events to you instead of waiting for you to poll. And almost every webhook endpoint I have reviewed has the same three holes: it trusts the caller, it accepts old messages, and it assumes each event arrives exactly once.
This article walks through a small, production-shaped webhook receiver in Laravel that closes all three.
The problem: a public URL that changes your data
A webhook endpoint is a public POST route that, by design, modifies business records without a logged-in user. That gives you three questions to answer before you write any business logic:
- Authenticity: did this request really come from the provider?
- Freshness: is this a new message, or a captured one being replayed hours later?
- Idempotency: if the provider sends the same event twice (and it will), do we process it only once?
Most providers solve the first two by signing each request with a shared secret, typically an HMAC-SHA256 over the raw body plus a timestamp. The exact header names and formats differ between providers, so always read your provider's documentation. In this article I'll use a common shape:
X-Signature: t=1793260800,v1=5f2b...e91c
Here t is a Unix timestamp and v1 is HMAC-SHA256(secret, "{t}.{raw_body}") in hex.
Step 1: a table that remembers every event
Idempotency needs memory. Before writing the endpoint, create a table where every incoming event is stored once, enforced by a unique index at the database level, not in PHP:
<?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('provider', 50);
$table->string('event_id', 191);
$table->string('type', 100);
$table->json('payload');
$table->timestamp('processed_at')->nullable();
$table->text('last_error')->nullable();
$table->timestamps();
$table->unique(['provider', 'event_id']);
});
}
public function down(): void
{
Schema::dropIfExists('webhook_events');
}
};
The unique index is on provider + event_id, not on event_id alone. Two different providers can easily produce the same ID format.
Step 2: verify the signature in middleware
Keep verification out of the controller. A middleware makes it reusable across providers and impossible to forget on a new route.
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class VerifyWebhookSignature
{
private const TOLERANCE_SECONDS = 300;
public function handle(Request $request, Closure $next): Response
{
$header = (string) $request->header('X-Signature', '');
$parts = $this->parseHeader($header);
if (! isset($parts['t'], $parts['v1']) || ! ctype_digit($parts['t'])) {
abort(400, 'Missing or malformed signature header.');
}
$timestamp = (int) $parts['t'];
if (abs(time() - $timestamp) > self::TOLERANCE_SECONDS) {
abort(400, 'Signature timestamp outside tolerance.');
}
// Sign the RAW body. Never json_decode and re-encode before verifying.
$signedPayload = $timestamp.'.'.$request->getContent();
foreach ($this->secrets() as $secret) {
$expected = hash_hmac('sha256', $signedPayload, $secret);
// Known string first, user-supplied string second.
if (hash_equals($expected, $parts['v1'])) {
return $next($request);
}
}
abort(401, 'Invalid signature.');
}
private function parseHeader(string $header): array
{
$result = [];
foreach (explode(',', $header) as $pair) {
[$key, $value] = array_pad(explode('=', trim($pair), 2), 2, null);
if ($key !== '' && $value !== null) {
$result[$key] = $value;
}
}
return $result;
}
/** Current secret first, previous one kept during rotation. */
private function secrets(): array
{
return array_filter([
config('services.payments.webhook_secret'),
config('services.payments.webhook_secret_previous'),
]);
}
}
A few details matter here:
-
hash_equals()is a timing-attack-safe comparison. A plain===can leak, through response timing, how many leading characters matched. The PHP manual also asks you to pass the user-supplied value as the second argument. - The timestamp check is your replay protection. A captured request is useless after five minutes because its signature covers the timestamp.
- Supporting two secrets lets you rotate the key without downtime: deploy the new secret as primary, keep the old one as
previous, update the provider, then remove the old one.
Step 3: register the route outside the CSRF-protected group
Providers can't send a CSRF token. Put the route in routes/api.php, which is stateless and doesn't belong to the web middleware group. On Laravel 11 and later, php artisan install:api creates that file and applies the /api prefix.
<?php
use App\Http\Controllers\PaymentWebhookController;
use App\Http\Middleware\VerifyWebhookSignature;
use Illuminate\Support\Facades\Route;
Route::post('/webhooks/payments', PaymentWebhookController::class)
->middleware(VerifyWebhookSignature::class);
If you must keep it in web.php, exclude the URI from CSRF verification in bootstrap/app.php as described in the CSRF page of the docs for your Laravel version (the method name changed in recent releases, so check the version you run).
Step 4: store first, process later
The controller has one job: record the event and answer quickly. Heavy work in the request is the root cause of most duplicate events, because the provider times out, assumes failure and retries.
<?php
namespace App\Http\Controllers;
use App\Jobs\ProcessWebhookEvent;
use App\Models\WebhookEvent;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class PaymentWebhookController
{
public function __invoke(Request $request): JsonResponse
{
$payload = json_decode($request->getContent(), true);
if (! is_array($payload) || empty($payload['id']) || empty($payload['type'])) {
return response()->json(['error' => 'Invalid payload'], 422);
}
try {
$event = WebhookEvent::create([
'provider' => 'payments',
'event_id' => (string) $payload['id'],
'type' => (string) $payload['type'],
'payload' => $payload,
]);
} catch (UniqueConstraintViolationException) {
// Already received. Answer 200 so the provider stops retrying.
return response()->json(['status' => 'duplicate']);
}
ProcessWebhookEvent::dispatch($event);
return response()->json(['status' => 'queued']);
}
}
Two things to notice. First, the duplicate check is the database insert itself. A "check if exists, then insert" approach has a race condition when two retries arrive at the same millisecond; the unique index doesn't. Second, a duplicate returns 200, not an error. If you answer 4xx or 5xx to a duplicate, many providers will keep retrying it.
The model is minimal:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class WebhookEvent extends Model
{
protected $fillable = ['provider', 'event_id', 'type', 'payload', 'processed_at', 'last_error'];
protected function casts(): array
{
return [
'payload' => 'array',
'processed_at' => 'datetime',
];
}
}
Step 5: an idempotent job
Queues retry too. The job must be safe to run twice, so it locks the row and checks processed_at inside a transaction:
<?php
namespace App\Jobs;
use App\Models\WebhookEvent;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\DB;
use Throwable;
class ProcessWebhookEvent implements ShouldQueue
{
use Queueable;
public function __construct(public WebhookEvent $event) {}
public function tries(): int
{
return 5;
}
public function handle(): void
{
DB::transaction(function () {
$event = WebhookEvent::whereKey($this->event->getKey())
->lockForUpdate()
->first();
if ($event === null || $event->processed_at !== null) {
return; // Already handled by another worker or attempt.
}
match ($event->type) {
'payment.succeeded' => $this->markInvoicePaid($event->payload),
'payment.refunded' => $this->registerRefund($event->payload),
default => null, // Unknown types are stored, not failed.
};
$event->update(['processed_at' => now(), 'last_error' => null]);
});
}
public function failed(?Throwable $exception): void
{
$this->event->update(['last_error' => $exception?->getMessage()]);
}
private function markInvoicePaid(array $payload): void
{
// Your domain logic. Keep it idempotent as well:
// "set status to paid" is safe to repeat, "add amount to balance" is not.
}
private function registerRefund(array $payload): void
{
// ...
}
}
The comment in markInvoicePaid is the real lesson. Prefer operations that set a state ("invoice is paid") over operations that accumulate ("add 100 to balance"). If you must accumulate, write a ledger row keyed by the event ID so a repeat insert fails on a unique index.
Step 6: test it with a real signature
Signature code that is never tested will break the first time someone "cleans up" the middleware. Build the raw body yourself and sign exactly that string:
<?php
namespace Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class PaymentWebhookTest extends TestCase
{
use RefreshDatabase;
public function test_valid_event_is_stored_once(): void
{
Queue::fake();
config(['services.payments.webhook_secret' => 'test-secret']);
$body = json_encode(['id' => 'evt_123', 'type' => 'payment.succeeded']);
$t = time();
$sig = hash_hmac('sha256', $t.'.'.$body, 'test-secret');
$server = [
'CONTENT_TYPE' => 'application/json',
'HTTP_X_SIGNATURE' => "t={$t},v1={$sig}",
];
$this->call('POST', '/api/webhooks/payments', [], [], [], $server, $body)
->assertOk()->assertJson(['status' => 'queued']);
$this->call('POST', '/api/webhooks/payments', [], [], [], $server, $body)
->assertOk()->assertJson(['status' => 'duplicate']);
$this->assertDatabaseCount('webhook_events', 1);
}
public function test_tampered_body_is_rejected(): void
{
config(['services.payments.webhook_secret' => 'test-secret']);
$t = time();
$sig = hash_hmac('sha256', $t.'.{"id":"evt_1"}', 'test-secret');
$this->call('POST', '/api/webhooks/payments', [], [], [], [
'CONTENT_TYPE' => 'application/json',
'HTTP_X_SIGNATURE' => "t={$t},v1={$sig}",
], '{"id":"evt_2"}')->assertStatus(401);
}
}
Common pitfalls
-
Verifying a re-encoded body.
json_encode(json_decode($body))can change whitespace, key order or escaping, and the signature won't match. Always sign$request->getContent(). -
Comparing with
===. Usehash_equals(). - No timestamp check. Without it, a signed request is valid forever.
- Doing the work in the request. Slow responses cause provider retries, which cause duplicates. Store, queue, answer.
- "Exists? then insert" instead of a unique index. It works in development and fails under concurrent retries.
- Answering duplicates with an error. Return 200 so the provider stops.
- Failing on unknown event types. Store them and move on; providers add new types without asking you.
-
Secrets in code. Keep them in environment variables, read through
config(), and plan for rotation.
Summary
A reliable webhook receiver is less about clever code and more about order of operations: verify the raw body with a timing-safe HMAC check, reject stale timestamps, record the event behind a unique index, answer fast, and let an idempotent queued job do the real work. In ERP and invoicing systems, where one duplicate can mean a double payment or a wrong stock level, these few dozen lines are some of the most valuable you'll write.
What's the strangest webhook behavior you've seen from a provider in production?
Top comments (1)
The order of operations here is the part I would underline. Storing the event before doing any business work changed webhook bugs for me from silent double charges into something I could inspect and replay safely. The one extra habit I would add is keeping the raw payload exactly as received, not just the parsed fields, because the first time a provider adds a field you did not expect, that raw copy is the only way to reconstruct what actually arrived. Returning 200 for a duplicate is also the detail most teams get backwards at first.