spatie/laravel-webhook-client
Receive webhooks in your Laravel app with Spatie’s webhook client. Verify signed requests, store incoming payloads, and process them asynchronously via queued jobs. Includes configurable webhook profiles and processing logic for reliable integrations.
Installation:
composer require spatie/laravel-webhook-client
php artisan vendor:publish --provider="Spatie\WebhookClient\WebhookClientServiceProvider" --tag="webhook-client-config"
php artisan vendor:publish --provider="Spatie\WebhookClient\WebhookClientServiceProvider" --tag="webhook-client-migrations"
php artisan migrate
Configure .env:
WEBHOOK_CLIENT_SECRET=your_webhook_secret_here
Define Route:
Route::webhooks('webhook-receiving-url');
Exclude CSRF for Webhook Route (Laravel 10+):
// app/Http/Middleware/VerifyCsrfToken.php
protected $except = ['webhook-receiving-url'];
Create a Job (e.g., app/Jobs/ProcessWebhookJob.php):
namespace App\Jobs;
use Spatie\WebhookClient\Jobs\ProcessWebhookJob as SpatieProcessWebhookJob;
class ProcessWebhookJob extends SpatieProcessWebhookJob
{
public function handle()
{
$payload = $this->webhookCall->payload;
// Process payload (e.g., parse JSON, trigger events, etc.)
}
}
Update Config (config/webhook-client.php):
'process_webhook_job' => App\Jobs\ProcessWebhookJob::class,
Test with a Webhook Sender: Use tools like webhook.site or Postman to send a signed request to your endpoint.
Configure Stripe Secret:
WEBHOOK_CLIENT_SECRET=stripe_webhook_signing_secret
Create a Job for Stripe Events:
namespace App\Jobs;
use Spatie\WebhookClient\Jobs\ProcessWebhookJob;
class HandleStripeWebhook extends ProcessWebhookJob
{
public function handle()
{
$payload = json_decode($this->webhookCall->payload, true);
$event = $payload['data']['object'];
// Handle Stripe events (e.g., payment_intent.succeeded)
if ($event['object'] === 'payment_intent' && $event['status'] === 'succeeded') {
// Update user subscription, send email, etc.
}
}
}
Update Config:
'process_webhook_job' => App\Jobs\HandleStripeWebhook::class,
Send a Test Webhook:
Use Stripe CLI or Postman to trigger a test event (e.g., payment_intent.succeeded).
Incoming Request:
POST request to /webhook-receiving-url.DefaultSignatureValidator (HMAC-SHA256 by default).Profile Check:
ProcessEverythingWebhookProfile (default) to determine if the request should be processed.X-Event-Type header) for selective processing.Storage:
store_headers), and metadata to webhook_calls table.WebhookCall):
namespace App\Models;
use Spatie\WebhookClient\Models\WebhookCall;
class CustomWebhookCall extends WebhookCall
{
protected $table = 'custom_webhook_calls';
public static function storeWebhook($payload, $headers, $config)
{
return static::create([
'payload' => $payload,
'headers' => $headers,
// Custom fields
'source_app' => $headers['x-source-app'] ?? 'unknown',
]);
}
}
Update config:
'webhook_model' => App\Models\CustomWebhookCall::class,
Queue Processing:
ProcessWebhookJob to handle business logic asynchronously.public function handle()
{
try {
$payload = json_decode($this->webhookCall->payload, true);
// Business logic here
} catch (\Exception $e) {
$this->webhookCall->update(['exception' => $e->getMessage()]);
// Optionally notify admins via Slack/email
}
}
Response:
200 OK with OK message.namespace App\WebhookResponses;
use Spatie\WebhookClient\WebhookResponse\RespondsToWebhook;
use Illuminate\Http\Request;
use Spatie\WebhookClient\WebhookConfig;
class CustomResponse implements RespondsToWebhook
{
public function respondToValidWebhook(Request $request, WebhookConfig $config)
{
return response()->json(['status' => 'processed', 'idempotency_key' => $request->header('idempotency-key')]);
}
}
Update config:
'webhook_response' => App\WebhookResponses\CustomResponse::class,
Multiple Webhook Sources:
Configure multiple endpoints in webhook-client.php:
'configs' => [
[
'name' => 'stripe',
'signing_secret' => env('STRIPE_WEBHOOK_SECRET'),
'process_webhook_job' => App\Jobs\HandleStripeWebhook::class,
],
[
'name' => 'github',
'signing_secret' => env('GITHUB_WEBHOOK_SECRET'),
'process_webhook_job' => App\Jobs\HandleGithubWebhook::class,
],
],
Route them separately:
Route::webhooks('stripe-webhook', 'stripe');
Route::webhooks('github-webhook', 'github');
Dynamic Secret Management:
Use Laravel's env() or a secrets manager (e.g., AWS Secrets Manager) for secrets. Avoid hardcoding.
Idempotency:
Store and check idempotency-key headers in WebhookCall to prevent duplicate processing:
public static function storeWebhook($payload, $headers, $config)
{
$key = $headers['idempotency-key'] ?? null;
if ($key && static::where('idempotency_key', $key)->exists()) {
return null; // Skip duplicate
}
return static::create([
'payload' => $payload,
'headers' => $headers,
'idempotency_key' => $key,
]);
}
Testing:
Use WebhookCall factory or seeders to simulate webhooks:
// tests/Feature/WebhookTest.php
public function test_stripe_webhook()
{
$payload = file_get_contents(__DIR__.'/stripe-payment-success.json');
$headers = ['Signature' => 't=123,...'];
$response = $this->postJson('/stripe-webhook', $payload, $headers);
$response->assertOk();
$this->assertDatabaseHas('webhook_calls', ['payload' => $payload]);
}
Monitoring:
webhook_calls with exception field for debugging.\Log::info('Processed webhook', [
'id' => $this->webhookCall->id,
'payload' => $this->webhookCall->payload,
]);
Signature Mismatch:
500 error by default.webhook_response to return 401 Unauthorized:
public function respondToValidWebhook(Request $request, WebhookConfig $config)
{
return response()->json(['error' => 'Invalid signature'], 401);
}
$computed = hash_hmac('sha256', $request->getContent(), $config->signingSecret);
\Log::error("Signature mismatch. Computed: {$computed}, Received: {$request->header('Signature')}");
Queue Failures:
failed() method in jobs to handle failures:How can I help you explore Laravel packages today?