Webhook Handling Patterns in Laravel: Receive, Verify, Process

· 8 min read

Webhooks are HTTP callbacks — external services POST data to your app when something happens. Stripe sends payment confirmations. GitHub sends push events. Shopify sends order updates.

Handling them wrong leads to lost data, duplicate processing, and security vulnerabilities. Here's how to do it right in Laravel.

The Three Rules of Webhook Handling

  1. Verify — Confirm the request actually came from the expected sender
  2. Acknowledge — Return 200 immediately, before processing
  3. Process — Handle the payload asynchronously in a queue job
External Service → POST /webhook → Verify Signature → Return 200 → Queue Job → Process

If you process synchronously and your code takes 30 seconds, the sender will retry — creating duplicates.

Basic Webhook Controller

// app/Http/Controllers/Webhooks/StripeWebhookController.php

namespace App\Http\Controllers\Webhooks;

use Illuminate\Http\Request;
use Illuminate\Http\Response;
use App\Jobs\ProcessStripeWebhook;

class StripeWebhookController extends Controller
{
    public function handle(Request $request): Response
    {
        // Step 1: Verify signature
        $this->verifySignature($request);

        // Step 2: Dispatch to queue
        ProcessStripeWebhook::dispatch(
            payload: $request->all(),
            eventType: $request->input('type'),
        );

        // Step 3: Acknowledge immediately
        return response('OK', 200);
    }

    private function verifySignature(Request $request): void
    {
        $signature = $request->header('Stripe-Signature');
        $payload = $request->getContent();
        $secret = config('services.stripe.webhook_secret');

        try {
            \Stripe\Webhook::constructEvent($payload, $signature, $secret);
        } catch (\Exception $e) {
            abort(403, 'Invalid signature');
        }
    }
}

Route & Middleware

// routes/web.php
Route::post('/webhooks/stripe', [StripeWebhookController::class, 'handle'])
    ->name('webhooks.stripe')
    ->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]);

Critical: Exclude webhook routes from CSRF protection. External services can't provide CSRF tokens.

Signature Verification Patterns

HMAC-SHA256 (Most Common: Stripe, Shopify, GitHub)

class WebhookSignatureVerifier
{
    public static function verify(
        string $payload,
        string $signature,
        string $secret,
        string $algorithm = 'sha256',
    ): bool {
        $expected = hash_hmac($algorithm, $payload, $secret);

        return hash_equals($expected, $signature);
    }
}

GitHub Signature

private function verifyGitHubSignature(Request $request): void
{
    $signature = $request->header('X-Hub-Signature-256');
    $payload = $request->getContent();
    $secret = config('services.github.webhook_secret');

    $expected = 'sha256=' . hash_hmac('sha256', $payload, $secret);

    if (!hash_equals($expected, $signature)) {
        abort(403, 'Invalid GitHub signature');
    }
}

Timestamp Validation (Prevent Replay Attacks)

Stripe includes a timestamp in the signature. Verify the webhook is recent:

private function verifyTimestamp(Request $request): void
{
    $signatureHeader = $request->header('Stripe-Signature');
    preg_match('/t=(\d+)/', $signatureHeader, $matches);

    $timestamp = (int) ($matches[1] ?? 0);
    $tolerance = 300; // 5 minutes

    if (abs(time() - $timestamp) > $tolerance) {
        abort(403, 'Webhook timestamp too old');
    }
}

Middleware Approach (Reusable)

// app/Http/Middleware/VerifyWebhookSignature.php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;

class VerifyWebhookSignature
{
    public function handle(Request $request, Closure $next, string $provider): mixed
    {
        $verifier = match ($provider) {
            'stripe' => $this->verifyStripe(...),
            'github' => $this->verifyGitHub(...),
            'shopify' => $this->verifyShopify(...),
            default => throw new \InvalidArgumentException("Unknown provider: $provider"),
        };

        $verifier($request);

        return $next($request);
    }

    private function verifyStripe(Request $request): void
    {
        $secret = config('services.stripe.webhook_secret');
        \Stripe\Webhook::constructEvent(
            $request->getContent(),
            $request->header('Stripe-Signature'),
            $secret,
        );
    }

    private function verifyGitHub(Request $request): void
    {
        $signature = $request->header('X-Hub-Signature-256');
        $payload = $request->getContent();
        $secret = config('services.github.webhook_secret');

        $expected = 'sha256=' . hash_hmac('sha256', $payload, $secret);

        if (!hash_equals($expected, $signature ?? '')) {
            abort(403);
        }
    }

    private function verifyShopify(Request $request): void
    {
        $hmac = $request->header('X-Shopify-Hmac-SHA256');
        $payload = $request->getContent();
        $secret = config('services.shopify.webhook_secret');

        $calculated = base64_encode(hash_hmac('sha256', $payload, $secret, true));

        if (!hash_equals($calculated, $hmac ?? '')) {
            abort(403);
        }
    }
}
// routes/web.php
Route::post('/webhooks/stripe', StripeWebhookController::class)
    ->middleware('verify.webhook:stripe');

Route::post('/webhooks/github', GitHubWebhookController::class)
    ->middleware('verify.webhook:github');

Idempotency: Handling Duplicate Webhooks

Webhook senders retry on failure (or even on success sometimes). You MUST handle duplicates.

Strategy 1: Track Processed Event IDs

// Migration
Schema::create('processed_webhooks', function (Blueprint $table) {
    $table->id();
    $table->string('provider');
    $table->string('event_id')->index();
    $table->string('event_type');
    $table->timestamp('processed_at');
    $table->unique(['provider', 'event_id']);
});
// app/Jobs/ProcessStripeWebhook.php

class ProcessStripeWebhook implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 3;
    public array $backoff = [10, 60, 300];

    public function __construct(
        private array $payload,
        private string $eventType,
    ) {}

    public function handle(): void
    {
        $eventId = $this->payload['id'];

        // Idempotency check
        $alreadyProcessed = ProcessedWebhook::where('provider', 'stripe')
            ->where('event_id', $eventId)
            ->exists();

        if ($alreadyProcessed) {
            return; // Skip duplicate
        }

        // Process based on event type
        match ($this->eventType) {
            'payment_intent.succeeded' => $this->handlePaymentSuccess(),
            'customer.subscription.created' => $this->handleSubscriptionCreated(),
            'customer.subscription.deleted' => $this->handleSubscriptionCanceled(),
            'invoice.payment_failed' => $this->handlePaymentFailed(),
            default => null, // Ignore unknown events
        };

        // Mark as processed
        ProcessedWebhook::create([
            'provider' => 'stripe',
            'event_id' => $eventId,
            'event_type' => $this->eventType,
            'processed_at' => now(),
        ]);
    }

    private function handlePaymentSuccess(): void
    {
        $paymentIntent = $this->payload['data']['object'];

        $order = Order::where('payment_intent_id', $paymentIntent['id'])->first();

        if (!$order) {
            return;
        }

        $order->update(['status' => 'paid', 'paid_at' => now()]);

        $order->user->notify(new PaymentConfirmation($order));
    }

    private function handleSubscriptionCreated(): void
    {
        $subscription = $this->payload['data']['object'];

        $user = User::where('stripe_customer_id', $subscription['customer'])->first();

        $user?->subscription()->create([
            'stripe_subscription_id' => $subscription['id'],
            'plan' => $subscription['items']['data'][0]['price']['id'],
            'status' => $subscription['status'],
            'current_period_end' => Carbon::createFromTimestamp($subscription['current_period_end']),
        ]);
    }

    private function handleSubscriptionCanceled(): void
    {
        Subscription::where('stripe_subscription_id', $this->payload['data']['object']['id'])
            ->update(['status' => 'canceled', 'canceled_at' => now()]);
    }

    private function handlePaymentFailed(): void
    {
        $invoice = $this->payload['data']['object'];
        $user = User::where('stripe_customer_id', $invoice['customer'])->first();
        $user?->notify(new PaymentFailedNotification($invoice));
    }
}

Strategy 2: Database Transactions with Unique Constraints

public function handle(): void
{
    DB::transaction(function () {
        // This will fail on duplicate, which is fine
        ProcessedWebhook::create([
            'provider' => 'stripe',
            'event_id' => $this->payload['id'],
            'event_type' => $this->eventType,
            'processed_at' => now(),
        ]);

        $this->processEvent();
    });
}

Logging & Monitoring

Always log webhook payloads for debugging:

class StripeWebhookController extends Controller
{
    public function handle(Request $request): Response
    {
        $this->verifySignature($request);

        Log::channel('webhooks')->info('Stripe webhook received', [
            'type' => $request->input('type'),
            'id' => $request->input('id'),
            'ip' => $request->ip(),
        ]);

        ProcessStripeWebhook::dispatch(
            payload: $request->all(),
            eventType: $request->input('type'),
        );

        return response('OK', 200);
    }
}
// config/logging.php
'webhooks' => [
    'driver' => 'daily',
    'path' => storage_path('logs/webhooks.log'),
    'days' => 30,
],

Spatie's Webhook Client Package

For a battle-tested solution:

composer require spatie/laravel-webhook-client
// config/webhook-client.php
return [
    'configs' => [
        [
            'name' => 'stripe',
            'signing_secret' => env('STRIPE_WEBHOOK_SECRET'),
            'signature_header_name' => 'Stripe-Signature',
            'signature_validator' => \App\Webhooks\StripeSignatureValidator::class,
            'webhook_profile' => \Spatie\WebhookClient\WebhookProfile\ProcessEverythingWebhookProfile::class,
            'webhook_model' => \Spatie\WebhookClient\Models\WebhookCall::class,
            'process_webhook_job' => \App\Jobs\ProcessStripeWebhookJob::class,
        ],
    ],
];

It handles storage, idempotency, and job dispatch automatically.

Testing Webhooks

class StripeWebhookTest extends TestCase
{
    public function test_valid_payment_webhook_is_processed(): void
    {
        $payload = $this->createStripePayload('payment_intent.succeeded');

        $this->postJson('/webhooks/stripe', $payload, [
            'Stripe-Signature' => $this->generateSignature($payload),
        ])->assertOk();

        Queue::assertPushed(ProcessStripeWebhook::class, function ($job) {
            return $job->eventType === 'payment_intent.succeeded';
        });
    }

    public function test_invalid_signature_returns_403(): void
    {
        $this->postJson('/webhooks/stripe', ['data' => 'test'], [
            'Stripe-Signature' => 'invalid',
        ])->assertForbidden();
    }

    public function test_duplicate_webhook_is_skipped(): void
    {
        ProcessedWebhook::create([
            'provider' => 'stripe',
            'event_id' => 'evt_123',
            'event_type' => 'payment_intent.succeeded',
            'processed_at' => now(),
        ]);

        $job = new ProcessStripeWebhook(
            payload: ['id' => 'evt_123', 'type' => 'payment_intent.succeeded'],
            eventType: 'payment_intent.succeeded',
        );

        $job->handle();

        // Order should NOT be updated since it's a duplicate
        $this->assertDatabaseMissing('orders', ['status' => 'paid']);
    }

    private function generateSignature(array $payload): string
    {
        $timestamp = time();
        $payloadString = json_encode($payload);
        $signedPayload = "{$timestamp}.{$payloadString}";
        $signature = hash_hmac('sha256', $signedPayload, config('services.stripe.webhook_secret'));

        return "t={$timestamp},v1={$signature}";
    }
}

Monitoring & Alerting

Health Check

Route::get('/health/webhooks', function () {
    $lastProcessed = ProcessedWebhook::latest('processed_at')->first();

    if (!$lastProcessed || $lastProcessed->processed_at->diffInHours(now()) > 24) {
        return response()->json(['status' => 'warning', 'message' => 'No webhooks in 24h'], 503);
    }

    $failedCount = WebhookLog::where('status', 'failed')
        ->where('created_at', '>', now()->subHour())
        ->count();

    if ($failedCount > 10) {
        return response()->json(['status' => 'critical', 'failed_count' => $failedCount], 503);
    }

    return response()->json(['status' => 'healthy']);
});

Security Checklist

✅ Verify signature on EVERY webhook request
✅ Use hash_equals() (timing-attack safe)
✅ Log signature failures (detect attacks)
✅ Rate limit webhook endpoints
✅ Validate payload schema before processing
✅ Store webhook secrets in environment variables
✅ Rotate secrets periodically
✅ Never trust payload implicitly — validate everything
✅ IP allowlisting (if provider supports it)

Conclusion

Webhook handling is critical infrastructure. Get it wrong and you lose payments, miss events, or process things twice.

Production checklist:

  1. Verify signatures — first line of defense
  2. Return 200 immediately — before processing, avoid timeout retries
  3. Process in background jobs — queue for async, automatic retries
  4. Implement idempotency — track event IDs, use insertOrIgnore or unique constraints
  5. Log everything — raw payloads, processing results, failures
  6. Replay capability — when fixing bugs, replay failed webhooks
  7. Monitor — alert when failure rate spikes
  8. Test thoroughly — valid/invalid signatures, duplicates, edge cases

Comments