[ GUIDE ]

Receiving and monitoring Laravel webhooks

Signature verification, async processing, idempotency, and dead-man's-switch monitoring. Everything your webhook endpoint needs to be production-ready.

QUICK ANSWER

How do I monitor Laravel webhook endpoints?

Track four signals: incoming delivery rate (with drop alerts via dead-man's-switch), signature verification failure rate, webhook receive-to-commit latency, and async processing job failure rate. In code, always: verify signatures before trusting payloads, persist raw bodies, queue processing async, and make handlers idempotent using the webhook's unique ID. NightOwl records webhook endpoints like any request with duration, status, and error context.

Updated · 2026-04-13

The 4-part pattern

Every webhook endpoint should follow the same skeleton:

app/Http/Controllers/StripeWebhookController.php

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

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.

bash
composer require nightowl/agent
php artisan nightowl:install

From $5/month flat. Data in your PostgreSQL.

Frequently asked questions

How do I reliably receive webhooks in Laravel?

Four principles: (1) return 200 fast and process async — accept the payload, queue a job, return 200 within a few seconds, (2) verify signatures before trusting the payload, (3) make processing idempotent using the webhook's unique ID — retries mustn't cause duplicate side effects, (4) persist the raw payload to a webhook_deliveries table for audit and replay. Spatie's laravel-webhook-client package handles most of this if you don't want to build it yourself.

What's the most common webhook bug in Laravel?

Processing synchronously in the controller. The webhook provider times out (usually 5-30 seconds), thinks the delivery failed, and retries — sometimes with duplicates. You process the retry, the original completes late, and now you've double-charged or double-emailed someone. Always process async: accept, queue, return 200.

How do I verify webhook signatures in Laravel?

Every reputable provider signs payloads with HMAC. Extract the signature header, compute HMAC-SHA256 of the raw request body with your shared secret, compare using hash_equals (constant-time). Fail the request with 401 if signatures don't match. Use the raw body (request()->getContent()), not request()->all(), so you compare what the provider actually signed.

How do I make Laravel webhook handlers idempotent?

Use the webhook's unique event ID (Stripe's evt_, Shopify's X-Shopify-Webhook-Id, etc.) as a primary key in a processed_webhooks table. On receipt, try to insert the ID. If it already exists (duplicate retry), return 200 without re-processing. If it's new, process and record. The insert-first pattern avoids a race between two concurrent deliveries.

What happens when my webhook endpoint is down?

Reputable providers retry with backoff — Stripe retries up to 3 days, Shopify up to 48 hours. Less reputable ones give up after 1-2 attempts. Either way, your observability has to detect 'webhooks haven't arrived for X minutes' as a signal — absence of deliveries is hard to alert on. Use dead-man's-switch monitoring: alert if you don't receive any webhook from Stripe in a rolling 30-minute window.

Should I rate-limit incoming webhooks?

Usually no — rate-limiting incoming webhooks means you're dropping real events. Instead, make your async processing capacity match peak delivery rate. If rate limiting is necessary, return 429 with a Retry-After header so the provider backs off appropriately, and ensure your queue buffers the backlog without dropping payloads.

How do I test Laravel webhook handlers locally?

Use ngrok or Cloudflare Tunnel to expose localhost to the public internet. Configure a test webhook in the provider's dashboard pointing to your ngrok URL. Or use the provider's CLI (stripe listen, shopify-cli webhook trigger) that forwards real webhooks to local endpoints. For unit tests, mock the raw body and headers and assert the handler's effects.

What should I monitor for Laravel webhook endpoints?

Four things: (1) receipt rate — how many webhooks per minute are coming in, with alerts on sudden drops, (2) processing latency — receive-to-commit time, (3) signature verification failure rate — spikes indicate a compromised secret or a misconfigured provider, (4) async job failure rate for the processing jobs. APMs that record webhook endpoints like any other route surface the first three; job monitoring covers the fourth.

PRICING

Flat pricing. No event caps. No per-seat fees.

14-day free trial, no credit card. Your PostgreSQL, your data.

HOBBY

$5 /month

1 app · 14 days lookback · all Laravel events

TEAM

$15 /month

Up to 3 connected apps · unlimited environments · all Laravel events

AGENCY

$69 /month

Unlimited apps · unlimited agent instances · same flat rate at any traffic

Related