Sending and receiving webhooks in Laravel apps
A webhook is a mechanism where an application can notify an other application that something has happened. Technically, the application sends an HTTP request to that other application. In this blog post, I'd like to introduce you to two packages that we recently released. The first is laravel-webhook-server, which allows you to send webhook requests. The second one is laravel-webhook-client, which makes it easy to receive those webhook request.
Ready to dig in? Let's go!
When are webhooks useful?
Webhooks are very useful when a system wants to be notified as soon as something happens in another system. Let's make that a bit less abstract and take a look at a real-world example.
Oh Dear is an uptime tracker that I've built together with my buddy Mattias. The users of our app want us to let them know as soon as one of their sites is down. Of course, we can send our users notification, via e-mail, Slack, SMS and so on.
But what if one of our users wants to know in a system of their own that a site is down? We don't want them to poll us via our API every second to check. Instead, we offer webhooks. We'll send an HTTP requests on various events. At Spatie, we use these webhooks, to display the sites that are down on our dashboard.
A lot of services offer webhooks: Slack, Stripe (you can handle those using this package), GitHub, ... If you're building a system where something happens that another app is interested in, consider adding support for webhooks.
Sending webhooks in Laravel apps
After having coded up support for webhooks in several projects, and after having seen Lorna Jane's excellent talk on this subject at Laravel Live UK 2019, I thought it was time to finally code up a package to send webhooks.
laravel-webhook-server allows you to configure and send webhooks in a Laravel app easily. So you'll install this into the app that wants to notify other apps that something has happened. The package has support for signing calls, retrying calls, and backoff strategies. Before going further into that, let's first take a look at how you can send out webhook calls.
WebhookCall::create()
->url('https://other-app.com/webhooks')
->payload(['key' => 'value'])
->useSecret('sign-using-this-secret')
->dispatch();
This will send a POST request to https://other-app.com/webhooks
. The body of the request will be JSON encoded version of the array passed to payload
.
Signing webhook requests
When setting up webhooks, it's common to generate, store, and share a secret between your app and the app that wants to receive webhooks. Generating and storing the secret itself is out of scope for the package but could be done easily with Illuminate\Support\Str::random()
.
Our package will add a header called Signature
to every webhook request it makes. That header contains a signature the receiving app can use the payload hasn't been tampered with. The value of the Signature
is computed by the package like this:
$payloadJson = json_encode($payload);
// that secret is the string passed to `useSecret`
$signature = hash_hmac('sha256', $payloadJson, $secret);
Signing a header makes it impossible for outsiders, who don't have access to the shared secret, to compute the right value of the signature.
Of course, you can customize the signing process by creating your own signer. A signer is any class that implements this simple interface.
namespace Spatie\WebhookServer\Signer;
interface Signer
{
public function signatureHeaderName(): string;
public function calculateSignature(array $payload, string $secret): string;
}
Though the package doesn't force you to, I'd recommend only calling other apps via HTTPS, so others can't even see the data transmitted.
Using a backoff strategy
Let's take a look at the code to send a webhook again.
WebhookCall::create()
->url('https://other-app.com/webhooks')
->payload(['key' => 'value'])
->useSecret('sign-using-this-secret')
->dispatch();
You might wonder why the method to kick off the webhook sending process is called dispatch
, and not something like send
. Well, behind the scenes, a job to call the webhook is dispatched.
If the receiving app doesn't respond with a response code starting with 2
(or it doesn't respond within 3 seconds), the package will release the job back into the queue with a delay. We add a delay because we don't want to hammer on the receiving app. Maybe it has some load or other issues right now, and hopefully it will be healthy again a bit later.
The time there should be between attempts is determined by a backoff strategy. Here's the default one:
namespace Spatie\WebhookServer\BackoffStrategy;
class ExponentialBackoffStrategy implements BackoffStrategy
{
public function waitInSecondsAfterAttempt(int $attempt): int
{
if ($attempt > 4) {
return 100000;
}
return 10 ** $attempt;
}
}
So with each attempt, we'll have more time between attempts, with a maximum of 100000 seconds (that's roughly 27 hours). Of course, you can customize that backoff behavior.
By default, the package will try to send the webhook request only three times. Of course, you customize that number in the config file. If the request failed after that last attempt, the package will fire the FinalWebhookCallFailedEvent
. You could use that event to, for example, send a mail to the owner of the app to notify that a webhook couldn't be processed. If a lot of those events are fired for the same URL, you could determine it's time to turn off the webhooks temporarily for that URL. That's all up to you.
Receiving webhooks in Laravel apps
After having created a package to send webhooks in Laravel, it only seemed logical to also create a package to receive them. laravel-webhook-client has support for verifying signed calls, storing payloads, and processing the payloads in a queued job.
Installing the package encompasses adding a route like this.
Route::webHooks('webhook-receiving-url');
For every request that hits that endpoint a couple of steps will be performed. Let's review them.
Verifying the signature
First, we'll verify if the signature of the request is valid. The verification is basically the inverse of the signing done by laravel-webhook-server. The package will take a look at the Signature
header and compare it's contents to a computed version of the signture that's being calculated like this:
$computedSignature = hash_hmac('sha256', $request->getContent(), $configuredSigningSecret);
If the $computedSignature
does not match the content of the Signature header
the package will respond with a 500` and discard the request. Of course, you can customize the signature verification process.
Filtering out unwanted requests
It could be that the app sending out the webhooks, sends out webhook requests for events you're not interested in. Using a webhook profile, you can filter out these uninteresting calls.
All requests that have a valid signature will be passed to a webhook profile. By default, the package will use the profile called ProcessEverythingWebhookProfile
which will determine that all requests are of interest.
Creating a custom webhook profile is easy. Just implement this interface:
namespace Spatie\WebhookClient\WebhookProfile;
use Illuminate\Http\Request;
interface WebhookProfile
{
public function shouldProcess(Request $request): bool;
}
Storing and processing the webhook request
If the webhook profile determines that request is of interest, we'll first store the request in the webhook_calls
table. After that, we'll pass that newly created WebhookCall
model to a queued job.
Most webhook sending apps expect you to respond very quickly. Offloading the real processing work to a queued job allows for speedy responses. You can specify which job should process the webhook in the process_webhook_job
in the config file of the package. A valid job is any class that extends \Spatie\WebhookClient\ProcessWebhookJob
. Here's an example.
namespace App\Jobs;
use \Spatie\WebhookClient\ProcessWebhookJob as SpatieProcessWebhookJob;
class YourProcessWebhookJob extends SpatieProcessWebhookJob
{
public function handle()
{
// $this->webhookCall // contains an instance of `WebhookCall`
// perform the work here
}
}
Should an exception be thrown while queueing the job, the package will store that exception in the exception
attribute on the WebhookCall
model.
After the job has been dispatched, the controller will respond with a 200
status code. The webhook sending app now knows that the webhook request was well received.
If anything goes wrong during the execution of the job, you still have the webhook request available in the webhook_calls
table. After diagnosing and fixing the problem, you should be able to restart the webhook processing job.
In closing
If you need to send and/or receive webhooks in Laravel take a look at laravel-webhook-server and/or laravel-webhook-client packages. They are feature rich and robust. In the blog post above I've highlighted the most important bits, but the readmes of the package contain much more info.
These aren't the first packages to come out of team Spatie. Do take a look at the list of packages we've made previously. If you use any of our code, don't forget to send us a postcard.
What are your thoughts on "Sending and receiving webhooks in Laravel apps"?