June 19, 2026
Real-Time Without WebSocket? Meet Webhooks in Laravel
If you’ve ever refreshed a payment page wondering why the status hasn’t updated yet — this article is for you.

By Developer Awam
6 min read
Here's a scenario most backend developers have been through: a user completes a payment, gets redirected to a "processing" page, and your app keeps polling the payment gateway every few seconds asking, "Did it go through? Did it go through?"
That works. But it's wasteful, fragile, and honestly — a bit embarrassing for a modern backend.
Webhooks flip the script. Instead of your app constantly asking, the external service tells you the moment something happens. No polling. No wasted requests. Just an HTTP POST straight to your endpoint with everything you need.
And no — you don't need WebSockets for this. Webhooks handle a whole class of real-time problems that WebSockets aren't even designed for.
In this article, we'll cover both sides of the webhook equation in Laravel: receiving webhooks from external services and sending them to other systems. We'll also get into the security layer — HMAC signature verification — because skipping that part is how you end up processing fake payment events.
Webhook vs WebSocket — Know Which Tool to Reach For
Before writing a single line of code, let's clear up the confusion between these two.
Think of a webhook as a doorbell — someone rings it once, you get the signal, done. A WebSocket is an open phone line — both sides can talk freely, anytime, without redialing.
More technically:
Webhook
────────
- Protocol : Standard HTTP/HTTPS (stateless)
- Connection : Opens → sends request → closes
- Direction : One-way (server → server)
- Who initiates: The sender (external service)
- Best for : Event notifications, server-to-server integration
WebSocket
──────────
- Protocol : WS / WSS (persistent TCP connection)
- Connection : Stays open for the duration of the session
- Direction : Two-way (server ↔ client)
- Who initiates: The client
- Best for : Live chat, real-time dashboards, multiplayer gamesWebhook
────────
- Protocol : Standard HTTP/HTTPS (stateless)
- Connection : Opens → sends request → closes
- Direction : One-way (server → server)
- Who initiates: The sender (external service)
- Best for : Event notifications, server-to-server integration
WebSocket
──────────
- Protocol : WS / WSS (persistent TCP connection)
- Connection : Stays open for the duration of the session
- Direction : Two-way (server ↔ client)
- Who initiates: The client
- Best for : Live chat, real-time dashboards, multiplayer gamesUse webhooks when:
- Stripe needs to tell you a payment succeeded
- GitHub triggers your CI/CD pipeline on every push
- A third-party service sends you order or shipping status updates
- System A needs to notify System B when something changes
Use WebSockets when:
- You're building a live chat feature between users
- A dashboard needs to update numbers every second
- You're pushing real-time browser notifications directly to a logged-in user
- You're building a browser-based game with shared state
The good news: they're not mutually exclusive. A common real-world pattern is receiving a Stripe webhook (server-to-server), processing the payment, and then pushing a notification to the user's browser via WebSocket (or Laravel Reverb, which shipped with Laravel 11). Right tool, right job.
For the rest of this article, we're focused on webhooks.
How Webhooks Flow — The Big Picture
Before jumping into code, here's what happens end-to-end:
Receiving a webhook:
External Service (Stripe, GitHub, etc.)
│
│ POST /webhooks/stripe + Signature Header
▼
Laravel App
│
├─ Verify Signature ──► Reject if invalid (HTTP 500)
│
├─ Store payload to DB (webhook_calls table)
│
└─ Dispatch to Queue Job
│
└─ Process payload asynchronouslyExternal Service (Stripe, GitHub, etc.)
│
│ POST /webhooks/stripe + Signature Header
▼
Laravel App
│
├─ Verify Signature ──► Reject if invalid (HTTP 500)
│
├─ Store payload to DB (webhook_calls table)
│
└─ Dispatch to Queue Job
│
└─ Process payload asynchronouslySending a webhook:
Event occurs in Laravel App
│
└─ Dispatch WebhookCall (via Queue)
│
├─ Sign payload with HMAC-SHA256
│
└─ HTTP POST to target URL + auto-retry on failureEvent occurs in Laravel App
│
└─ Dispatch WebhookCall (via Queue)
│
├─ Sign payload with HMAC-SHA256
│
└─ HTTP POST to target URL + auto-retry on failureTwo directions, two separate Spatie packages. Let's dig in.
Part 1: Receiving Webhooks with laravel-webhook-client
Installation
composer require spatie/laravel-webhook-client
php artisan vendor:publish --provider="Spatie\WebhookClient\WebhookClientServiceProvider" --tag="webhook-client-config"
php artisan migratecomposer require spatie/laravel-webhook-client
php artisan vendor:publish --provider="Spatie\WebhookClient\WebhookClientServiceProvider" --tag="webhook-client-config"
php artisan migrateThe migration creates a webhook_calls table — every incoming webhook gets stored here before processing. This is intentional, and we'll talk about why it matters in the security section.
Configuration
Open config/webhook-client.php. The key parts to configure:
return [
'configs' => [
[
'name' => 'stripe',
// The shared secret between you and the sender
'signing_secret' => env('STRIPE_WEBHOOK_SECRET'),
// Which header carries the signature
'signature_header_name' => 'Stripe-Signature',
// Class responsible for validating the signature
'signature_validator' => \Spatie\WebhookClient\SignatureValidator\DefaultSignatureValidator::class,
// Determines whether a webhook should be processed at all
'webhook_profile' => \Spatie\WebhookClient\WebhookProfile\ProcessEverythingWebhookProfile::class,
// Model used to persist the webhook payload
'webhook_model' => \Spatie\WebhookClient\Models\WebhookCall::class,
// The job that will process the webhook asynchronously
'process_webhook_job' => \App\Jobs\ProcessStripeWebhook::class,
],
],
];return [
'configs' => [
[
'name' => 'stripe',
// The shared secret between you and the sender
'signing_secret' => env('STRIPE_WEBHOOK_SECRET'),
// Which header carries the signature
'signature_header_name' => 'Stripe-Signature',
// Class responsible for validating the signature
'signature_validator' => \Spatie\WebhookClient\SignatureValidator\DefaultSignatureValidator::class,
// Determines whether a webhook should be processed at all
'webhook_profile' => \Spatie\WebhookClient\WebhookProfile\ProcessEverythingWebhookProfile::class,
// Model used to persist the webhook payload
'webhook_model' => \Spatie\WebhookClient\Models\WebhookCall::class,
// The job that will process the webhook asynchronously
'process_webhook_job' => \App\Jobs\ProcessStripeWebhook::class,
],
],
];Got multiple services sending you webhooks (Stripe, GitHub, Midtrans)? Just add another array entry inside
configs. Each one gets its ownname, secret, and job class.
Register the Route
// routes/api.php
use Spatie\WebhookClient\WebhookClientController;
Route::webhooks('/webhooks/stripe', 'stripe');// routes/api.php
use Spatie\WebhookClient\WebhookClientController;
Route::webhooks('/webhooks/stripe', 'stripe');Since external services can't provide a CSRF token, you need to exclude webhook routes from CSRF protection. In Laravel 11+, update bootstrap/app.php:
->withMiddleware(function (Middleware $middleware) {
$middleware->validateCsrfTokens(except: [
'webhooks/*',
]);
})->withMiddleware(function (Middleware $middleware) {
$middleware->validateCsrfTokens(except: [
'webhooks/*',
]);
})On Laravel 10 and below, add to App\Http\Middleware\VerifyCsrfToken:
protected $except = [
'webhooks/*',
];protected $except = [
'webhooks/*',
];Create the Processing Job
php artisan make:job ProcessStripeWebhook
namespace App\Jobs;
use Spatie\WebhookClient\Jobs\ProcessWebhookJob;
class ProcessStripeWebhook extends ProcessWebhookJob
{
public function handle(): void
{
$payload = $this->webhookCall->payload;
$event = $payload['type'] ?? null;
match ($event) {
'payment_intent.succeeded' => $this->handlePaymentSucceeded($payload),
'customer.subscription.deleted' => $this->handleSubscriptionCancelled($payload),
default => logger()->info("Unhandled Stripe event: {$event}"),
};
}
private function handlePaymentSucceeded(array $payload): void
{
$orderId = $payload['data']['object']['metadata']['order_id'] ?? null;
if (!$orderId) {
return;
}
\App\Models\Order::find($orderId)?->markAsPaid();
}
private function handleSubscriptionCancelled(array $payload): void
{
// Handle cancellation logic
}
}php artisan make:job ProcessStripeWebhook
namespace App\Jobs;
use Spatie\WebhookClient\Jobs\ProcessWebhookJob;
class ProcessStripeWebhook extends ProcessWebhookJob
{
public function handle(): void
{
$payload = $this->webhookCall->payload;
$event = $payload['type'] ?? null;
match ($event) {
'payment_intent.succeeded' => $this->handlePaymentSucceeded($payload),
'customer.subscription.deleted' => $this->handleSubscriptionCancelled($payload),
default => logger()->info("Unhandled Stripe event: {$event}"),
};
}
private function handlePaymentSucceeded(array $payload): void
{
$orderId = $payload['data']['object']['metadata']['order_id'] ?? null;
if (!$orderId) {
return;
}
\App\Models\Order::find($orderId)?->markAsPaid();
}
private function handleSubscriptionCancelled(array $payload): void
{
// Handle cancellation logic
}
}Because this class extends ProcessWebhookJob, the stored payload is already available via $this->webhookCall->payload — no need to re-parse the request.
Part 2: Sending Webhooks with laravel-webhook-server
Installation
composer require spatie/laravel-webhook-server
php artisan vendor:publish --provider="Spatie\WebhookServer\WebhookServerServiceProvider"composer require spatie/laravel-webhook-server
php artisan vendor:publish --provider="Spatie\WebhookServer\WebhookServerServiceProvider"Sending Your First Webhook
use Spatie\WebhookServer\WebhookCall;
WebhookCall::create()
->url('https://partner-app.com/webhooks')
->payload([
'event' => 'order.created',
'order' => [
'id' => $order->id,
'total' => $order->total,
'status' => $order->status,
],
])
->signUsingSecret(env('WEBHOOK_SECRET'))
->dispatch();use Spatie\WebhookServer\WebhookCall;
WebhookCall::create()
->url('https://partner-app.com/webhooks')
->payload([
'event' => 'order.created',
'order' => [
'id' => $order->id,
'total' => $order->total,
'status' => $order->status,
],
])
->signUsingSecret(env('WEBHOOK_SECRET'))
->dispatch();That's it for the basics. Under the hood, the package:
- Computes an HMAC-SHA256 signature from the payload
- Attaches it to the
Signatureheader - Dispatches the HTTP request via Laravel's queue (non-blocking)
- Automatically retries on failure
Retry Strategy — Make Your Webhooks Resilient
WebhookCall::create()
->url('https://partner-app.com/webhooks')
->payload($payload)
->signUsingSecret(env('WEBHOOK_SECRET'))
->maximumTries(5)
->useBackoffStrategy(\Spatie\WebhookServer\BackoffStrategy\ExponentialBackoffStrategy::class)
->dispatch();WebhookCall::create()
->url('https://partner-app.com/webhooks')
->payload($payload)
->signUsingSecret(env('WEBHOOK_SECRET'))
->maximumTries(5)
->useBackoffStrategy(\Spatie\WebhookServer\BackoffStrategy\ExponentialBackoffStrategy::class)
->dispatch();Exponential backoff means the gap between retries grows progressively — 10 seconds, then 100 seconds, then potentially hours. This prevents your system from hammering a temporarily unavailable endpoint and giving it room to recover.
Handling Permanent Failures
When all retries are exhausted, Spatie fires a FinalWebhookCallFailedEvent. Listen for it and act accordingly:
// app/Providers/AppServiceProvider.php
use Spatie\WebhookServer\Events\FinalWebhookCallFailedEvent;
Event::listen(FinalWebhookCallFailedEvent::class, function ($event) {
logger()->critical('Webhook permanently failed', [
'url' => $event->webhookUrl,
'payload' => $event->payload,
]);
// Alert your team, queue a manual review, etc.
});// app/Providers/AppServiceProvider.php
use Spatie\WebhookServer\Events\FinalWebhookCallFailedEvent;
Event::listen(FinalWebhookCallFailedEvent::class, function ($event) {
logger()->critical('Webhook permanently failed', [
'url' => $event->webhookUrl,
'payload' => $event->payload,
]);
// Alert your team, queue a manual review, etc.
});Part 3: Security — Don't Skip This
This is the part most tutorials gloss over. Without signature verification, your webhook endpoint is open to anyone who knows the URL. An attacker could send a fake "payment succeeded" event and your system might process it without question.
How HMAC-SHA256 Works
Both packages use the same underlying pattern:
Signature = HMAC-SHA256(json_encode(payload), secret)Signature = HMAC-SHA256(json_encode(payload), secret)The sender computes a signature from the payload and attaches it to the request header. The receiver recomputes the signature from the raw request body and compares it to the header value. If they don't match, the request is rejected immediately.
Here's what that looks like internally:
// What laravel-webhook-server's DefaultSigner does
$payloadJson = json_encode($payload);
$signature = hash_hmac('sha256', $payloadJson, $secret);
// What laravel-webhook-client's DefaultSignatureValidator does
$computedSignature = hash_hmac('sha256', $request->getContent(), $configuredSigningSecret);
// Always use hash_equals - never ===
if (!hash_equals($computedSignature, $headerSignature)) {
// Reject with HTTP 500
}
// What laravel-webhook-server's DefaultSigner does
$payloadJson = json_encode($payload);
$signature = hash_hmac('sha256', $payloadJson, $secret);
// What laravel-webhook-client's DefaultSignatureValidator does
$computedSignature = hash_hmac('sha256', $request->getContent(), $configuredSigningSecret);
// Always use hash_equals - never ===
if (!hash_equals($computedSignature, $headerSignature)) {
// Reject with HTTP 500
}
Why
hash_equalsand not ===? Regular string comparison is vulnerable to timing attacks — an attacker can measure response time to guess the signature character by character.hash_equalstakes the same amount of time regardless of how many characters match, making that attack useless.
Custom Signature Validator — Real-World Example with Stripe
Stripe's signature format includes a timestamp for replay attack protection, which differs from Spatie's default. Here's how to handle it:
namespace App\Webhooks;
use Illuminate\Http\Request;
use Spatie\WebhookClient\SignatureValidator\SignatureValidator;
use Spatie\WebhookClient\WebhookConfig;
class StripeSignatureValidator implements SignatureValidator
{
public function isValid(Request $request, WebhookConfig $config): bool
{
$sigHeader = $request->header('Stripe-Signature');
if (!$sigHeader) {
return false;
}
try {
\Stripe\WebhookSignature::verifyHeader(
$request->getContent(),
$sigHeader,
$config->signingSecret,
300 // 5-minute tolerance window for replay protection
);
return true;
} catch (\Stripe\Exception\SignatureVerificationException $e) {
return false;
}
}
}namespace App\Webhooks;
use Illuminate\Http\Request;
use Spatie\WebhookClient\SignatureValidator\SignatureValidator;
use Spatie\WebhookClient\WebhookConfig;
class StripeSignatureValidator implements SignatureValidator
{
public function isValid(Request $request, WebhookConfig $config): bool
{
$sigHeader = $request->header('Stripe-Signature');
if (!$sigHeader) {
return false;
}
try {
\Stripe\WebhookSignature::verifyHeader(
$request->getContent(),
$sigHeader,
$config->signingSecret,
300 // 5-minute tolerance window for replay protection
);
return true;
} catch (\Stripe\Exception\SignatureVerificationException $e) {
return false;
}
}
}Register it in your config:
'signature_validator' => \App\Webhooks\StripeSignatureValidator::class,'signature_validator' => \App\Webhooks\StripeSignatureValidator::class,Three Rules You Can't Compromise On
Before shipping webhooks to production, make sure you've checked all three:
- Always verify signatures — no exceptions, no matter how small the project
- HTTPS only — sending sensitive payload over plain HTTP means anyone between the two servers can read it
- Store before processing — persist the raw payload to the database before running any business logic; if your job fails, you can requeue from stored data without chasing the sender to resend
Bonus: Webhook Profile — Filter Events Before They Hit the Queue
Not every event from an external service is worth processing. If you're receiving Stripe webhooks but only care about two or three event types, filtering at the profile level is cleaner than branching inside the job:
namespace App\Webhooks;
use Illuminate\Http\Request;
use Spatie\WebhookClient\WebhookProfile\WebhookProfile;
class StripeWebhookProfile implements WebhookProfile
{
public function shouldProcess(Request $request): bool
{
$payload = json_decode($request->getContent(), true);
$eventType = $payload['type'] ?? '';
return in_array($eventType, [
'payment_intent.succeeded',
'payment_intent.payment_failed',
'customer.subscription.deleted',
]);
}
}namespace App\Webhooks;
use Illuminate\Http\Request;
use Spatie\WebhookClient\WebhookProfile\WebhookProfile;
class StripeWebhookProfile implements WebhookProfile
{
public function shouldProcess(Request $request): bool
{
$payload = json_decode($request->getContent(), true);
$eventType = $payload['type'] ?? '';
return in_array($eventType, [
'payment_intent.succeeded',
'payment_intent.payment_failed',
'customer.subscription.deleted',
]);
}
}Register in config:
'webhook_profile' => \App\Webhooks\StripeWebhookProfile::class,'webhook_profile' => \App\Webhooks\StripeWebhookProfile::class,Events outside that list get ignored before they're stored — cleaner logs, less noise in your webhook_calls table, and no unnecessary queue jobs.
Wrapping Up
Webhooks aren't just "HTTP requests someone else sends you." They're the backbone of event-driven architecture — and Laravel has a mature ecosystem to implement them properly.
Here's what we covered:
spatie/laravel-webhook-clientfor receiving webhooks securely and asynchronouslyspatie/laravel-webhook-serverfor sending webhooks with signing, retries, and backoff strategy- HMAC-SHA256 as the core security mechanism — non-negotiable in production
- Custom validators to handle services like Stripe that have their own signature format
- Webhook profiles to filter irrelevant events before they ever touch your queue
One habit worth building: always store the payload before processing. It'll save you the awkward conversation with a third-party support team asking them to resend events because your job threw an exception.