The 4-part pattern
Every webhook endpoint should follow the same skeleton:
app/Http/Controllers/StripeWebhookController.php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use App\Jobs\ProcessStripeWebhook;
public function __invoke(Request $request)
{
// 1. Verify signature on the RAW body
$sig = $request->header('Stripe-Signature');
$body = $request->getContent();
$expected = hash_hmac('sha256', $body, config('services.stripe.webhook_secret'));
if (!hash_equals($expected, $sig)) {
abort(401, 'Invalid signature');
}
$payload = json_decode($body, true);
$eventId = $payload['id'];
// 2. Persist raw payload for audit and replay
$stored = DB::table('webhook_deliveries')->insertOrIgnore([
'provider' => 'stripe',
'event_id' => $eventId,
'event_type' => $payload['type'],
'payload' => $body,
'received_at' => now(),
]);
// 3. Idempotency — only queue if this is a fresh delivery
if ($stored === 0) {
// Duplicate retry — we already have it. ACK and bail.
return response()->json(['status' => 'duplicate']);
}
// 4. Process async — return 200 fast
ProcessStripeWebhook::dispatch($eventId);
return response()->json(['status' => 'queued']);
}
Signature first — if we can't trust the payload, nothing else matters. Raw body — request()->all() re-serializes and breaks HMAC. Unique event ID insert — duplicate retries hit the constraint. Async dispatch — controller returns in under a second.
Dead-man's-switch monitoring
The scariest webhook bug: no webhooks at all. The provider rotated a secret, the endpoint returns 401 silently, and nobody notices until business metrics crater.
Alert on absence, not on errors:
app/Console/Commands/CheckWebhookHeartbeat.php
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class CheckWebhookHeartbeat extends Command
{
protected $signature = 'webhooks:heartbeat';
public function handle()
{
$providers = ['stripe', 'shopify', 'sendgrid'];
foreach ($providers as $provider) {
$recentCount = DB::table('webhook_deliveries')
->where('provider', $provider)
->where('received_at', '>=', now()->subMinutes(30))
->count();
if ($recentCount === 0) {
logger()->alert("No {$provider} webhooks in 30 minutes");
// Send Slack alert via a real channel
}
}
}
}Schedule every 5 minutes. Tune the window per provider — high-volume providers (Stripe at scale) should alert if silent for 5 minutes; low-volume (password reset emails) can tolerate longer silence.
Metrics that matter
- Delivery rate per provider — time-series of webhooks received per minute. Drops alert.
- Signature failure rate — > 0 is a red flag.
- Receive-to-ACK latency — should be under 1 second. Longer means the controller is doing too much synchronously.
- Processing job failure rate — async processing is where real business logic lives and breaks.
- Duplicate rate — measure how often you hit the idempotency constraint. Some duplicates are normal; a spike usually means the provider thinks you're timing out.
Spatie's laravel-webhook-client
If you don't want to hand-roll the pattern, Spatie's package does most of it: signature verification, persistence to a webhook_calls table, async job dispatch with full payload, retry configuration. Recommended for multi-provider webhook setups.
THE EASY WAY
NightOwl surfaces webhook endpoints like any request
Webhook endpoints show up in the requests dashboard with count, p95 duration, and error rate. Filter to a specific route pattern to see only your /webhooks/stripe traffic. Pair with queue monitoring to track the async processing jobs.
composer require nightowl/agent
php artisan nightowl:installFrom $5/month flat. Data in your PostgreSQL.