Webhook Handling Patterns in Laravel: Receive, Verify, Process
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
- Verify — Confirm the request actually came from the expected sender
- Acknowledge — Return 200 immediately, before processing
- 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:
- Verify signatures — first line of defense
- Return 200 immediately — before processing, avoid timeout retries
- Process in background jobs — queue for async, automatic retries
- Implement idempotency — track event IDs, use
insertOrIgnoreor unique constraints - Log everything — raw payloads, processing results, failures
- Replay capability — when fixing bugs, replay failed webhooks
- Monitor — alert when failure rate spikes
- Test thoroughly — valid/invalid signatures, duplicates, edge cases