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 Server Laravel Package

spatie/laravel-webhook-server

Send webhooks from your Laravel app with ease. Configure endpoints, sign outgoing calls, and automatically retry failed deliveries using customizable backoff strategies. Pairs with spatie/laravel-webhook-client for receiving webhooks.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and publish the config first.

composer require spatie/laravel-webhook-server
php artisan vendor:publish --provider="Spatie\WebhookServer\WebhookServerServiceProvider"

Everything you'll touch day-to-day is decided in config/webhook-server.php. Read it top to bottom once — it's short, and it tells you the whole architecture: queue, HTTP verb, signer, signature header name, timeout, tries, backoff strategy, job class, SSL verification, failure behaviour, Horizon tags. Most "why did it do that?" questions are answered by one of those keys.

Configure a real queue before your first call. The package retries by re-dispatching delayed jobs, so sync silently defeats the retry/backoff design. Set a dedicated queue (e.g. webhooks) so you can scale and monitor it independently of your application jobs:

'queue' => 'webhooks',

First use case — notify a partner when something happens in your app:

use Spatie\WebhookServer\WebhookCall;

WebhookCall::create()
    ->url('https://partner.test/webhooks/orders')
    ->payload([
        'event' => 'order.shipped',
        'id' => $order->uuid,
        'tracking' => $order->tracking_number,
    ])
    ->useSecret(config('services.partner.webhook_secret'))
    ->useTimestamp()
    ->dispatch();

Dispatch it from a listener or a queued job — never inline in a controller's happy path if the call matters, and never inside a DB transaction that might roll back.

Where to look next, in order: the WebhookCall fluent API (url/payload/useSecret/dispatch and its per-call overrides), Spatie\WebhookServer\CallWebhookJob (what actually fires), the Signer and BackoffStrategy interfaces (your two main extension points), and the events — DispatchingWebhookCallEvent, FinalWebhookCallFailedEvent, plus the per-attempt success/failure events — which are where you wire logging and alerting.

Implementation Patterns

Wrap it, don't sprinkle it. Calling WebhookCall::create() in twenty places makes endpoint/secrets/payload drift inevitable. Centralise:

class PartnerWebhook
{
    public function send(string $event, array $data, ?string $secret = null): void
    {
        WebhookCall::create()
            ->url(config('services.partner.webhook_url'))
            ->payload(['event' => $event, 'data' => $data, 'sent_at' => now()->toIso8601String()])
            ->useSecret($secret ?? config('services.partner.webhook_secret'))
            ->useTimestamp()
            ->maximumTries(5)
            ->dispatch();
    }
}

A WebhookSubscription-style model (endpoint URL + secret + enabled flag per tenant) plus a small dispatcher that loops subscriptions is the natural multi-tenant shape. ->dispatchIf($subscription->enabled) exists for exactly this.

Respect transactions. Queued webhooks can fire before the DB commit. Dispatch from an event listener, or ensure your queue connection uses after_commit, otherwise partners receive payloads for rows that don't exist yet (or never will).

Use dispatchSync() inside jobs that are already queued. If you're in a long-running queued job and want the webhook to happen as part of that job's lifecycle (and be retried by it), dispatchSyncIf($condition) avoids a second queue hop and keeps ordering predictable.

Per-call overrides instead of global config. The defaults are conservative (3s timeout, 3 tries). Override at the call site for slow or flaky endpoints: ->timeoutInSeconds(10), ->maximumTries(8), ->useBackoffStrategy(FlakyPartnerBackoff::class), ->withHeaders([...]), ->useHttpVerb('put'). Keep the global config as a safe baseline.

Version your payloads. Include event, a stable id/delivery_id, and an API version in every payload. Because retries mean duplicates, the receiver dedupes on delivery_id; because you'll change the schema, the version field saves you a migration conversation.

Secrets per endpoint. Store them encrypted in the DB and pass with ->useSecret($model->secret); keep the global default in .env only as a fallback. Str::random(40) is fine for generation and is what partners usually expect.

Custom signer when the partner's scheme differs. Implement Signer (signatureHeaderName(), calculateSignature(array $payload, string $secret)), set it globally in config or per-call with ->signUsing(PartnerSigner::class) — e.g. when a partner wants sha1, a prefixed t=...,v1=... scheme, or a signature header with a different name.

Observability via events + Horizon tags. Listen for FinalWebhookCallFailedEvent and alert/persist a dead-letter record; log WebhookCallSucceededEvent with the endpoint and duration. Set 'tags' => ['webhooks'] so Horizon groups attempts — remember each retry is a separate delayed job, so one logical webhook can appear as several.

Testing: fake the queue, don't fake HTTP.

Queue::fake();

PartnerWebhook::send('order.shipped', ['id' => $order->uuid]);

Queue::assertPushed(CallWebhookJob::class);

The request itself is made with Guzzle inside the job, not Laravel's HTTP client, so Http::fake() won't intercept it. Assert on the dispatched job, or bind a fake webhook_job class in the container. Reserve one integration test (a real local endpoint or dispatchSync against a test server) to keep the payload shape honest.

Gotchas and Tips

The signature is over the raw JSON body. hash_hmac('sha256', json_encode($payload), $secret). Any re-encoding on the receiving side (decode → re-encode, key reordering, different escaping of slashes/unicode) breaks verification. Tell your consumers to hash the raw request body, not a parsed array. It also means anything you add to the payload changes the signature — no post-hoc mutation.

The timestamp is not part of the signature by the default signer. useTimestamp() gives receivers a freshness window, but on its own it doesn't prevent replays of a captured body with a rewritten timestamp. Pair it with signed idempotency keys (in the payload) and receiver-side dedupe, or roll a custom signer that includes the timestamp in the HMAC input.

Failures are silent by default. throw_exception_on_failure is false; a webhook that exhausts retries just fires FinalWebhookCallFailedEvent and disappears. Either flip that to true, or (better) listen to the event and persist a dead-letter row you can inspect and replay. Do not assume "no exception" means "delivered".

Timeouts compose badly. Worker --timeout must exceed your webhook timeout, otherwise the worker is killed mid-request and you get phantom retries. Likewise, timeout_in_seconds is aggressive at 3s by default — a partner with a cold cache will fail and be retried 10s and 100s later for no good reason. Raise it per-call for slow endpoints.

SQS caps delay at 15 minutes. The default exponential backoff jumps 10s → 100s → 1000s, so the fourth attempt already exceeds the SQS limit. If you're on SQS, use a custom BackoffStrategy with a hard cap, or switch the webhook queue to Redis/database.

Never disable SSL verification to "fix" a call. verify_ssl => false / ->doNotVerifySsl() hides certificate expiry, wrong hostnames, and MITM. For private CAs, use ->verifySsl('/path/to/ca.pem') instead. Mutual TLS is available via ->mutualTls(certPath: ..., sslKeyPath: ...); proxies via ->useProxy() in Guzzle format.

Payloads are serialised onto the queue. No closures, no UploadedFile, no live Eloquent models with open connections — convert to plain arrays/scalars first. Big payloads bloat the queue table; send a reference and let the partner fetch details if it's more than a few KB.

Config caching will surprise you. Anything read at job-construction time (including the signer class and headers) is baked in; after changing config/webhook-server.php in production, run php artisan config:cache and restart workers.

Extension points worth remembering: 'webhook_job' in config lets you subclass CallWebhookJob and drop in structured logging, metrics, or extra Guzzle options without touching call sites; 'signer' and 'backoff_strategy' swap the defaults globally. Anything narrower belongs on the fluent call.

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.
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource