Processing Webhooks From Multiple Origins Using Laravel

Most tutorials on processing webhooks in laravel applications assume that your application is expecting webhooks from one origin. In this tutorial, you are going to learn how process webhooks from multiple origins while keeping your application as performant as possible and without installing any package.

For demonstration purposes, we'll see how to process webhooks from both Lemon Squeezy and Paystack. However, I am going to assume that you have a laravel application up and running. If you don't, follow these instructions to have one running as quickly as possible.

Laying the foundation

A critical part of processing webhooks is storing them in the database. So let's create a webhook model, migration and controller using artisan; the command line interface included with Laravel.

php artisan make:model webhook -mc

Next we would define what our webhooks table would look like. Open the ***_create_webhooks_table migration earlier generated by the framework and update it like so. Note that you can change the primary key of the database table to a uuid or uLid and add indexes according to your needs.

public function up(): void
{
    Schema::create('webhooks', function (Blueprint $table) {
        $table->id();
        $table->string('origin');
        $table->string('event_name', 100)->nullable();
        $table->json('payload');
        $table->unsignedTinyInteger('tries')->default(0);
        $table->longText('exception')->nullable();
        $table->timestamp('processed_at')->nullable();
        $table->timestamp('failed_at')->nullable();
        $table->timestamp('created_at')->useCurrent();
        $table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate();
    });
}

I find adding the useCurrent and useCurrentOnUpdate database triggers for the created_at and updated_at columns very useful as they delegate setting the value of both columns to the database. The origin column would be used to store the name of where the webhook was sent from. I want to believe that the remaining database columns are self-explanatory from their names.

We need to update the casts and fillable array properties of the webhook model. Updating the $fillable property prevents mass assignment exceptions when creating or updating a webhook and the $casts property instructs the framework to convert model attributes from one data type to another.

class Webhook extends Model
{
    use HasFactory;

    /**
     * @inheritdoc
     */
    protected $casts = [
        'payload' => 'array',
        'processed_at' => 'datetime',
        'failed_at' => 'datetime',
    ];

    /**
     * @inheritdoc
     */
    protected $fillable = [
        'origin',
        'event_name',
        'payload',
        'tries',
        'exception',
        'processed_at',
        'failed_at',
    ];
}

Next we are going to define a route in web.php to handle all webhook requests to our application.

use App\Http\Controllers\WebhookController;


Route::post('webhooks/{origin}', [WebhookController::class, 'store'])->name('webhooks.store');

However, this route declaration won't work yet because the store method is not defined in WebhookController.php. Let us fix that.

public function store(Request $request, string $origin)
{
    // TODO implement webhook processing
}

We need to exclude our webhook endpoint from CSRF validation by the framework. Open the VerifyCsrfToken middleware and add the webhooks route to the except array property.

protected $except = [
    'webhooks/*'
];

Creating webhook processors

Processing a webhook generally involves the following steps

  • Validating the webhook request.
  • Saving the webhook to the database.
  • Processing the saved webhook.

While saving the webhook can be done by a controller, service, action, console command etc, the other steps must be performed by webhook processors. This is because the validation and processing of a webhook request differs from one webhook origin to another as we would soon see in the next few paragraphs.

Let's create an interface that would enforce webhook validation and processing on our webhook processors. The interface would be created in the App\ThirdParty\WebhookProcessors\Contracts namespace. Create the necessary directories if they don't exist. Add the code below to the interface. I have added comments to describe what each method does.

<?php

namespace App\ThirdParty\WebhookProcessors\Contracts;

use App\Models\Webhook;
use Illuminate\Http\Request;

interface WebhookProcessorInterface
{
    /**
     * Returns the name of the webhook processor
     *
     * @return string
     */
    public function getName(): string;

    /**
     * Returns the webhook verification signature from the request if present.
     *
     * @param Request $request
     *
     * @return string|null
     */
    public function getSignature(Request $request): ?string;

    /**
     * Returns the webhook event name from the payload if present.
     *
     * @param array $payload
     *
     * @return string|null
     */
    public function getEventName(array $payload): ?string;

    /**
     * Returns an array of events that is subscribed to from this webhook origin.
     *
     * @return array
     */
    public function getSubscribedEvents(): array;

    /**
     * Returns the actual data contained in the webhook.
     *
     * @param array $payload
     *
     * @return array
     */
    public function getData(array $payload): array;

    /**
     * Validates the request and throws an exception if validation fails.
     *
     * @param Request $request
     *
     * @throws \Throwable
     */
    public function validate(Request $request): void;

    /**
     * Process the webhook.
     *
     * @param Webhook $webhook
     *
     * @throws \Throwable
     */
    public function process(Webhook $webhook): void;

    /**
     * Checks whether the webhook origin requires a response.
     *
     * @return bool
     */
    public function originExpectsAResponse(): bool;

    /**
     * Generate response data if the webhook origin expects a response
     *
     * @param Webhook $webhook
     *
     * @return array
     */
    public function generateResponse(Webhook $webhook): array;
}

Next, we're going to create an abstract class in the App\ThirdParty\WebhookProcessors namespace. This class would implement the WebhookProcessorInterface we created previously and also provide a default implemention of some methods for our webhook processors.

<?php

namespace App\ThirdParty\WebhookProcessors;

use App\Enums\WebhookOrigin;
use App\Models\Webhook;
use App\ThirdParty\WebhookProcessors\Contracts\WebhookProcessorInterface;
use Illuminate\Support\Str;

abstract class AbstractWebhookProcessor implements WebhookProcessorInterface
{
    protected WebhookOrigin $origin;

    /**
     * @inheritDoc
     */
    public function getName(): string
    {
        return Str::of($this->origin->value)->squish()->lower()->value();
    }

    /**
     * @inheritDoc
     */
    public function getSubscribedEvents(): array
    {
        return config("services.{$this->getName()}.webhook.events", []);
    }

    /**
     * @inheritDoc
     */
    public function originExpectsAResponse(): bool
    {
        return false;
    }

    public function generateResponse(Webhook $webhook): array
    {
        return [];
    }
}

The abstract class defines an origin property which is a backed enum with the following cases.

case PAYSTACK = 'Paystack';

case LEMONSQUEEZY = 'lemon-squeezy';

The getName method returns the name of the webhook origin. It uses the Str helper class to remove unneccesary spaces and converts the resulting string to lowercase before returning it. The getSubscribedEvents method returns an array of events that the webhook processor is subscribed to. It retrieves this information from the services.php configuration file based on the webhook origin's name.

To process Paystack webhooks, create a PaystackWebhookProcessor.php class in the App\ThirdParty\WebhookProcessors namespace. Add the following lines of code to it.

<?php

namespace App\ThirdParty\WebhookProcessors;

use App\Enums\WebhookOrigin;
use App\Exceptions\WebhookException;
use App\Models\Webhook;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\IpUtils;

class PaystackWebhookProcessor extends AbstractWebhookProcessor
{
    protected WebhookOrigin $origin = WebhookOrigin::PAYSTACK;

    /**
     * @inheritDoc
     */
    public function getSignature(Request $request): ?string
    {
        return $request->header(key: 'X-Paystack-Signature');
    }

    /**
     * @inheritDoc
     */
    public function getEventName(array $payload): ?string
    {
        return $payload['event'] ?? null;
    }

    /**
     * @inheritDoc
     */
    public function getData(array $payload): array
    {
        return $payload['data'] ?? [];
    }

    /**
     * @inheritDoc
     */
    public function validate(Request $request): void
    {
        $computedSignature = hash_hmac(
            algo: 'sha512',
            data: $request->getContent(),
            key: config(key: 'services.paystack.secret_key', default: '')
        );

        throw_if(
            $this->getSignature($request) !== $computedSignature,
            new WebhookException(message: 'Webhook signature validation failed')
        );

        $webhookSourceIps = (array) config(key: "services.paystack.webhook.source_ips", default: []);

        $requestIp = $this->getConnectingIp(request: $request);

        throw_if(
            ! IpUtils::checkIp(requestIp: $requestIp, ips: $webhookSourceIps),
            new WebhookException(message: "Invalid webhook origin ip address: $requestIp")
        );

        $webhookEvent = $this->getEventName(payload: $request->input());

        throw_if(
            ! in_array(needle: $webhookEvent, haystack: $this->getSubscribedEvents()),
            new WebhookException(message: "Webhook event: $webhookEvent is not recognised")
        );
    }

    /**
     * @inheritDoc
     *
     * @throws WebhookException
     */
    public function process(Webhook $webhook): void
    {
        match ($webhook->event_name) {
            // TODO implement webhook processing using each of the events we are interested in from this origin
            default => throw new WebhookException(message: "An unrecognised webhook event name of: {$webhook->event_name}" ),
        };
    }

    /**
     * Fetches the connecting IP address from the request.
     *
     * @param Request $request
     *
     * @return string
     */
    protected function getConnectingIp(Request $request): string
    {
        return $request->header(key: 'cf-connecting-ip')
            ?? $request->header(key: 'x-forwarded-for')
            ?? $request->ip()
            ?? '';
    }
}

The Paystack processor validates a webhook using its signature, source ip and webhook event. According to the Paystack webhook validation documentation, we are only meant to validate the webhook signature and its origin ip address. Nevertheless, I'm sure we don't want to store webhooks for events we aren't interested in.

The process method of the class is largely empty as how webhooks are processed is heavily dependent on business requirements. I also believe its easy to see that each webhook event we want processed can be a case in the match expression.

To process Lemon Squeezy webhooks, create a LemonSqueezyWebhookProcessor.php class in the App\ThirdParty\WebhookProcessors namespace. Add the following lines of code to it.

<?php

namespace App\ThirdParty\WebhookProcessors;

use App\Enums\WebhookOrigin;
use App\Exceptions\WebhookException;
use App\Models\Webhook;
use Illuminate\Http\Request;

class LemonSqueezyWebhookProcessor extends AbstractWebhookProcessor
{
    protected WebhookOrigin $origin = WebhookOrigin::LEMONSQUEEZY;

    /**
     * @inheritDoc
     */
    public function getSignature(Request $request): ?string
    {
        return $request->header(key: 'HTTP_X_SIGNATURE');
    }

    /**
     * @inheritDoc
     */
    public function getEventName(array $payload): ?string
    {
        return $payload['meta']['event_name'] ?? null;
    }

    /**
     * @inheritDoc
     */
    public function getData(array $payload): array
    {
        return $payload['data'] ?? [];
    }

    /**
     * @inheritDoc
     */
    public function validate(Request $request): void
    {
        $computedSignature = hash_hmac(
            algo: 'sha256',
            data: $request->getContent(),
            key: config(key: 'services.lemon-squeezy.webhook.secret_key', default: '')
        );

        throw_if(
            $this->getSignature($request) !== $computedSignature,
            new WebhookException(message: 'Webhook signature validation failed')
        );

        $webhookEvent = $this->getEventName(payload: $request->input());

        throw_if(
            ! in_array(needle: $webhookEvent, haystack: $this->getSubscribedEvents()),
            new WebhookException(message: "Webhook event: $webhookEvent is not recognised")
        );
    }

    /**
     * @inheritDoc
     */
    public function process(Webhook $webhook): void
    {
        match ($webhook->event_name) {
            // TODO implement webhook processing using each of the events listened to by this origin
            default => throw new WebhookException(message: "An unrecognised webhook event name of: {$webhook->event_name}" ),
        };
    }
}

The Lemon Squeezy processor validates a webhook using its signature and webhook event. According to the Lemon Squeezy webhook validation documentation, we are only supposed to validate the webhook signature. Also, Lemon Squeezy allows us to configure the specific webhook events we want to be notified about. Despite this, I want to rest assured that even if we are notified of events we aren't interested in, our application will not store such webhooks.

Next, we are goign to create the neccessary configuration for our webhook processors in the services.php config file. The file arranges config values in an alphabetical order by default; so I suggest you stick to that convention. Update the file like so

'lemon-squeezy' => [
    'secret_key' => env('LEMONSQUEEZY_SECRET_KEY',),
    'webhook' => [
        'secret_key' => env('LEMONSQUEEZY_WEBHOOK_SECRET_KEY'),
        'events' => explode(',', env('LEMONSQUEEZY_WEBHOOK_EVENTS', sprintf(
            '%s',
            'order_created,order_refunded,subscription_created,subscription_updated',
        ))),
    ]
],

'paystack' => [
    'secret_key' => env('PAYSTACK_SECRET_KEY',),
    'public_key' => env('PAYSTACK_PUBLIC_KEY',),
    'webhook' => [
        'source_ips' => ['52.31.139.75', '52.49.173.169', '52.214.14.220'],
        'events' => explode(',', env('PAYSTACK_WEBHOOK_EVENTS', sprintf(
            '%s',
            'charge.success,transfer.failed,transfer.success,transfer.reversed',
        ))),
    ]
],

We also throw a custom exception in the webhook processors that does not exist; let's fix that.

php artisan make:exception WebhookException

This generates a WebhookException.php class in the App\Exceptions namespace. Add the following lines of code to it.

<?php

namespace App\Exceptions;

use Exception;
use Symfony\Component\HttpFoundation\Response;
use Throwable;

class WebhookException extends Exception
{
    /**
     * @param string $message
     * @param int $code
     * @param \Throwable|null $previous
     */
    public function __construct(
        string $message = 'Something went wrong',
        int $code = Response::HTTP_BAD_REQUEST,
        Throwable $previous = null
    ) {
        parent::__construct($message, $code, $previous);
    }
}

Managing webhook processors

How do we know the webhook processor to use for each request that hits the webhook endpoint? Well, we're going to use a Manager for that. You see, Laravel ships with a manager class which according to the documentation manages the creation of driver-based components.

Create a WebhookManager.php class in the App\Support\Managers namespace and add the following lines of code it.

<?php

namespace App\Support\Managers;

use App\ThirdParty\WebhookProcessors\Contracts\WebhookProcessorInterface;
use App\ThirdParty\WebhookProcessors\LemonSqueezyWebhookProcessor;
use App\ThirdParty\WebhookProcessors\NullWebhookProcessor;
use App\ThirdParty\WebhookProcessors\PaystackWebhookProcessor;
use Illuminate\Support\Manager;

class WebhookManager extends Manager
{
    public function createPaystackDriver(): WebhookProcessorInterface
    {
        return new PaystackWebhookProcessor();
    }

    public function createLemonSqueezyDriver(): WebhookProcessorInterface
    {
        return new LemonSqueezyWebhookProcessor();
    }

    public function createNullDriver(): WebhookProcessorInterface
    {
        return new NullWebhookProcessor();
    }

    /**
     * @inheritDoc
     */
    public function getDefaultDriver(): string
    {
        return 'null';
    }
}

As you can see, we have implemented a getDefaultDriver method which is used to determine the default webhook processor the manager would use to process webhooks. In our example, we are returning the null string value. This will cause the framework to automatically use the NullWebhookProcessor if we don't specify the webhook driver when using the WebhookManager. The remaining methods in the class, which follow the structure that the underlying Manager class expects, would be used to dynamically create webhook processors using the driver syntax like so.

WebhookManager::driver('name-of-webhook-processor');

For the sake of brevity, the NullWebhookProcessor is not shown here but available in this tutorial's GitHub repository.

Open the AppServiceProvider and register the WebhookManager as a singleton. This ensures that once the WebhookManager is resolved by the framework, the same object instance will be returned on subsequent calls into the container. This can be done like so

class AppServiceProvider extends ServiceProvider
{
    /**
     * All the container singletons that should be registered.
     *
     * @var array
     */
    public array $singletons = [
        WebhookManager::class => WebhookManager::class,
    ];
}

Tying it all together

So now we have a webhook manager and webhook processors, it's time to tie all this together. Open the WebhookController and update it like so.

/**
 * @throws \Throwable
 */
public function store(Request $request, string $origin, WebhookManager $manager): JsonResponse
{
    /** @var WebhookProcessorInterface|null $webhookProcessor */
    $webhookProcessor = $manager->driver(Str::lower($origin));

    throw_if(
        $webhookProcessor === null,
        new WebhookException(message: "Could not find a webhook processor for the origin: {$origin}")
    );

    $webhookProcessor->validate($request);

    $webhook = Webhook::create([
        'origin' => $webhookProcessor->getName(),
        'event_name' => $webhookProcessor->getEventName($request->input()),
        'payload' => $request->input(),
    ]);

    Queue::push(new ProcessWebhookJob($webhook));

    if ($webhookProcessor->originExpectsAResponse()) {
        return response()->json(data: $webhookProcessor->generateResponse($webhook));
    }

    return response()->json();
}

Our controller's store method is now doing the following:

  • Resolving a webhook driver using the WebhookManager.
  • Throwing an exception if no webhook processor is found for the request.
  • Validating the request before creating a Webhook record.
  • Creating a Webhook record if validation is successful.
  • Queuing a background job to process the saved webhook.
  • Returning a response to the wehook origin.

By using a background job to process the saved webhook, the webhook origin doesn't have to wait for us to finish processing the webhok before getting a response.

What you'd have to be careful of when setting webhook urls at the origin is using a $origin that would be resolvable by the WebhookManager.

So if we'd be setting our webhook URL with Paystack or Lemon Squeezy, any of the following would be valid because we first convert the $origin to lowercase before using it to resolve a webhook processor using the WebhookManager.

  1. https://our-custom-domain-name.com/webhooks/paystack
  2. https://our-custom-domain-name.com/webhooks/paYSTack
  3. https://our-custom-domain-name.com/webhooks/lemon-squeezy
  4. https://our-custom-domain-name.com/webhooks/LEMON-squeezy

However, I'd strongly advice that you pick a naming convention for the origin variable and stick with it for all third parties that send webhooks to your application. My recommendation though is to always use the first option above.

The last piece of the puzzle is creating the ProcessWebhookJob. Run the command below from the terminal.

php artisan make:job ProcessWebhookJob

Add the following lines of code to the generated job.

<?php

namespace App\Jobs;

use App\Exceptions\WebhookException;
use App\Models\Webhook;
use App\Support\Managers\WebhookManager;
use App\ThirdParty\WebhookProcessors\Contracts\WebhookProcessorInterface;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;

class ProcessWebhookJob implements ShouldQueue, ShouldBeUnique
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    /**
     * The number of times the job may be attempted.
     *
     * @var int
     */
    public int $tries = 4;

    /**
     * Create a new job instance.
     */
    public function __construct(public Webhook $webhook)
    {
    }

    /**
     * Get the unique ID for the job.
     */
    public function uniqueId(): string
    {
        return (string) $this->webhook->id;
    }

    /**
     * Execute the job.
     */
    public function handle(WebhookManager $manager): void
    {
        try {
            /** @var WebhookProcessorInterface|null $webhookProcessor */
            $webhookProcessor = $manager->driver($this->webhook->origin);

            throw_if(
                $webhookProcessor === null,
                new WebhookException(message: "Could not find a processor for webhook with id: {$this->webhook->id}")
            );

            $webhookProcessor->process($this->webhook);

            $this->webhook->update([
                'processed_at' => Carbon::now(),
                'exception' => null,
                'failed_at' => null,
                'tries' => ++$this->webhook->tries
            ]);
        } catch (\Throwable $exception) {
            $this->webhook->update([
                'failed_at' => Carbon::now(),
                'exception' => (string) $exception,
                'tries' => ++$this->webhook->tries
            ]);

            Log::error('Webhook processing failed', ['exception' => $exception]);

            $this->release(Carbon::now()->addMinutes(15));
        }
    }
}

The WebhookManager is available in the job's handle method because the Laravel's service container resolves any class type-hinted on a job's handle method. In the method above, we try to resolve the webhook processor which would be used to process the saved webhook. We then call the process method of the returned webhook processor. Remember that this method is enforced by the WebhookProcessorInterface. We then update the webhook model to reflect that it has been processed.

All of these is wrapped in a try-catch block to ensure that exceptions thrown are caught and logged. We also update the webhook model in the catch block to show that an exception occured during its processing. Finally, this job will be run a maximum number of 4 times as configured using its $tries property. Also, only one instance of this job processing a specific webhook will be on the queue at any point in time because we implement the ShouldBeUnique interface documented here.

Conclusion

Now that we have this setup, processing webhooks from another origin is as simple as doing the following:

  • Creating a processor class for that origin. This class must extend the AbstractWebhookProcessor or implement the WebhookProcessorInterface.
  • Implementing the methods of the interface in the processor created in the previous step.
  • Defining a method in the WebhookManager to dynamically resolve the newly created webhook processor.
  • Adding the necessary config values in services.php for that webhook origin.

The code for this tutorial is available on GitHub for you to read, clone, fork or star. If you have any challenges with the tutorial, I can reached via Twitter/X or LinkedIn. Please don't forget to share if you have found it useful.