Try Live
Add Docs
Rankings
Pricing
Docs
Install
Install
Docs
Pricing
More...
More...
Try Live
Rankings
Enterprise
Create API Key
Add Docs
Laravel Webhook Client
https://github.com/spatie/laravel-webhook-client
Admin
A Laravel package that facilitates receiving and processing incoming webhooks, with support for
...
Tokens:
11,326
Snippets:
42
Trust Score:
8.5
Update:
4 months ago
Context
Skills
Chat
Benchmark
85.3
Suggestions
Latest
Show doc for...
Code
Info
Show Results
Context Summary (auto-generated)
Raw
Copy
Link
# Laravel Webhook Client ## Introduction Laravel Webhook Client is a comprehensive package for receiving and processing webhooks in Laravel applications. It provides a robust infrastructure for securely accepting webhook requests from external services, verifying their authenticity through signature validation, and processing them asynchronously through Laravel's queue system. The package handles the complete webhook lifecycle from receiving the HTTP request to storing the payload in the database and dispatching queued jobs for processing. This package is designed with flexibility and security in mind, supporting multiple webhook endpoints with different configurations, customizable signature validation mechanisms, and selective webhook filtering through profiles. It automatically stores webhook payloads in the database for auditing and debugging purposes, supports automatic cleanup of old webhook records, and provides extensible interfaces for customizing every aspect of webhook handling. Whether you're integrating with payment providers, third-party APIs, or building webhook-driven architectures, this package provides all the tools needed for reliable webhook processing. ## APIs and Key Functions ### Webhook Route Registration Register webhook endpoints in your Laravel routes file to accept incoming webhook requests. ```php use Illuminate\Support\Facades\Route; // Basic webhook route - uses 'default' config Route::webhooks('api/webhooks/stripe'); // Named webhook configuration Route::webhooks('api/webhooks/github', 'github-webhooks'); // Different HTTP methods Route::webhooks('api/webhooks/custom', 'custom-service', 'put'); Route::webhooks('api/webhooks/get-hook', 'get-service', 'get'); // Multiple webhooks for different services Route::webhooks('api/webhooks/stripe', 'stripe-webhooks'); Route::webhooks('api/webhooks/github', 'github-webhooks'); Route::webhooks('api/webhooks/mailgun', 'mailgun-webhooks'); ``` ### Configuration Setup Configure webhook handling behavior with signing secrets, signature validators, and processing jobs. ```php // config/webhook-client.php return [ 'configs' => [ [ 'name' => 'stripe-webhooks', 'signing_secret' => env('STRIPE_WEBHOOK_SECRET'), 'signature_header_name' => 'Stripe-Signature', 'signature_validator' => \Spatie\WebhookClient\SignatureValidator\DefaultSignatureValidator::class, 'webhook_profile' => \Spatie\WebhookClient\WebhookProfile\ProcessEverythingWebhookProfile::class, 'webhook_response' => \Spatie\WebhookClient\WebhookResponse\DefaultRespondsTo::class, 'webhook_model' => \Spatie\WebhookClient\Models\WebhookCall::class, 'store_headers' => ['user-agent', 'stripe-signature'], 'process_webhook_job' => \App\Jobs\ProcessStripeWebhookJob::class, ], [ 'name' => 'github-webhooks', 'signing_secret' => env('GITHUB_WEBHOOK_SECRET'), 'signature_header_name' => 'X-Hub-Signature-256', 'signature_validator' => \App\SignatureValidators\GithubSignatureValidator::class, 'webhook_profile' => \App\WebhookProfiles\GithubWebhookProfile::class, 'webhook_response' => \Spatie\WebhookClient\WebhookResponse\DefaultRespondsTo::class, 'webhook_model' => \Spatie\WebhookClient\Models\WebhookCall::class, 'store_headers' => '*', // Store all headers 'process_webhook_job' => \App\Jobs\ProcessGithubWebhookJob::class, ], ], 'delete_after_days' => 30, ]; // .env STRIPE_WEBHOOK_SECRET=whsec_abc123xyz789 GITHUB_WEBHOOK_SECRET=your-github-secret-token ``` ### Custom Processing Job Create a job to handle webhook payloads asynchronously with custom business logic. ```php namespace App\Jobs; use Spatie\WebhookClient\Jobs\ProcessWebhookJob as SpatieProcessWebhookJob; class ProcessStripeWebhookJob extends SpatieProcessWebhookJob { public function handle() { // Access the webhook call model $webhookCall = $this->webhookCall; // Get the payload data $payload = $webhookCall->payload; $eventType = $payload['type'] ?? null; // Handle different event types match($eventType) { 'payment_intent.succeeded' => $this->handlePaymentSuccess($payload), 'payment_intent.failed' => $this->handlePaymentFailure($payload), 'customer.subscription.updated' => $this->handleSubscriptionUpdate($payload), 'charge.refunded' => $this->handleRefund($payload), default => \Log::info('Unhandled webhook event', ['type' => $eventType]) }; } protected function handlePaymentSuccess(array $payload): void { $paymentIntent = $payload['data']['object']; $orderId = $paymentIntent['metadata']['order_id'] ?? null; if ($orderId) { $order = \App\Models\Order::find($orderId); $order->update([ 'status' => 'paid', 'payment_intent_id' => $paymentIntent['id'], 'paid_at' => now(), ]); // Send confirmation email \Mail::to($order->customer)->send(new \App\Mail\PaymentConfirmation($order)); } } protected function handlePaymentFailure(array $payload): void { $paymentIntent = $payload['data']['object']; $orderId = $paymentIntent['metadata']['order_id'] ?? null; if ($orderId) { $order = \App\Models\Order::find($orderId); $order->update(['status' => 'payment_failed']); \Log::error('Payment failed', [ 'order_id' => $orderId, 'error' => $paymentIntent['last_payment_error']['message'] ?? 'Unknown error' ]); } } protected function handleSubscriptionUpdate(array $payload): void { $subscription = $payload['data']['object']; $customerId = $subscription['customer']; \App\Models\User::where('stripe_customer_id', $customerId) ->update([ 'subscription_status' => $subscription['status'], 'subscription_ends_at' => $subscription['current_period_end'], ]); } protected function handleRefund(array $payload): void { $charge = $payload['data']['object']; $paymentIntentId = $charge['payment_intent']; $order = \App\Models\Order::where('payment_intent_id', $paymentIntentId)->first(); if ($order) { $order->update([ 'status' => 'refunded', 'refunded_at' => now(), 'refund_amount' => $charge['amount_refunded'], ]); } } } ``` ### Custom Signature Validator Implement custom signature validation for services with unique signing mechanisms. ```php namespace App\SignatureValidators; use Illuminate\Http\Request; use Spatie\WebhookClient\WebhookConfig; use Spatie\WebhookClient\SignatureValidator\SignatureValidator; class GithubSignatureValidator implements SignatureValidator { public function isValid(Request $request, WebhookConfig $config): bool { $signature = $request->header($config->signatureHeaderName); if (!$signature) { return false; } // GitHub sends signature in format: sha256=<signature> $signatureParts = explode('=', $signature, 2); if (count($signatureParts) !== 2) { return false; } [$algorithm, $hash] = $signatureParts; if ($algorithm !== 'sha256') { return false; } $computedSignature = hash_hmac('sha256', $request->getContent(), $config->signingSecret); return hash_equals($computedSignature, $hash); } } class SlackSignatureValidator implements SignatureValidator { public function isValid(Request $request, WebhookConfig $config): bool { $timestamp = $request->header('X-Slack-Request-Timestamp'); $signature = $request->header('X-Slack-Signature'); if (!$timestamp || !$signature) { return false; } // Prevent replay attacks - reject requests older than 5 minutes if (abs(time() - $timestamp) > 60 * 5) { return false; } $signatureBase = "v0:{$timestamp}:{$request->getContent()}"; $computedSignature = 'v0=' . hash_hmac('sha256', $signatureBase, $config->signingSecret); return hash_equals($computedSignature, $signature); } } class TwilioSignatureValidator implements SignatureValidator { public function isValid(Request $request, WebhookConfig $config): bool { $signature = $request->header('X-Twilio-Signature'); if (!$signature) { return false; } // Twilio includes URL and POST parameters in signature $url = $request->fullUrl(); $data = $request->all(); // Sort parameters alphabetically ksort($data); // Build string to sign $signatureData = $url; foreach ($data as $key => $value) { $signatureData .= $key . $value; } $computedSignature = base64_encode(hash_hmac('sha1', $signatureData, $config->signingSecret, true)); return hash_equals($computedSignature, $signature); } } ``` ### Custom Webhook Profile Filter incoming webhooks to process only relevant events based on custom criteria. ```php namespace App\WebhookProfiles; use Illuminate\Http\Request; use Spatie\WebhookClient\WebhookProfile\WebhookProfile; class GithubWebhookProfile implements WebhookProfile { public function shouldProcess(Request $request): bool { $event = $request->header('X-GitHub-Event'); // Only process specific GitHub events $allowedEvents = ['push', 'pull_request', 'issues', 'release']; if (!in_array($event, $allowedEvents)) { return false; } // For push events, only process main branch if ($event === 'push') { $payload = $request->json()->all(); $ref = $payload['ref'] ?? ''; return $ref === 'refs/heads/main'; } // For pull requests, only process opened and closed if ($event === 'pull_request') { $payload = $request->json()->all(); $action = $payload['action'] ?? ''; return in_array($action, ['opened', 'closed', 'synchronize']); } return true; } } class StripeWebhookProfile implements WebhookProfile { public function shouldProcess(Request $request): bool { $payload = $request->json()->all(); $eventType = $payload['type'] ?? null; // Only process payment and subscription events $processableEvents = [ 'payment_intent.succeeded', 'payment_intent.failed', 'charge.refunded', 'customer.subscription.created', 'customer.subscription.updated', 'customer.subscription.deleted', 'invoice.payment_succeeded', 'invoice.payment_failed', ]; if (!in_array($eventType, $processableEvents)) { return false; } // Ignore test mode events in production if (app()->environment('production')) { $livemode = $payload['livemode'] ?? false; return $livemode === true; } return true; } } class ConditionalWebhookProfile implements WebhookProfile { public function shouldProcess(Request $request): bool { $payload = $request->json()->all(); // Process only if specific conditions are met if (!isset($payload['tenant_id'])) { return false; } // Check if tenant is active $tenant = \App\Models\Tenant::find($payload['tenant_id']); if (!$tenant || !$tenant->is_active) { return false; } // Check if tenant has webhook processing enabled if (!$tenant->webhooks_enabled) { return false; } // Rate limiting per tenant $cacheKey = "webhook_count:{$tenant->id}"; $count = \Cache::get($cacheKey, 0); if ($count > 100) { \Log::warning('Webhook rate limit exceeded', ['tenant_id' => $tenant->id]); return false; } \Cache::put($cacheKey, $count + 1, now()->addMinute()); return true; } } ``` ### WebhookCall Model Usage Access and manipulate stored webhook data for debugging and reprocessing. ```php use Spatie\WebhookClient\Models\WebhookCall; // Retrieve recent webhooks $recentWebhooks = WebhookCall::where('name', 'stripe-webhooks') ->orderBy('created_at', 'desc') ->limit(10) ->get(); // Find webhooks with exceptions $failedWebhooks = WebhookCall::whereNotNull('exception') ->where('created_at', '>', now()->subDay()) ->get(); foreach ($failedWebhooks as $webhook) { echo "Webhook {$webhook->id} failed:\n"; echo "Error: {$webhook->exception['message']}\n"; echo "Trace:\n{$webhook->exception['trace']}\n"; // Retry processing dispatch(new \App\Jobs\ProcessStripeWebhookJob($webhook)); $webhook->clearException(); } // Search webhooks by payload content $specificWebhooks = WebhookCall::where('name', 'github-webhooks') ->where('payload->action', 'opened') ->where('payload->pull_request->user->login', 'octocat') ->get(); // Access webhook headers $webhook = WebhookCall::first(); $userAgent = $webhook->headers()->get('user-agent'); $signature = $webhook->headers()->get('stripe-signature'); // Get all headers $allHeaders = $webhook->headers()->all(); // Access payload data $payload = $webhook->payload; $eventType = $payload['type'] ?? null; $objectData = $payload['data']['object'] ?? []; // Handle file attachments if present if ($webhook->payload && isset($webhook->payload['attachments'])) { $attachments = $webhook->getAttachments(); foreach ($attachments as $file) { // Process uploaded files $originalName = $file->getClientOriginalName(); $mimeType = $file->getMimeType(); $size = $file->getSize(); // Store file $path = $file->store('webhook-attachments'); } } // Custom model with overridden storage behavior namespace App\Models; use Spatie\WebhookClient\Models\WebhookCall as BaseWebhookCall; use Illuminate\Http\Request; use Spatie\WebhookClient\WebhookConfig; class CustomWebhookCall extends BaseWebhookCall { protected $table = 'custom_webhook_calls'; public static function storeWebhook(WebhookConfig $config, Request $request): WebhookCall { $webhookCall = parent::storeWebhook($config, $request); // Add custom processing $webhookCall->update([ 'processed' => false, 'tenant_id' => $request->input('tenant_id'), 'source_ip' => $request->ip(), ]); return $webhookCall; } } ``` ### WebhookProcessor Direct Usage Process webhooks programmatically without using routes or controllers. ```php use Spatie\WebhookClient\WebhookProcessor; use Spatie\WebhookClient\WebhookConfig; use Illuminate\Http\Request; // In a custom controller class CustomWebhookController { public function handleWebhook(Request $request) { // Dynamic configuration based on request $tenant = $this->identifyTenant($request); $webhookConfig = new WebhookConfig([ 'name' => "tenant-{$tenant->id}", 'signing_secret' => $tenant->webhook_secret, 'signature_header_name' => 'X-Webhook-Signature', 'signature_validator' => \Spatie\WebhookClient\SignatureValidator\DefaultSignatureValidator::class, 'webhook_profile' => \Spatie\WebhookClient\WebhookProfile\ProcessEverythingWebhookProfile::class, 'webhook_response' => \Spatie\WebhookClient\WebhookResponse\DefaultRespondsTo::class, 'webhook_model' => \Spatie\WebhookClient\Models\WebhookCall::class, 'process_webhook_job' => \App\Jobs\ProcessTenantWebhookJob::class, ]); try { return (new WebhookProcessor($request, $webhookConfig))->process(); } catch (\Spatie\WebhookClient\Exceptions\InvalidWebhookSignature $e) { \Log::warning('Invalid webhook signature', [ 'tenant_id' => $tenant->id, 'ip' => $request->ip(), ]); return response()->json(['error' => 'Invalid signature'], 401); } } protected function identifyTenant(Request $request) { $tenantId = $request->header('X-Tenant-ID') ?? $request->input('tenant_id'); return \App\Models\Tenant::findOrFail($tenantId); } } // Batch webhook processing class WebhookReplayService { public function replayFailedWebhooks(string $configName, \DateTimeInterface $since) { $failedWebhooks = WebhookCall::where('name', $configName) ->whereNotNull('exception') ->where('created_at', '>', $since) ->get(); $config = $this->getWebhookConfig($configName); foreach ($failedWebhooks as $webhook) { try { // Recreate request from stored data $request = Request::create( $webhook->url, 'POST', $webhook->payload, [], [], [], json_encode($webhook->payload) ); // Add stored headers foreach ($webhook->headers ?? [] as $name => $values) { $request->headers->set($name, $values); } (new WebhookProcessor($request, $config))->process(); \Log::info('Successfully replayed webhook', ['webhook_id' => $webhook->id]); } catch (\Exception $e) { \Log::error('Failed to replay webhook', [ 'webhook_id' => $webhook->id, 'error' => $e->getMessage(), ]); } } } protected function getWebhookConfig(string $configName): WebhookConfig { $configs = config('webhook-client.configs'); $configArray = collect($configs)->firstWhere('name', $configName); return new WebhookConfig($configArray); } } ``` ### CSRF Protection Exclusion Exclude webhook routes from CSRF token verification for external services. ```php // Modern Laravel (bootstrap/app.php) use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Middleware; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( web: __DIR__.'/../routes/web.php', api: __DIR__.'/../routes/api.php', commands: __DIR__.'/../routes/console.php', health: '/up', ) ->withMiddleware(function (Middleware $middleware) { $middleware->validateCsrfTokens(except: [ 'api/webhooks/*', 'webhooks/stripe', 'webhooks/github', 'webhooks/mailgun', ]); }) ->create(); // Older Laravel (app/Http/Middleware/VerifyCsrfToken.php) namespace App\Http\Middleware; use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as Middleware; class VerifyCsrfToken extends Middleware { protected $except = [ 'api/webhooks/*', 'webhooks/stripe', 'webhooks/github', 'webhooks/mailgun', ]; } ``` ### Webhook Model Pruning Automatically clean up old webhook records to manage database size. ```php use Illuminate\Support\Facades\Schedule; use Spatie\WebhookClient\Models\WebhookCall; // routes/console.php or app/Console/Kernel.php Schedule::command('model:prune', [ '--model' => [WebhookCall::class], ])->daily(); // config/webhook-client.php - configure retention period return [ 'configs' => [ // ... webhook configurations ], // Delete webhooks older than 30 days 'delete_after_days' => 30, // Or keep webhooks for 90 days // 'delete_after_days' => 90, // Never delete webhooks // 'delete_after_days' => null, ]; // Custom pruning logic in extended model namespace App\Models; use Spatie\WebhookClient\Models\WebhookCall as BaseWebhookCall; class CustomWebhookCall extends BaseWebhookCall { public function prunable() { // Keep failed webhooks longer for debugging return static::where('created_at', '<', now()->subDays(90)) ->whereNull('exception'); } } ``` ### Testing Webhooks Send test webhook requests to verify signature validation and processing. ```bash # Stripe webhook example curl -X POST http://localhost/api/webhooks/stripe \ -H "Content-Type: application/json" \ -H "Stripe-Signature: t=1234567890,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd" \ -d '{ "id": "evt_test_webhook", "object": "event", "type": "payment_intent.succeeded", "livemode": false, "data": { "object": { "id": "pi_test_123", "amount": 2000, "currency": "usd", "status": "succeeded", "metadata": { "order_id": "12345" } } } }' # GitHub webhook example curl -X POST http://localhost/api/webhooks/github \ -H "Content-Type: application/json" \ -H "X-GitHub-Event: push" \ -H "X-Hub-Signature-256: sha256=abc123def456" \ -d '{ "ref": "refs/heads/main", "repository": { "name": "my-repo", "full_name": "user/my-repo" }, "commits": [ { "id": "abc123", "message": "Update README", "author": { "name": "John Doe", "email": "john@example.com" } } ] }' ``` ### Event Listening Listen to webhook-related events for monitoring and logging. ```php // app/Providers/EventServiceProvider.php use Spatie\WebhookClient\Events\InvalidWebhookSignatureEvent; protected $listen = [ InvalidWebhookSignatureEvent::class => [ \App\Listeners\LogInvalidWebhookSignature::class, \App\Listeners\NotifySecurityTeam::class, ], ]; // app/Listeners/LogInvalidWebhookSignature.php namespace App\Listeners; use Spatie\WebhookClient\Events\InvalidWebhookSignatureEvent; use Illuminate\Support\Facades\Log; class LogInvalidWebhookSignature { public function handle(InvalidWebhookSignatureEvent $event) { Log::warning('Invalid webhook signature received', [ 'config_name' => $event->config->name, 'url' => $event->request->fullUrl(), 'ip' => $event->request->ip(), 'user_agent' => $event->request->userAgent(), 'headers' => $event->request->headers->all(), ]); // Track suspicious activity \Cache::increment("invalid_webhooks:{$event->request->ip()}", 1, now()->addHour()); $count = \Cache::get("invalid_webhooks:{$event->request->ip()}", 0); if ($count > 10) { // Too many invalid attempts - potential attack event(new \App\Events\SuspiciousWebhookActivity($event->request->ip())); } } } // app/Listeners/NotifySecurityTeam.php namespace App\Listeners; use Spatie\WebhookClient\Events\InvalidWebhookSignatureEvent; class NotifySecurityTeam { public function handle(InvalidWebhookSignatureEvent $event) { if (app()->environment('production')) { \Notification::route('slack', config('services.slack.security_webhook')) ->notify(new \App\Notifications\InvalidWebhookReceived($event)); } } } ``` ## Summary Laravel Webhook Client provides a production-ready solution for receiving, validating, and processing webhooks from external services. The package handles signature verification using HMAC-SHA256 or custom validation logic, stores webhook payloads in the database for auditing, and processes them asynchronously through Laravel's queue system. It supports multiple webhook endpoints with different configurations, making it suitable for applications that integrate with various third-party services like Stripe, GitHub, Mailgun, or custom webhook providers. The package excels in scenarios requiring secure webhook handling with automatic retry logic, selective webhook filtering through profiles, and comprehensive error tracking. Common use cases include payment processing integrations where webhooks notify about successful payments or refunds, CI/CD pipelines triggered by repository events, notification systems that react to third-party service updates, and multi-tenant applications where each tenant has unique webhook configurations. The extensible architecture allows developers to customize signature validation, webhook filtering, response handling, and storage mechanisms while maintaining security best practices and providing excellent debugging capabilities through stored webhook history and exception tracking.