Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

Laravel Webhooks Send and Receive Reliably

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.

Inbound Laravel Webhook FlowProviderStripe / KhaltiLaravel RoutePOST /webhooks/*Verify SigHMAC checkHTTP 200Under 2 secwebhook_eventsIdempotency rowQueue Jobwebhooks queueBusiness Logic HandlerOrders, emails, ledger updates
Inbound Laravel webhook architecture: verify signature, persist event, respond fast, process asynchronously

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').

Signature Verification DecisionRaw POST Body ReceivedMissing Header?Return 401Stale Timestamp?Return 401Bad HMAC?Return 401Signature ValidPersist and queue jobUse hash_equals — never ==
Webhook signature verification decision flow before any business logic executes

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.

Outbound Webhook Delivery PipelineDomain Eventorder.placedCreate Rowwebhook_deliveriesSign PayloadHMAC-SHA256HTTP POSTSubscriber URL2xx ResponseStatus: delivered4xx / 5xxBackoff retry5 FailuresStatus: failedafter 5 triesAdmin Replay + Subscriber DashboardManual retry for failed deliveries
Outbound Laravel webhook pipeline with exponential backoff and dead-letter handling after five failed attempts

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.

StrategyBest forImplementationTrade-off
Provider event IDInbound webhooks (Stripe, PayPal)Unique index on (provider, external_id)Requires provider-supplied ID
Idempotency-Key headerOutbound POST from your appUUID per delivery in X-Webhook-IdSubscriber must deduplicate
Business-key lockDomain events (order paid)firstOrCreate on order_id + event_typeMust choose key carefully
Database transaction + statusState transitionsUpdate only if status = pendingRace 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

  1. Install Stripe CLI and run stripe listen --forward-to localhost:8000/api/webhooks/stripe to receive real sandbox events locally.
  2. Use Http::fake() in PHPUnit/Pest to assert outbound delivery headers and payloads without hitting real URLs.
  3. Validate JSON payloads against your schema using the JSON formatter tool during manual QA before shipping payload changes.
  4. Replay production webhook rows from webhook_events through 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_events rows stay pending longer 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=3600 to prevent memory leaks on long-running PHP 8.5 processes.
Sync vs Queued Webhook HandlingSynchronous (Avoid)Queued (Recommended)Provider timeoutRetries = duplicatesFast 200 responseProvider satisfiedDB lock during requestBlocks PHP-FPM workersIsolated queue workerScales independentlyUncaught exceptionReturns 500 to providerJob retry with backoffAutomatic recoveryNo audit trailHard to debugPersisted event rowsFull replay capability
Synchronous webhook handling versus queued processing — why Laravel Webhooks Send and Receive Reliably only with async jobs

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 pending status.
  • 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

Verify every inbound signature, return HTTP 200 within seconds via a queued job, store idempotency keys to block duplicates, and send outbound webhooks through Laravel's queue with exponential backoff, delivery logs, and signed payloads.

Register a dedicated POST route outside CSRF protection, apply a webhook-specific rate limiter, and keep the controller thin. Read the raw body, verify the provider signature, persist the event to a webhook_events table, dispatch a background job, and return HTTP 200 immediately. On production Laravel apps I maintain, all order fulfilment, emails, and database updates run inside ProcessStripeWebhook or equivalent jobs—not in the controller. Providers like Stripe and Khalti retry when responses are slow; synchronous handlers are the main reason duplicate charges and missed callbacks appear after traffic spikes.

Yes. Inbound webhooks are server-to-server POST requests with no CSRF token, so Laravel will reject them if VerifyCsrfToken applies. Register routes in routes/api.php, which excludes CSRF by default, or add an exception in bootstrap/app.php on Laravel 13 or app/Http/Middleware/VerifyCsrfToken.php on Laravel 12. Keep paths identifiable for provider dashboards—/webhooks/stripe works—but avoid overly generic URLs like /hook alone. Pair CSRF bypass with signature verification and rate limiting; never treat an open POST endpoint as trusted just because CSRF is off.

Payment gateways and SaaS platforms retry when they do not get a timely 2xx response. Running fulfilment, email, or ledger updates synchronously often pushes response times past provider timeouts, triggering duplicate deliveries. The article targets under two seconds in the controller: verify, persist, dispatch, return OK. Jobs like ProcessStripeWebhook carry the real work with configurable tries and backoff arrays such as 30, 60, 120, 300, and 600 seconds. I run webhook queues on dedicated Supervisor workers so a Stripe spike does not block password resets or other default-queue jobs.

Return HTTP 200 within seconds—under two seconds in the controller. Queue everything else.

Never trust JSON fields like amount or status until HMAC verification passes. Read the raw request body with getContent(), not parsed input, because re-encoding can break signatures. Compute HMAC-SHA256 with your webhook secret and compare using hash_equals for timing-safe equality. For Stripe, use Webhook::constructEvent() from stripe/stripe-php with your endpoint secret—it validates the Stripe-Signature header and rejects timestamps older than five minutes. For custom or Nepal payment gateways without an SDK, a WebhookSignatureVerifier service combining timestamp plus raw body works well. Store secrets per environment in .env and rotate through the provider dashboard.

Stripe signs the string {timestamp}.{raw_body} with your endpoint secret. The controller passes the raw payload, the Stripe-Signature header, and config('services.stripe.webhook_secret') into Webhook::constructEvent(). Invalid JSON or bad signatures throw UnexpectedValueException or SignatureVerificationException—return HTTP 400, not 200, so Stripe knows verification failed rather than assuming success. The built-in five-minute timestamp tolerance blocks simple replay attacks. After verification, persist the Stripe event ID before dispatching work; that ID is your idempotency anchor when Stripe retries checkout.session.completed or payment_intent.payment_failed events.

Webhook delivery is at-least-once, not exactly-once. Stripe retries when your server is slow; Khalti may send the same payment confirmation twice; your own outbound job may redispatch after a timeout. Without idempotency, the same payment posts twice, booking emails send three times, and ledger balances drift. Treat every handler as safe to run multiple times. The article outlines four strategies: provider event IDs with a unique index on provider plus external_id, X-Webhook-Id for outbound deliveries, business-key locks on domain events, and status-gated database updates that only transition from pending. Pick the key that matches your provider and business object.

Use firstOrCreate on provider plus external_id in your webhook_events table before dispatching a job. Stripe assigns a unique event ID per delivery attempt; if your server was slow and Stripe retries, wasRecentlyCreated stays false and you skip dispatching a second ProcessStripeWebhook job. Inside the job, check status === processed and return early if already handled. Add a unique database index on provider and external_id so race conditions cannot insert duplicates. This is your first idempotency line; combine it with business-key locks—firstOrCreate on order_id plus event_type—for domain actions like marking an order paid exactly once.

When your app is the source of truth—booking updates on Adventure Third Pole Trek or partner notifications from Nepal Gift Card—you need persisted subscriptions and delivery attempts. Store subscriber URLs, secrets, enabled events, and active flags in webhook_subscriptions. Each attempt gets a row in webhook_deliveries with event_type, a UUID delivery_id, payload JSON, attempt count, HTTP status, and status field. The DeliverWebhook job signs the payload with hash_hmac over timestamp dot payload, sends headers X-Webhook-Id, X-Webhook-Timestamp, and X-Webhook-Signature, and uses Http::timeout(10). Fire deliveries from domain event listeners like DispatchOrderWebhooks so controllers stay clean.

Application-level retry logic beats Http::retry() alone because subscribers need visible attempt counts and audit trails. DeliverWebhook sets tries to 1 per dispatch and manages retries manually. On failure, scheduleRetry uses backoff delays of 60, 300, 900, 3600, and 14400 seconds based on attempt number. Update status to retrying with next_retry_at, then redispatch DeliverWebhook with delay. Persist http_status and increment attempts after each call. Add a composite index on status and next_retry_at for your retry scheduler. Inbound jobs can use Laravel's built-in backoff property—ProcessStripeWebhook uses 30 through 600 seconds across five tries—while outbound needs finer control over dead-letter timing.

The delivery status updates to failed—a dead-letter state—and no further automatic retries run.

Yes. Route inbound jobs to a webhooks queue and outbound to webhooks-outbound via onQueue() when dispatching. Configure a separate Supervisor program running queue:work --queue=webhooks so payment callback spikes do not starve default-queue jobs. On GitLab CI/CD Laravel deployments I maintain, this separation is standard—same pattern as sister sites on shared EC2 infrastructure. ProcessStripeWebhook and DeliverWebhook both implement ShouldQueue. Without isolation, a burst of Stripe checkout.session.completed events during a sale can delay password resets, report generation, and other background work your users notice immediately.

Extract verification into reusable middleware rather than duplicating logic per controller. A VerifyWebhookSignature middleware accepts a provider parameter, loads timestamp and signature header names plus secret from config("webhooks.providers.{$provider}"), and calls WebhookSignatureVerifier verifyWithTimestamp on the raw body. Register the alias in bootstrap/app.php and attach verify.webhook:stripe or verify.webhook:khalti to routes. Invalid signatures abort with 401 before any controller logic runs. Store separate secrets per environment in .env and rotate through each provider dashboard without code changes—only config updates and deploy. This scales cleanly when one Laravel app receives Stripe, Khalti, and internal service callbacks.

Apply a dedicated rate limiter so a misconfigured provider cannot flood your application. Register RateLimiter::for('webhooks') returning Limit::perMinute(120)->by($request->ip()) in AppServiceProvider or a service provider, then attach throttle:webhooks middleware to webhook routes alongside signature verification. One hundred twenty requests per minute per IP is generous for legitimate Stripe or payment-gateway retry bursts but blocks runaway misconfiguration or abuse. Rate limiting complements—not replaces—signature verification and idempotency checks. Without it, an attacker probing /webhooks/stripe or a broken integration loop can consume PHP-FPM workers even when every payload fails HMAC validation.

Share this article

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.

Quick Contact Options
Choose how you want to connect me: