
September 07, 2026
15 min read
By Kokil Thapa | Last reviewed: September 2026
Payment gateways, CRMs, and SaaS platforms push state changes through HTTP callbacks, yet most Laravel apps treat webhooks as thin controller methods that run synchronously and hope for the best. That pattern breaks the moment Stripe retries a checkout.session.completed event or Khalti sends the same payment confirmation twice. To Laravel Webhooks Send and Receive Reliably, you need signature verification, fast HTTP responses, queued processing, idempotency keys, and structured retry logic—the same foundations I use on production apps that integrate with Laravel payment integrations and third-party APIs. This guide walks through receiving inbound callbacks and dispatching outbound events on Laravel 13 with PHP 8.3 or higher, using patterns that survive real traffic spikes and provider retries.
How do you receive webhooks reliably in Laravel?
Inbound webhooks are POST requests from a third party announcing that something changed—a payment succeeded, a shipment updated, a document was signed. Your endpoint must do three things quickly: authenticate the caller, acknowledge receipt, and hand off heavy work to a background worker. Running business logic inside the controller is the most common production mistake I see on client projects.
Step 1: Create a dedicated route outside CSRF protection
Webhook routes must bypass Laravel's CSRF middleware. Register them in routes/api.php or add an exception in bootstrap/app.php (Laravel 13) or app/Http/Middleware/VerifyCsrfToken.php (Laravel 12). Keep paths predictable for provider dashboards but not guessable—/webhooks/stripe is fine; /hook alone is not.
// routes/api.php
use App\Http\Controllers\Webhooks\StripeWebhookController;
Route::post('/webhooks/stripe', StripeWebhookController::class)
->middleware('throttle:webhooks')
->name('webhooks.stripe'); Apply a dedicated rate limiter so a misconfigured provider cannot flood your app. In AppServiceProvider or a service provider:
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;
RateLimiter::for('webhooks', function ($request) {
return Limit::perMinute(120)->by($request->ip());
}); Step 2: Verify, persist, and queue—nothing else
The controller should finish in under two seconds. Read the raw body, verify the signature, insert a row into a webhook_events table, dispatch a job, return 200 OK. All order fulfilment, email sending, and database updates happen inside the job.
// app/Http/Controllers/Webhooks/StripeWebhookController.php
namespace App\Http\Controllers\Webhooks;
use App\Http\Controllers\Controller;
use App\Jobs\ProcessStripeWebhook;
use App\Models\WebhookEvent;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Stripe\Webhook;
class StripeWebhookController extends Controller
{
public function __invoke(Request $request): Response
{
$payload = $request->getContent();
$signature = $request->header('Stripe-Signature');
try {
$event = Webhook::constructEvent(
$payload,
$signature,
config('services.stripe.webhook_secret')
);
} catch (\UnexpectedValueException|\Stripe\Exception\SignatureVerificationException $e) {
return response('Invalid signature', 400);
}
$record = WebhookEvent::firstOrCreate(
[
'provider' => 'stripe',
'external_id' => $event->id,
],
[
'event_type' => $event->type,
'payload' => json_decode($payload, true),
'status' => 'pending',
]
);
if ($record->wasRecentlyCreated) {
ProcessStripeWebhook::dispatch($record)->onQueue('webhooks');
}
return response('OK', 200);
}
} The firstOrCreate on external_id is your first line of idempotency. Stripe assigns a unique event ID per delivery attempt; storing it prevents duplicate job dispatches when the provider retries because your server was slow.
Step 3: Process inside a dedicated job with status tracking
// app/Jobs/ProcessStripeWebhook.php
namespace App\Jobs;
use App\Models\WebhookEvent;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class ProcessStripeWebhook implements ShouldQueue
{
use Queueable;
public int $tries = 5;
public array $backoff = [30, 60, 120, 300, 600];
public function __construct(public WebhookEvent $webhookEvent) {}
public function handle(): void
{
if ($this->webhookEvent->status === 'processed') {
return;
}
match ($this->webhookEvent->event_type) {
'checkout.session.completed' => $this->handleCheckoutCompleted(),
'payment_intent.payment_failed' => $this->handlePaymentFailed(),
default => null,
};
$this->webhookEvent->update([
'status' => 'processed',
'processed_at' => now(),
]);
}
} Run your webhook queue on a dedicated worker so a spike in payment callbacks does not block default queue jobs like password resets. On projects using GitLab CI/CD for Laravel, I configure a separate Supervisor program for queue:work --queue=webhooks.
How do you verify webhook signatures in Laravel?
Signature verification proves the payload came from the provider and was not tampered with in transit. Never trust JSON fields like amount or status until the HMAC check passes. Each provider uses a slightly different scheme, but the Laravel pattern is the same: read the raw request body, combine it with a timestamp header, compute HMAC-SHA256 with your webhook secret, and compare using a timing-safe function.
Stripe-style verification
Stripe signs {timestamp}.{raw_body} with your endpoint secret. The official stripe/stripe-php package exposes Webhook::constructEvent(), which also rejects timestamps older than five minutes—a useful replay-attack guard. See the Stripe webhook signature documentation for the exact header format.
Generic HMAC verification for custom providers
When integrating Nepal payment gateways or internal services without an SDK, implement verification manually:
// app/Services/WebhookSignatureVerifier.php
namespace App\Services;
class WebhookSignatureVerifier
{
public function verify(string $payload, ?string $signatureHeader, string $secret): bool
{
if ($signatureHeader === null) {
return false;
}
$expected = hash_hmac('sha256', $payload, $secret);
return hash_equals($expected, $signatureHeader);
}
public function verifyWithTimestamp(
string $payload,
?string $timestamp,
?string $signature,
string $secret,
int $toleranceSeconds = 300
): bool {
if ($timestamp === null || $signature === null) {
return false;
}
if (abs(time() - (int) $timestamp) > $toleranceSeconds) {
return false;
}
$signedPayload = $timestamp . '.' . $payload;
$expected = hash_hmac('sha256', $signedPayload, $secret);
return hash_equals($expected, $signature);
}
} On a legal-tech portal with document-status callbacks, I store separate secrets per environment in .env and rotate them through the provider dashboard without code changes—only config updates and a deploy. For Khalti-specific patterns, see the dedicated Khalti integration guide for Laravel apps.
Middleware approach for multiple providers
If you receive webhooks from several sources, extract verification into middleware:
// app/Http/Middleware/VerifyWebhookSignature.php
public function handle(Request $request, Closure $next, string $provider): Response
{
$verifier = app(WebhookSignatureVerifier::class);
$config = config("webhooks.providers.{$provider}");
$valid = $verifier->verifyWithTimestamp(
$request->getContent(),
$request->header($config['timestamp_header']),
$request->header($config['signature_header']),
$config['secret']
);
if (! $valid) {
abort(401, 'Invalid webhook signature');
}
return $next($request);
} Register the middleware alias in bootstrap/app.php and attach it to routes: ->middleware('verify.webhook:stripe').
How do you send outbound webhooks from a Laravel application?
When your Laravel app is the source of truth and external systems need to react—CRM updates after a booking on Adventure Third Pole Trek, or partner notifications from an eCommerce platform—you become the webhook sender. Reliability here means signed payloads, persisted delivery attempts, exponential backoff, and a dead-letter state when all retries fail.
Model outbound subscriptions and deliveries
Store subscriber endpoints in a webhook_subscriptions table with URL, secret, enabled events, and active flag. Each delivery attempt gets its own row in webhook_deliveries with HTTP status, response body snippet, and attempt count. This mirrors patterns from webhook design patterns for reliability and keeps audit trails for disputes.
// database/migrations/xxxx_create_webhook_subscriptions_table.php
Schema::create('webhook_subscriptions', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('url');
$table->string('secret');
$table->json('events');
$table->boolean('is_active')->default(true);
$table->timestamps();
});
Schema::create('webhook_deliveries', function (Blueprint $table) {
$table->id();
$table->foreignId('webhook_subscription_id')->constrained();
$table->string('event_type');
$table->uuid('delivery_id')->unique();
$table->json('payload');
$table->unsignedSmallInteger('attempts')->default(0);
$table->unsignedSmallInteger('http_status')->nullable();
$table->string('status')->default('pending');
$table->timestamp('next_retry_at')->nullable();
$table->timestamps();
}); Follow database migration best practices and add a composite index on (status, next_retry_at) for the retry scheduler.
Dispatch delivery through a queued job
// app/Jobs/DeliverWebhook.php
namespace App\Jobs;
use App\Models\WebhookDelivery;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Http;
class DeliverWebhook implements ShouldQueue
{
use Queueable;
public int $tries = 1;
public function __construct(public WebhookDelivery $delivery) {}
public function handle(): void
{
$subscription = $this->delivery->subscription;
$payload = json_encode($this->delivery->payload);
$timestamp = time();
$signature = hash_hmac('sha256', "{$timestamp}.{$payload}", $subscription->secret);
$response = Http::timeout(10)
->withHeaders([
'Content-Type' => 'application/json',
'X-Webhook-Id' => $this->delivery->delivery_id,
'X-Webhook-Timestamp' => $timestamp,
'X-Webhook-Signature' => $signature,
])
->post($subscription->url, $this->delivery->payload);
$this->delivery->increment('attempts');
$this->delivery->update(['http_status' => $response->status()]);
if ($response->successful()) {
$this->delivery->update(['status' => 'delivered']);
return;
}
$this->scheduleRetry();
}
private function scheduleRetry(): void
{
$attempts = $this->delivery->attempts;
$backoff = [60, 300, 900, 3600, 14400];
$delay = $backoff[min($attempts - 1, count($backoff) - 1)];
if ($attempts >= 5) {
$this->delivery->update(['status' => 'failed']);
return;
}
$this->delivery->update([
'status' => 'retrying',
'next_retry_at' => now()->addSeconds($delay),
]);
DeliverWebhook::dispatch($this->delivery)
->delay(now()->addSeconds($delay));
}
} Laravel's HTTP client wraps Guzzle and supports timeouts, retries at the transport level, and fake responses in tests. The official Laravel HTTP client documentation covers fakes and response assertions. I prefer application-level retry logic in the job over Http::retry() alone because you need persisted attempt counts visible to subscribers.
Fire webhooks from domain events
Decouple webhook dispatch from controllers by listening to Eloquent events or Laravel's event system:
// app/Listeners/DispatchOrderWebhooks.php
public function handle(OrderPlaced $event): void
{
$subscriptions = WebhookSubscription::query()
->where('is_active', true)
->whereJsonContains('events', 'order.placed')
->get();
foreach ($subscriptions as $subscription) {
$delivery = WebhookDelivery::create([
'webhook_subscription_id' => $subscription->id,
'event_type' => 'order.placed',
'delivery_id' => Str::uuid(),
'payload' => OrderWebhookResource::make($event->order)->resolve(),
]);
DeliverWebhook::dispatch($delivery)->onQueue('webhooks-outbound');
}
} This aligns with modern Laravel architecture where side effects leave the request cycle immediately. On Nepal Gift Card, gift-card redemption events trigger partner callbacks through exactly this listener pattern.
What is the best approach for webhook idempotency and duplicate prevention?
Providers retry when they do not receive a timely 2xx response. Your app may also retry outbound deliveries. Without idempotency, the same payment posts twice, the same booking email sends three times, and ledger balances drift. Treat every webhook as "at-least-once delivery" and make handlers safe to run multiple times.
| Strategy | Best for | Implementation | Trade-off |
|---|---|---|---|
| Provider event ID | Inbound webhooks (Stripe, PayPal) | Unique index on (provider, external_id) | Requires provider-supplied ID |
| Idempotency-Key header | Outbound POST from your app | UUID per delivery in X-Webhook-Id | Subscriber must deduplicate |
| Business-key lock | Domain events (order paid) | firstOrCreate on order_id + event_type | Must choose key carefully |
| Database transaction + status | State transitions | Update only if status = pending | Race conditions need row locks |
Inbound idempotency with database constraints
// migration
$table->unique(['provider', 'external_id']);
// job handler
DB::transaction(function () use ($event) {
$payment = Payment::lockForUpdate()->find($event['payment_id']);
if ($payment->status === 'paid') {
return;
}
$payment->update(['status' => 'paid']);
OrderFulfillmentJob::dispatch($payment->order);
}); For PostgreSQL-backed apps, partial unique indexes add extra safety—see PostgreSQL for Laravel developers for index strategies that complement webhook tables.
Outbound idempotency contract
Document that subscribers must store X-Webhook-Id and reject duplicates. Include the delivery UUID inside the JSON body as well so logs correlate even if headers are stripped by a proxy. When building public APIs alongside webhooks, keep versioning consistent—Laravel API versioning strategy applies to webhook payload schemas too.
How do you test and monitor Laravel webhooks in production?
Webhook bugs surface at 2 AM when a provider rotates signing secrets or your queue worker dies silently. Testing and observability are not optional extras—they are part of the integration contract.
Local testing with Stripe CLI and HTTP fakes
- Install Stripe CLI and run
stripe listen --forward-to localhost:8000/api/webhooks/stripeto receive real sandbox events locally. - Use
Http::fake()in PHPUnit/Pest to assert outbound delivery headers and payloads without hitting real URLs. - Validate JSON payloads against your schema using the JSON formatter tool during manual QA before shipping payload changes.
- Replay production webhook rows from
webhook_eventsthrough artisan commands in staging—never replay against live subscriber endpoints without coordination.
// tests/Feature/StripeWebhookTest.php
it('queues job for valid stripe webhook', function () {
Queue::fake();
$payload = file_get_contents(base_path('tests/fixtures/stripe/checkout.completed.json'));
$secret = config('services.stripe.webhook_secret');
$timestamp = time();
$signature = hash_hmac('sha256', "{$timestamp}.{$payload}", $secret);
$response = $this->postJson('/api/webhooks/stripe', json_decode($payload, true), [
'Stripe-Signature' => "t={$timestamp},v1={$signature}",
]);
$response->assertOk();
Queue::assertPushed(ProcessStripeWebhook::class);
}); Production monitoring checklist
- Alert when
webhook_eventsrows staypendinglonger than five minutes—usually a dead queue worker. - Track failed outbound deliveries in a dashboard; expose retry buttons for admin staff on client portals like Mijar Law Associates.
- Log signature failures separately from application exceptions—they often indicate secret rotation, not code bugs.
- Schedule a nightly artisan command to purge processed inbound events older than 90 days and keep MySQL 9.7 or MariaDB 12.3 tables lean.
- Run webhook queue workers under Supervisor with
--max-time=3600to prevent memory leaks on long-running PHP 8.5 processes.
Redis 8.10 as your queue backend gives you visibility through queue:monitor and Horizon if you run it. For apps already using Redis for cache, dedicating a separate queue connection prevents webhook backlogs from evicting hot cache keys. Authentication for webhook management APIs belongs behind Sanctum or Passport—compare approaches in Laravel Passport vs Sanctum.
If you expose webhook subscription management to customers, apply the same authorization rigour as any RESTful API built with Laravel. Document payload schemas with Scribe or OpenAPI so integrators know exactly which fields arrive in each event type. For deeper API design context, read Laravel API best practices and consider professional API development services when webhook infrastructure becomes a product feature rather than a one-off integration.
Key Takeaways
- Return HTTP 200 within two seconds on inbound webhooks; verify signatures on the raw body before parsing JSON.
- Store provider event IDs with a unique database constraint so retries never dispatch duplicate jobs.
- Send outbound webhooks through queued jobs with signed payloads, persisted delivery rows, and exponential backoff capped at five attempts.
- Use
hash_equals, timestamp tolerance windows, and row-level locks inside transactions for idempotent business logic. - Run dedicated queue workers for webhook traffic and alert on rows stuck in
pendingstatus. - Test with provider CLI tools and
Http::fake(); never deploy webhook changes without a staging replay pass.
People Also Ask
Should Laravel webhook routes use API or web middleware?
Use API routes or explicitly disable CSRF for webhook paths. Web middleware applies session and CSRF protection that legitimate provider POST requests cannot satisfy. Throttling and signature middleware are appropriate; session middleware is not.
What HTTP status code should a Laravel webhook endpoint return?
Return 200 OK or 204 No Content after the event is persisted and queued. Return 401 for invalid signatures and 400 for malformed payloads. Avoid 500 for business-logic failures inside queued jobs—the provider already received acknowledgment.
How long should webhook payload data be retained?
Keep inbound payloads 30–90 days for debugging and dispute resolution, then archive or purge. Legal and financial apps may need longer retention aligned with audit requirements. Store only fields you need; strip card numbers and PII from logged payloads.
Can Laravel Horizon manage webhook queue workers?
Yes. Configure separate supervisors for webhooks and webhooks-outbound queues in config/horizon.php with appropriate maxProcesses and timeout values. Horizon gives you throughput graphs and failed-job retry UI that plain queue:work lacks.
Ship webhook infrastructure you can trust
Reliable webhooks are not a controller method and a prayer—they are verified ingress, fast acknowledgments, queued processing, idempotent handlers, and signed egress with logged retries. On Laravel 13 with PHP 8.3+, Redis queues, and the patterns above, you can integrate payment gateways, CRMs, and partner systems without duplicate charges or silent delivery failures. If you need help wiring inbound Stripe or Khalti callbacks, building outbound subscription APIs, or hardening an existing integration, contact us for a scoped review—or explore custom software development for full webhook platform work. For related reading, see SEO setup for Laravel sites when webhook-driven pages affect indexation, and review the about page for production integration experience across Nepal and international client projects.
Frequently Asked Questions
0 Comments
Leave a comment
Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

