# laravel-webhook-server: signed, retried webhooks out of a Laravel app

> A Spatie package for sending webhooks from a Laravel application, with HMAC-style request signing, a configurable backoff strategy and a retry schedule of 10 seconds then 100 seconds before a final failure event. It is the sending half only, and the README points at a separate package for the receiving half.

**spatie/laravel-webhook-server** — Send webhooks from Laravel apps

- Repository: https://github.com/spatie/laravel-webhook-server
- Website: https://freek.dev/1383-sending-and-receiving-webhooks-in-laravel-apps
- Stars: 1,083 · Forks: 143
- Language: PHP
- License: MIT
- Published: 2026-10-19 · Updated: 2026-10-19 · Language: en
- Canonical page: https://hysenlabs.com/projects/spatie-laravel-webhook-server

## What the package does, and what it does not

A webhook is a way for an app to provide information to another app about a particular event, and the way the two apps communicate is with a simple HTTP request. This package allows you to configure and send webhooks in a Laravel app easily, with support for signing calls, and for retrying calls with backoff strategies.

That last clause is the scope statement that matters. This is a sending package. The README says explicitly that if you need to receive and process webhooks you should look at the laravel-webhook-client package, which is a separate Spatie project.

So the two halves of a webhook integration are two dependencies, and that is not an accident. Receiving a webhook means validating an inbound signature, which is a different problem from producing one, and the two have different failure modes.

The project is by Spatie, the primary language is PHP, the licence is MIT with a `LICENSE.md` file, and the repository is not archived with the last push on 2026-08-23. The releases are 3.11.0 from 2026-08-23, 3.10.0 from 2026-02-21 and 3.9.0 from 2026-02-09, so the package is on a stable 3.x line and still moving.

The homepage is a blog post rather than a documentation site, and the README's support section is Spatie's usual appeal for sponsorship and postcards, which is a reasonable model for a package this small and this widely used.

## The simplest call, and what it does

The whole API is a fluent builder on a `WebhookCall`, and the shortest useful example is four lines:

```php
WebhookCall::create()
   ->url('https://other-app.com/webhooks')
   ->payload(['key' => 'value'])
   ->useSecret('sign-using-this-secret')
   ->dispatch();
```

What that produces is a POST request to the given URL, with a JSON encoded body from the payload array, and a header called `Signature` containing a signature the receiving app can use to verify the payload has not been tampered with.

Dispatching also fires a `DispatchingWebhookCallEvent`, so a listener can observe every outbound call without instrumenting the call sites.

The retry behaviour is described concretely rather than abstractly. If the receiving app does not respond with a response code starting with `2`, the package retries the call after 10 seconds. If that second attempt fails, it makes a final attempt after 100 seconds. If that attempt fails too, the `FinalWebhookCallFailedEvent` is raised.

So the schedule is 10 seconds, then 100 seconds, then failure. That is a reasonable shape for a webhook: the first retry catches a momentary blip, the second catches a deployment or a restart, and the third gives up and lets your application logic deal with it. The failure event rather than an exception is the deliberate choice, and the config has a switch for the other behaviour.

## The configuration file, and the defaults that matter

The package ships a config file that is published with:

```bash
php artisan vendor:publish --provider="Spatie\WebhookServer\WebhookServerServiceProvider"
```

and it lands at `config/webhook-server.php`. The defaults are the interesting part, because each one encodes a decision.

The queue is `default`, the HTTP verb is `post`, and the signer is the `DefaultSigner` class. The signature goes in a header named `Signature` and the timestamp in a header named `Timestamp`, and there is a `headers` array for anything added to every request.

The operational defaults are a timeout of 3 seconds, 3 tries, and the `ExponentialBackoffStrategy` for the delay between attempts. Three seconds is short, which is the right default for a webhook, because the receiver is not on your critical path and a slow receiver should not hold your worker. Three tries matches the 10 and 100 second schedule in the usage section.

Two more settings deserve attention. `verify_ssl` is true by default, so the destination certificate is validated. And `throw_exception_on_failure` is false, so a webhook that never succeeds raises the `FinalWebhookCallFailedEvent` rather than throwing; set it to true and the last failure becomes an exception instead.

The `webhook_job` setting points at the class used to dispatch webhooks onto the queue, and the `tags` array lets you tag the underlying job when you are using Laravel Horizon.

## The queue is not optional, and the README says so

One sentence in the README carries more operational weight than the rest of the configuration:

By default, the package uses queues to retry failed webhook requests. Be sure to set up a real queue other than `sync` in non-local environments.

That is short and it is the whole warning. The `sync` queue runs the job in the same process as the request, which means the retry schedule of 10 and 100 seconds happens inside a web request, which means the request either blocks for nearly two minutes or the retry never happens because the process is gone.

So the package's default behaviour, the retries and the backoff, only exists if you have a real worker. That is not a detail of the configuration, it is a prerequisite, and a project that installs this and leaves `QUEUE_CONNECTION=sync` in production has a package that appears to work and silently drops failed webhooks.

The `backoff_strategy` setting reinforces the point. The delay between attempts is a strategy object rather than a number, so the package's authors expected the timing to be tuned, and tuning it is pointless if the queue never gets to the second attempt.

The `webhook_job` class is configurable for the same reason. Swapping the job class is how you change what is recorded about an outbound call, and the `tags` setting exists so that a Horizon dashboard can show you the webhooks that are queued and failing.

## Sending synchronously, and conditionally

Not every webhook should be queued, and the package offers a synchronous path for the cases where the call is part of a job that is already running.

The `dispatchSync` method calls the webhook immediately, and the webhook will not be queued and will be run immediately. The README says this can be helpful in situations where sending the webhook is part of a bigger job that has already been queued. That is the case it is meant for: a queued job that does some work and then notifies another system, where the notification is part of the job's own contract rather than a side effect that can be retried independently.

The conditional variants are the same idea with a predicate attached. There are `dispatchIf`, `dispatchUnless`, `dispatchSyncIf` and `dispatchSyncUnless`:

```php
WebhookCall::create()
   ...
   ->dispatchIf($condition);

WebhookCall::create()
   ...
   ->dispatchSyncIf($condition);
```

The four-method matrix is a small design decision that pays off in readability. A team that has one convention for conditional dispatch across the codebase has four entry points for it, and a team that does not ends up with a mix of `if` statements around `dispatch` calls scattered through the feature code.

What the synchronous path does not give you is the retry schedule, and that is the trade to be explicit about. A synchronous call has no queue behind it, so there is no second attempt after 10 seconds and no final failure event. If the receiver is down, the exception or the failed response is yours to handle.

## Signing, and the secret you have to manage yourself

The signing section is where the security of the whole mechanism is decided, and the README is direct about the responsibility.

When setting up, it is common to generate, store and share a secret between your app and the app that wants to receive webhooks. The README notes that generating the secret could be done with `Illuminate\Support\Str::random()`, and then says it is entirely up to you. The package will use the secret you give it.

That last sentence is the point. The package does not generate, store or rotate the secret, and there is no key management, no per-consumer secret and no rotation mechanism. You have a single shared secret per integration, handed to you by the caller through `useSecret`.

The `signer` configuration points at a class that is responsible for calculating the signature that will be added to the headers of the webhook request, and a webhook client can use the signature to verify the request has not been tampered with. The `DefaultSigner` is what you get, and because the signer is a configured class you can replace it, which is how a project with unusual requirements, a different algorithm or a key identifier in the header, plugs in its own.

The presence of a `Timestamp` header alongside the `Signature` one is the detail that makes a timestamped signature worth having. A signature over a payload is only useful for a short window, because otherwise a captured request can be replayed indefinitely, and sending the timestamp separately is what lets the receiver reject an old request. The README's signing documentation covers how it works, and the receiver side is the `laravel-webhook-client` package.

So the security model is: a shared secret, a signature computed over the payload, a timestamp, and a receiver that checks both. Everything hinges on that secret being generated properly, stored properly and shared over a channel you trust.

## Where laravel-webhook-server is the wrong choice

The first wrong case is a project that needs to receive webhooks. The README is explicit that the receiving side is the laravel-webhook-client package, and this one only sends. Installing both is normal, but a team that assumes this package handles inbound calls will build something that trusts unsigned requests.

The second is an application that dispatches webhooks inline while handling a user request, with a synchronous queue. The retry schedule and the final failure event are the package's main features, and both require a worker. On a `sync` queue they do not exist, and the failure is silent.

The third is a project that needs per-consumer secrets with rotation. There is one secret per call, supplied by the caller, and the package stores nothing and rotates nothing. A system with dozens of consumers and a compliance requirement to rotate keys is doing that work itself, and the `signer` class is the extension point where it happens.

The fourth is a team that needs delivery guarantees, such as exactly-once or a dead letter queue with manual replay. What the package offers is three tries and an event on the last failure, and everything after that is your application listening for `FinalWebhookCallFailedEvent` and doing something about it.

The fifth is a project that wants to send to a receiver with a self-signed certificate. `verify_ssl` is on by default and turning it off is a global setting, not a per-destination one, which means the setting that makes one integration work weakens every other integration in the application.

For the ordinary case, a Laravel app telling another system that something happened, with a signature the receiver can check and a retry that survives a receiver restart, this package does that job in a small and readable amount of code.

## Conclusion

laravel-webhook-server suits a Laravel team that needs to notify other systems about events, and that wants signing, retries and backoff handled rather than hand-rolled in every feature. It does not suit a team looking for the receiving side, since the README directs that to the separate laravel-webhook-client package, and it does not suit a project that dispatches webhooks inline in a request, because the default is queued and a synchronous queue in production loses the retry behaviour entirely. Before adopting it, set a real queue rather than `sync` outside local development, and read the signing section, because the secret is shared with the receiving application and a default or reused secret makes the signature worthless.

## FAQ

### How do I send a webhook from a Laravel application?

Build a `WebhookCall` with `url`, `payload` and `useSecret`, then call `dispatch()`. The request is a POST with a JSON encoded body and a `Signature` header, and dispatching fires a `DispatchingWebhookCallEvent`.

### What happens when a webhook call fails?

If the receiver does not respond with a status code starting with 2, the call is retried after 10 seconds and then again after 100 seconds. If the final attempt fails, the `FinalWebhookCallFailedEvent` is raised, unless `throw_exception_on_failure` is enabled, in which case an exception is thrown instead.

### Do I need a real queue for laravel-webhook-server?

Yes. The README says the package uses queues to retry failed webhook requests and that you should be sure to set up a real queue other than `sync` in non-local environments, because the retry schedule only works with a worker.

### How is the webhook request signed?

The `signer` configuration points at a class that calculates the signature added to the request headers, and the `DefaultSigner` is the default. The signature goes in the header named by `signature_header_name` and the timestamp in `timestamp_header_name`, and the secret itself is generated, stored and shared by you, with `Str::random()` suggested.

### How do I send a webhook synchronously or conditionally?

Use `dispatchSync` to run the call immediately without queuing, which is useful when the webhook is part of a job that is already queued. The conditional variants are `dispatchIf`, `dispatchUnless`, `dispatchSyncIf` and `dispatchSyncUnless`.

## Sources

- [License: MIT](https://github.com/spatie/laravel-webhook-server/blob/main/LICENSE)
- [Project website](https://freek.dev/1383-sending-and-receiving-webhooks-in-laravel-apps)
- [README](https://github.com/spatie/laravel-webhook-server/blob/main/README.md)
- [Releases](https://github.com/spatie/laravel-webhook-server/releases)
- [spatie/laravel-webhook-server on GitHub](https://github.com/spatie/laravel-webhook-server)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/spatie-laravel-webhook-server
