Building a Secure Webhook Receiver in Laravel
Webhooks are how third-party services tell your app that something happened: a payment settled, a subscription lapsed, a shipment moved. Receiving them looks trivial, but a production-grade receiver has to do four things well: verify the request is authentic, bypass CSRF without opening a hole, handle duplicate deliveries, and respond fast. Let us build one for the PHP Architect billing provider.
Step 1: The Route
Webhook endpoints are hit by machines, not browsers, so they live outside your session-protected routes. Define a single POST route.
use App\Http\Controllers\WebhookController;
Route::post('/webhooks/billing', WebhookController::class)
->name('webhooks.billing');
Step 2: Skip CSRF the Right Way
Laravel's CSRF protection expects a token that external services cannot provide, so you must exclude the webhook path. In current Laravel this is configured in bootstrap/app.php.
->withMiddleware(function (Middleware $middleware) {
$middleware->validateCsrfTokens(except: [
'webhooks/*',
]);
})
Excluding CSRF is safe here only because we replace it with something stronger in the next step: cryptographic signature verification. Never expose an unauthenticated, unsigned endpoint that mutates data.
Step 3: Verify the Signature
Most providers sign the raw request body with a shared secret and send the signature in a header. You recompute the HMAC and compare it in constant time. Putting this in middleware keeps the controller clean and rejects forgeries before any work happens.
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class VerifyBillingSignature
{
public function handle(Request $request, Closure $next): Response
{
$signature = $request->header('X-Billing-Signature', '');
$expected = hash_hmac(
'sha256',
$request->getContent(), // the RAW body, not parsed input
config('services.billing.webhook_secret'),
);
if (! hash_equals($expected, $signature)) {
abort(403, 'Invalid signature.');
}
return $next($request);
}
}
Two details matter. Use the raw request body via getContent(), because re-encoding the parsed array can change bytes and break the hash. And always compare with hash_equals() to avoid a timing attack.
Step 4: Guarantee Idempotency
Providers retry deliveries, so the same event can arrive more than once. If you charge or email on each delivery, duplicates cause real damage. The fix is to record each event's unique ID and ignore repeats.
Schema::create('received_webhooks', function (Blueprint $table) {
$table->id();
$table->string('event_id')->unique();
$table->timestamps();
});
A unique constraint turns "have we seen this?" into a database guarantee rather than a race-prone check.
Step 5: Respond Fast, Process on a Queue
Webhook senders expect a quick 2xx. If you do heavy work inline, you risk timing out, and the provider will retry, compounding the load. Instead, validate, record, dispatch a job, and return immediately.
namespace App\Http\Controllers;
use App\Jobs\ProcessBillingEvent;
use App\Models\ReceivedWebhook;
use Illuminate\Http\Request;
class WebhookController extends Controller
{
public function __invoke(Request $request)
{
$eventId = $request->input('id');
// firstOrCreate is atomic against the unique index.
$record = ReceivedWebhook::firstOrCreate(['event_id' => $eventId]);
if (! $record->wasRecentlyCreated) {
return response()->noContent(); // duplicate, already handled
}
ProcessBillingEvent::dispatch($request->all());
return response()->noContent(); // 204, acknowledged
}
}
Register the signature middleware on the route, and the controller only ever sees authentic, first-time deliveries:
Route::post('/webhooks/billing', WebhookController::class)
->middleware(\App\Http\Middleware\VerifyBillingSignature::class);
The actual business logic, such as marking an invoice paid, lives in the queued ProcessBillingEvent job, where retries and failures are handled by the queue rather than by the sender.
Wrapping Up
Four safeguards separate a toy webhook endpoint from a production one: signature verification for authenticity, a correct CSRF exclusion, an idempotency key backed by a unique index, and queued processing so you acknowledge quickly. If your provider is one of the popular payment platforms, the spatie/laravel-webhook-client package packages these same ideas into a configurable receiver, and it is a good study even if you roll your own.
Further Reading
- Laravel documentation, "CSRF Protection" (laravel.com/docs/13.x/csrf)
- Laravel documentation, "Queues" (laravel.com/docs/13.x/queues)
- Spatie, "Laravel Webhook Client" (github.com/spatie/laravel-webhook-client)