Laravel Magazine
Building a Webhook Dispatcher With Retries and Signature Verification in Laravel

Building a Webhook Dispatcher With Retries and Signature Verification in Laravel

Eric Van Johnson ·

If you've ever bolted webhooks onto an app as an afterthought, you know how fast it turns into a mess. A customer's endpoint goes down for ten minutes, your Http::post() call throws, and now you're missing an event with no record it ever happened. Multiply that by a few hundred integrations and you've got a support queue full of "did you send this?" tickets.

This tutorial builds a small but production-worthy webhook dispatcher for a fictional SaaS called PHP Architect. We'll cover signed payloads so subscribers can trust the source, queued delivery with exponential backoff so a flaky endpoint doesn't block your app, and a delivery log so you can actually answer "did you send this?" without guessing.

What we're building

  • A webhook_endpoints table so each customer can register one or more URLs
  • A webhook_deliveries table that logs every attempt, response code, and body
  • An HMAC signature header so receivers can verify the payload came from us
  • A queued job with retry and backoff baked in
  • A dispatcher service you call from anywhere in the app: WebhookDispatcher::send('order.shipped', $order)

Step 1: Migrations

Schema::create('webhook_endpoints', function (Blueprint $table) {
    $table->id();
    $table->foreignId('team_id')->constrained();
    $table->string('url');
    $table->string('secret');
    $table->json('events'); // e.g. ["order.shipped", "order.refunded"]
    $table->boolean('is_active')->default(true);
    $table->timestamps();
});

Schema::create('webhook_deliveries', function (Blueprint $table) {
    $table->id();
    $table->foreignId('webhook_endpoint_id')->constrained();
    $table->string('event');
    $table->json('payload');
    $table->unsignedSmallInteger('response_status')->nullable();
    $table->text('response_body')->nullable();
    $table->unsignedTinyInteger('attempts')->default(0);
    $table->string('status')->default('pending'); // pending, delivered, failed
    $table->timestamps();
});

Nothing exotic here. The events column stores a JSON array of event names each endpoint cares about, so we don't fire webhooks the customer never subscribed to.

Step 2: The models

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class WebhookEndpoint extends Model
{
    protected $fillable = ['team_id', 'url', 'secret', 'events', 'is_active'];

    protected $casts = [
        'events' => 'array',
        'is_active' => 'boolean',
    ];

    public function subscribesTo(string $event): bool
    {
        return $this->is_active && in_array($event, $this->events, true);
    }
}
namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class WebhookDelivery extends Model
{
    protected $fillable = [
        'webhook_endpoint_id', 'event', 'payload',
        'response_status', 'response_body', 'attempts', 'status',
    ];

    protected $casts = ['payload' => 'array'];

    public function endpoint()
    {
        return $this->belongsTo(WebhookEndpoint::class, 'webhook_endpoint_id');
    }
}

Step 3: Signing the payload

Never send an unsigned webhook. Anyone who guesses the URL can spam your customer's endpoint pretending to be you. We'll sign the raw JSON body with HMAC SHA-256 using the endpoint's stored secret, the same pattern Stripe and GitHub use.

namespace App\Services;

class WebhookSigner
{
    public static function sign(string $payloadJson, string $secret): string
    {
        return hash_hmac('sha256', $payloadJson, $secret);
    }
}

On the receiving end, PHP Architect's customers would verify it like this (worth including in your API docs):

$signature = hash_hmac('sha256', $request->getContent(), $storedSecret);

if (! hash_equals($signature, $request->header('X-PhpArchitect-Signature'))) {
    abort(401, 'Invalid signature.');
}

hash_equals() matters here. A regular === comparison leaks timing information that makes the signature guessable byte by byte. Don't skip it.

Step 4: The delivery job

This is where retries and backoff live. Laravel's queue system already knows how to retry failed jobs, we just need to configure it sensibly.

namespace App\Jobs;

use App\Models\WebhookDelivery;
use App\Services\WebhookSigner;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;

class DeliverWebhook implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 5;

    public function __construct(public WebhookDelivery $delivery) {}

    public function backoff(): array
    {
        // 30s, 2m, 10m, 30m, then give up
        return [30, 120, 600, 1800];
    }

    public function handle(): void
    {
        $endpoint = $this->delivery->endpoint;
        $body = json_encode($this->delivery->payload);
        $signature = WebhookSigner::sign($body, $endpoint->secret);

        $response = Http::withBody($body, 'application/json')
            ->withHeaders(['X-PhpArchitect-Signature' => $signature])
            ->timeout(8)
            ->post($endpoint->url);

        $this->delivery->increment('attempts');
        $this->delivery->update([
            'response_status' => $response->status(),
            'response_body' => substr($response->body(), 0, 2000),
            'status' => $response->successful() ? 'delivered' : 'pending',
        ]);

        if (! $response->successful()) {
            throw new \RuntimeException("Webhook delivery failed with status {$response->status()}");
        }
    }

    public function failed(\Throwable $exception): void
    {
        $this->delivery->update(['status' => 'failed']);
    }
}

Throwing an exception on a non-2xx response is what triggers Laravel's retry mechanism. The backoff() array gives each retry attempt a growing delay so a hiccup doesn't turn into a hammering loop against someone's server.

Step 5: The dispatcher service

This is the entry point you'll call from controllers, model observers, or event listeners.

namespace App\Services;

use App\Jobs\DeliverWebhook;
use App\Models\WebhookDelivery;
use App\Models\WebhookEndpoint;

class WebhookDispatcher
{
    public static function send(string $event, array $payload, ?int $teamId = null): void
    {
        WebhookEndpoint::query()
            ->when($teamId, fn ($q) => $q->where('team_id', $teamId))
            ->where('is_active', true)
            ->get()
            ->filter(fn (WebhookEndpoint $endpoint) => $endpoint->subscribesTo($event))
            ->each(function (WebhookEndpoint $endpoint) use ($event, $payload) {
                $delivery = WebhookDelivery::create([
                    'webhook_endpoint_id' => $endpoint->id,
                    'event' => $event,
                    'payload' => $payload,
                ]);

                DeliverWebhook::dispatch($delivery);
            });
    }
}

Now firing a webhook anywhere in your app is one line:

WebhookDispatcher::send('order.shipped', [
    'order_id' => $order->id,
    'tracking_number' => $order->tracking_number,
], teamId: $order->team_id);

Hook it into an OrderShipped event listener and you've decoupled "something happened" from "who needs to know about it."

Step 6: Testing it

Laravel's Http::fake() makes this painless. Fake the response, dispatch, run the queue, and assert.

use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;

it('records a successful delivery', function () {
    Http::fake(['*' => Http::response('ok', 200)]);

    $endpoint = WebhookEndpoint::factory()->create([
        'events' => ['order.shipped'],
    ]);

    WebhookDispatcher::send('order.shipped', ['order_id' => 1], $endpoint->team_id);

    $delivery = WebhookDelivery::first();

    (new DeliverWebhook($delivery))->handle();

    expect($delivery->refresh()->status)->toBe('delivered');
});

A few things to add before shipping this for real

Build a small admin screen showing recent deliveries with their status and response body, it turns "why didn't my webhook fire" from a database query into a support agent's five second lookup. Add a manual "redeliver" button that just re-dispatches DeliverWebhook for an existing delivery row. And if you're delivering at any real volume, put a per-endpoint rate limit around the dispatch loop so one aggressive customer integration can't monopolize your queue workers.

None of this is complicated in isolation, but together it's the difference between webhooks that work and webhooks you have to apologize for.

Stay Updated

Subscribe to our newsletter

Get latest news, tutorials, community articles and podcast episodes delivered to your inbox.

Weekly articles
We send a new issue of the newsletter every week on Friday.
No spam
We'll never share your email address and you can opt out at any time.