Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Webhook Client Laravel Package

spatie/laravel-webhook-client

Receive webhooks in Laravel apps with signature verification, payload storage, and queued processing. Handle incoming HTTP calls securely and dispatch jobs so your app processes webhook events reliably.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and scaffold (≈5 minutes).

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

Then set WEBHOOK_CLIENT_SECRET in .env to the secret the sender gave you (or, better, give each endpoint its own env var — see multi-provider setup below).

Register the route and get it past CSRF.

// routes/web.php or routes/api.php
Route::webhooks('webhooks/stripe', 'stripe');

The macro registers a POST route to the package controller. The default second argument is default (matching the name key in the config array). Because the sender cannot supply a CSRF token, exclude the path in bootstrap/app.php:

->withMiddleware(function (Middleware $middleware) {
    $middleware->preventRequestForgery(except: ['webhooks/*']);
})

Write the job and wire it up. This is the only real code you write on day one:

namespace App\Jobs;

use Spatie\WebhookClient\Jobs\ProcessWebhookJob as SpatieProcessWebhookJob;

class ProcessStripeWebhookJob extends SpatieProcessWebhookJob
{
    public function handle(): void
    {
        $payload = $this->webhookCall->payload; // array (json cast)
        match ($payload['type']) {
            'invoice.paid' => $this->invoicePaid($payload),
            'customer.subscription.deleted' => $this->subscriptionCancelled($payload),
            default => null,
        };
    }
}
// config/webhook-client.php
'name' => 'stripe',
'signing_secret' => env('STRIPE_WEBHOOK_SECRET'),
'signature_header_name' => 'Stripe-Signature',
'signature_validator' => \App\Webhooks\StripeSignatureValidator::class,
'process_webhook_job' => \App\Jobs\ProcessStripeWebhookJob::class,

Where to look first. Read the published config/webhook-client.php top-to-bottom — it is the architecture diagram. The request pipeline is: signature validator → webhook profile → store WebhookCall → dispatch job → webhook response. Everything you'd normally want to customize is one of those five keys. Then look at Spatie\WebhookClient\WebhookConfig (the DTO passed to validators/responses), WebhookController, and the webhook_calls table you just migrated — that table is your audit log and your debugging surface.

First realistic use case. A single Stripe/GitHub endpoint in a small app: default config, ProcessEverythingWebhookProfile, one job class, DefaultRespondsTo. Ship it, confirm the row appears in webhook_calls with processed_at set, then start branching on event type inside the job. Don't build custom validators/profiles until you actually receive an event type you don't care about or a provider whose signature scheme doesn't match hash_hmac('sha256', $rawBody, $secret).

Implementation Patterns

Multi-provider configs. The configs array is a list; each entry is an independent endpoint with its own secret, header name, validator, job, and model.

'configs' => [
    ['name' => 'stripe', 'signing_secret' => env('STRIPE_WEBHOOK_SECRET'), ...],
    ['name' => 'github', 'signing_secret' => env('GITHUB_WEBHOOK_SECRET'), ...],
],
Route::webhooks('webhooks/stripe', 'stripe');
Route::webhooks('webhooks/github', 'github');

This is the cleanest scaling axis the package offers: instead of one job with a giant match block, each provider gets its own job class, its own validator, and its own queue (see below).

Custom signature validators — expect this on every non-Spatie provider. The default validator compares the header value verbatim against hash_hmac('sha256', $request->getContent(), $secret). Most providers don't send it verbatim:

Provider Header Format
GitHub X-Hub-Signature-256 sha256=<hex>
Shopify X-Shopify-Hmac-Sha256 base64 of the binary HMAC
Stripe Stripe-Signature t=<ts>,v1=<hex> over "{ts}.{body}"
Slack X-Slack-Signature v0=<hex> over "v0:{ts}:{body}"
class StripeSignatureValidator implements SignatureValidator
{
    public function isValid(Request $request, WebhookConfig $config): bool
    {
        $header = $request->header($config->signatureHeaderName, '');
        [$timestamp, $signature] = /* parse t= and v1= */;
        $computed = hash_hmac('sha256', "{$timestamp}.{$request->getContent()}", $config->signingSecret);

        return hash_equals($computed, $signature)
            && abs(time() - (int) $timestamp) < 300; // replay window
    }
}

Always hash_equals, never ===. Always timestamp-check when the provider gives you one.

Profiles for cheap filtering — but be aware of the trade-off. shouldProcess() runs before storage, so returning false means the call never touches the database. That's great for noisy providers that emit 40 event types you ignore, but it destroys your audit trail. Two viable patterns:

  • Store everything, filter in the job (ProcessEverythingWebhookProfile) — simplest, best for debugging, costs a row per delivery.
  • Filter in the profile, log explicitly — cheaper, but when a provider claims they sent an event you have no evidence.

Dedupe also lives here, using the provider's event id:

public function shouldProcess(Request $request): bool
{
    return ! WebhookCall::where('url', $request->fullUrl())
        ->where('headers->X-Shopify-Event-Id', $request->header('X-Shopify-Event-Id'))
        ->exists()
        && in_array($request->input('type'), ['invoice.paid', /* ... */], true);
}

(That header-based lookup only works if you listed that header in store_headers; otherwise match on payload->id.) Prefer doing idempotency inside the job in a unique-constrained table — the profile approach has a race window and puts a query on the latency-critical path.

Jobs: make retries and failures visible. The base ProcessWebhookJob serializes the WebhookCall model ($this->webhookCall). You control the rest:

class ProcessStripeWebhookJob extends SpatieProcessWebhookJob
{
    public int $tries = 5;
    public array $backoff = [10, 60, 300];
    public int $uniqueFor = 3600;

    public function handle(): void { /* ... */ }

    public function failed(\Throwable $e): void
    {
        $this->webhookCall->update(['exception' => $e->getMessage()]);
        report($e);
    }
}

Important: the exception column is only populated automatically when queueing fails. Once the job is on the queue, its failures go to failed_jobs, not to webhook_calls — so override failed() if you want one place to look.

Give each provider its own queue. Webhook bursts (a Stripe replay, a Shopify bulk sync) shouldn't starve your transactional jobs. In the job: public string $queue = 'webhooks'; and run php artisan queue:work --queue=webhooks,default. Since the package stores before dispatching, the HTTP response is already fast; the queue choice is about isolation, not speed.

Custom responses. ~95% of providers accept any 2xx, so DefaultRespondsTo (200 JSON) is fine. The exceptions: Slack's url_verification challenge echo and a few providers that inspect the body. Those get a RespondsToWebhook:

class SlackRespondsTo implements RespondsToWebhook
{
    public function respondToValidWebhook(Request $request, WebhookConfig $config)
    {
        return response()->json(['challenge' => $request->input('challenge')]);
    }
}

Critically: a valid request always gets the configured response, even if the profile said "don't process". Filtering an event should never produce a non-2xx, or the provider will retry it forever.

Extend WebhookCall when you need tenancy or a different table. Set webhook_model and override storeWebhook():

class TenantWebhookCall extends WebhookCall
{
    public function storeWebhook(Request $request): WebhookCall
    {
        return static::create([
            'url' => $request->fullUrl(),
            'headers' => $this->getHeaders($request),
            'payload' => $request->input(),
            'tenant_id' => tenant()->id,
        ]);
    }
}

Testing. Verify the signature, the persistence, and the job dispatch separately:

Queue::fake();

$body = json_encode(['id' => 'evt_1', 'type' => 'invoice.paid']);
$secret = config('webhook-client.configs.0.signing_secret');

$this->call('POST', '/webhooks/stripe', [], [], [], [
    'HTTP_SIGNATURE' => hash_hmac('sha256', $body, $secret),
    'CONTENT_TYPE' => 'application/json',
], $body)->assertOk();

Queue::assertPushed(ProcessStripeWebhookJob::class);
$this->assertDatabaseHas('webhook_calls', ['payload->id' => 'evt_1']);

Build the raw $body string yourself — do not sign json_encode($payload) and then send via postJson(), because the two encodings can differ by escaping/ordering and your signature will mismatch. Also test the negative path: a bad signature should yield 500, no row, and an InvalidWebhookSignatureEvent. Test your job by instantiating it with a factory-made WebhookCall and calling handle() — that's where the business logic lives and it needs no HTTP at all.

Gotchas and Tips

  • The api middleware group. The Route::webhooks() macro attaches a middleware group (historically api) to the route, and the macro accepts an optional argument for it. In a Laravel 11+ app where api routes aren't registered, or where your api group has a throttle: limiter a bursting provider will blow through, this bites you. Put webhook routes where you can reason about the group, and check the macro definition in the service provider before assuming.
  • CSRF exclusions must match reality. except: ['webhooks/stripe'] vs 'webhooks/*' vs a leading slash — Laravel does prefix matching but it's easy to get subtly wrong, and the failure mode is a 419 that the provider reports as "your endpoint errored". Verify with a real POST, not just a browser test.
  • Signature verification uses $request->getContent(), the raw body. This is correct and robust — but it means anything that rewrites the body mid-flight is fatal. Don't add middleware that re-encodes JSON on webhook routes. It also means you can't compute signatures from $request->input() and expect them to match the provider's.
  • Invalid signature ⇒ 500. That's the package default, and it's deliberate (it's an attack signal), but it also means a misconfigured secret looks exactly like an outage to the provider, who will retry with exponential backoff for hours. Watch InvalidWebhookSignatureEvent — alerting on it is the single highest-value hook in this package. Add a listener that logs the source IP and payload size.
  • store_headers => '*' is convenient and mildly dangerous. It writes Authorization, cookies, and anything else the sender included straight into webhook_calls. Prefer an explicit list, or scrub on read. Same for oversized payloads: the payload column is long text, but a 5 MB event will sit in your DB for 30 days and be re-serialized into every job payload.
  • Retention is not automatic. delete_after_days only configures the prunable query on the model — nothing prunes unless you schedule it: $schedule->command('model:prune')->daily(); (the model is prunable; confirm the trait on your extended model if you swapped it). Without it, webhook_calls grows unbounded on a busy endpoint.
  • Pruning vs. queue backlog. If you prune after 30 days but a job on a stuck queue references a WebhookCall that's now deleted, the job fails on unserialize or null-dereferences. Since the job serializes the model, either keep retention well beyond your max queue lag or pass the payload, not the model, into sub-jobs.
  • Duplicates are the norm, not the exception. Providers retry on timeouts, and the package's store-then-dispatch flow means a job that times out after doing its work will still be retried. Every handle() should be idempotent — upsert, unique index on the provider's event id, or an explicit alreadyProcessed() guard. $tries/$backoff on your job is a correctness setting, not a performance one.
  • config:cache after changing secrets. env() calls inside config/webhook-client.php are fine; env() calls inside a custom validator are not — they return null under a cached config and your signatures silently fail everywhere.
  • Config name mismatches fail late. If Route::webhooks('x', 'stripe') doesn't match any name in the configs array, you get an exception at request time, not at boot. Boot-time validation of your own config (a tiny service provider register() check) is worth ten minutes.
  • Debugging checklist, in order: does the row exist in webhook_calls? → is exception populated (queueing failed)? → is processed_at null (job never ran / queue worker down)? → does failed_jobs contain the job? That sequence distinguishes "route/CSRF/signature problem" from "storage problem" from "queue problem" from "business-logic bug" in seconds. The stored headers column is what you want when a provider swears they sent a different content type or a different event id.
  • Extension points, ranked by likelihood of need: process_webhook_job (always), signature_validator (per provider), webhook_profile (noise/dedupe), webhook_model (tenancy/custom columns), webhook_response (rare), store_headers (debugging). The interfaces are tiny and stable — implementing them is the intended path, not a hack.
  • Local development needs a public URL: ngrok http 8000, point the provider at https://<id>.ngrok.app/webhooks/stripe, and remember to run queue:work in a second terminal — with QUEUE_CONNECTION=sync in dev everything runs inline and you'll misjudge latency and never exercise retries. Flip to database or redis locally to see the real behavior.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
bernskioldmedia/laravel-matomo
bernskioldmedia/laravel-harvest
bernskiold/laravel-data-scrubber
bernskiold/laravel-currency-converter
bernskiold/laravel-campaign-monitor
bernskioldmedia/laravel-autotranslate
siberfx/telegram-bot
roy-sandip/laravel-view-extended
gregsphotography/site-builder-core
roy-sandip/laravel-view
anodyne/blade-fluentui-icons
webx-ui/module-catalog-collections
webx-ui/module-catalog-feeds
makidizajnerica/laravel-dto
matviib/scheduler
webx-ui/templates
anodyne/blade-tabler-icons
bref/laravel-websockets
anodyne/blade-hugeicons
1994/flatpack-statamic