The Problem with Naive Webhook Handling
When integrating with third-party services like Stripe, GitHub, or Twilio, your application relies on webhooks to receive real-time events. However, most developers build webhooks the same way they build standard REST endpoints: receiving the data, running the business logic, and returning a 200 OK.
In production, this approach is a ticking time bomb.
If your database locks up, or if your business logic takes 15 seconds to execute, the third-party service will assume the webhook failed. It will retry. Now you have duplicate processing, race conditions, and potentially duplicate payments.
Here is the exact architecture I use to process millions of webhooks in Laravel safely.
1. Defeating Timing Attacks
When a webhook hits your server, your first job is to prove it actually came from the third-party provider securely calculating an HMAC signature.
A massive mistake developers make when comparing HMAC signatures is using standard equality operators (== or ===). This leaves your server vulnerable to timing attacks, where an attacker can guess the signature character-by-character based on how many nanoseconds the CPU takes to return false.
In Laravel, always use hash_equals() for constant-time string comparison.
private function validateSignature(Request $request): bool
{
$signature = $request->header('X-Stripe-Signature');
$secret = config('services.stripe.webhook_secret');
$expected = hash_hmac('sha256', $request->getContent(), $secret);
// Constant-time execution prevents timing attacks
return hash_equals($expected, $signature);
}
2. Ingest First, Process Later
The golden rule of webhooks is: Acknowledge immediately, process asynchronously.
The controller's only job is to validate the signature, save the raw payload to the database, and dispatch a queue worker. Webhooks should return a 202 Accepted within 50 milliseconds.
public function handle(Request $request)
{
abort_if(! $this->validateSignature($request), 401);
// Save the raw payload immediately
$log = WebhookLog::create([
'provider' => 'stripe',
'event_id' => $request->input('id'),
'event_type' => $request->input('type'),
'payload' => $request->all(),
]);
// Dispatch a dedicated worker
ProcessStripeWebhook::dispatch($log);
return response()->json(['status' => 'acknowledged'], 202);
}
By saving the raw payload to a webhook_logs table first, we gain an immutable audit trail. If our business logic contains a bug and the job fails, we haven't lost the data. We can simply replay the queue later.
3. Designing for Idempotency
Because networks are inherently unreliable, third-party providers guarantee "at-least-once" delivery. This means you will receive the exact same webhook twice eventually.
Your queue jobs must be idempotent, meaning they can run 100 times but only apply the business logic once.
public function handle()
{
// DB transaction with a pessimistic lock
DB::transaction(function () {
$log = WebhookLog::where('id', $this->webhookLog->id)
->lockForUpdate()
->first();
// 1. Idempotency Check
if ($log->processed_at !== null) {
return; // Already processed
}
// 2. Execute Business Logic here...
// 3. Mark as processed
$log->update(['processed_at' => now()]);
});
}
Conclusion
Building a reliable webhook system isn't about handling success; it's about anticipating failure.
By using constant-time signature validation, shifting the workload entirely to background queues, and implementing strict idempotency checks, you can guarantee that your Laravel application will effortlessly absorb massive webhook traffic spikes without dropping a single event.