Skip to content
APIs & Integrations•14 min read•Published September 18, 2026•Updated 9/27/2026

Building a Reliable Webhook Delivery System in Laravel

A webhook that fires once and hopes isn't an integration. It's a support ticket waiting to happen the first time a customer's server is down.

Aqib Javaid
Aqib Javaid
Senior Full-Stack Engineer
Dark tech blog cover of a Laravel webhook pipeline with retries, backoff, a dead-letter queue, and an HMAC signing snippet

Introduction: Sending a Webhook Is the Easy 10%#

Http::post($url, $payload) is one line. So is the tutorial version of "we support webhooks" — a model observer fires, an HTTP call goes out, the feature ships, and it works great in the demo where the receiving endpoint is a laptop running php artisan serve and answering in 40ms. Then it goes to production, where the receiving endpoint belongs to a customer, not you: their server is slow, or mid-deploy, or behind a firewall having a bad day, or just returns a 500 because someone on their team pushed a bug an hour ago. Your webhook call times out, or gets a non-2xx response, and the event — a payment confirmed, a review answered, a screen's content updated — is simply gone, because nothing wrote down that it needed to be sent again.

That's the part the one-liner doesn't show you. A webhook isn't an HTTP request, it's a promise: when this happens in my system, I will tell you, and I will keep trying until you've actually heard it or we've both given up. Keeping that promise means treating every outbound event as a durable record, not a fire-and-forget call — something with a status, an attempt count, a next-retry time, and a place for both sides to see what happened when it didn't go cleanly the first time.

I've built outbound webhook and integration-notification systems for products where that promise actually mattered: SignageFlow, the digital signage SaaS I built, where an API-driven system pushes content and status events to third-party apps a customer has connected, and a dropped notification means their integration silently goes stale; ReplyVibe, the brand reputation platform where negative-review alerts and ticket updates need to reach a customer's support or CRM tooling reliably, not "usually"; and Expreco, the logistics quoting system, where a ticketing flow with email integration depends on delivery actually completing, not just being attempted. In every one of these, the receiving side was someone else's infrastructure — which meant I had zero control over its uptime and had to build the sending side to survive that.

This is a breakdown of how to build a webhook delivery system in Laravel that holds up: durable delivery records instead of inline HTTP calls, HMAC-signed payloads so customers can trust what they receive, exponential backoff with jitter and a real dead-letter path, and per-endpoint rate limiting so one customer's broken server can't starve the queue that every other customer depends on.

Architecture: What a Production Webhook System Actually Needs#

A webhook sender that only has to work once, against a healthy endpoint, is a solved problem. A webhook sender that has to survive slow endpoints, dead endpoints, malicious endpoints, and its own queue backing up is four separate design decisions — and almost every "our webhooks are unreliable" bug report traces back to one of the four never having been made deliberately.

1. Every outbound event becomes a durable delivery record, not just an HTTP call#

The foundation is a webhook_deliveries table. Before anything gets sent over the wire, the event, the destination, and its current state are written to the database — so a crashed worker, a timed-out request, or a queue restart never loses the fact that this event still needs to go out.

php
Schema::create('webhook_deliveries', function (Blueprint $table) {
    $table->id();
    $table->foreignId('tenant_id')->constrained();
    $table->foreignId('webhook_endpoint_id')->constrained();
    $table->uuid('event_id')->unique();
    $table->string('event_type');
    $table->json('payload');
    $table->unsignedTinyInteger('attempts')->default(0);
    $table->string('status')->default('pending'); // pending, delivered, retrying, dead
    $table->unsignedSmallInteger('last_response_code')->nullable();
    $table->text('last_response_body')->nullable();
    $table->timestamp('next_attempt_at')->nullable();
    $table->timestamp('delivered_at')->nullable();
    $table->timestamps();

    $table->index(['status', 'next_attempt_at']);
});

event_id is a UUID generated once, at the moment the event happens — not re-generated on retry — because it's what the receiving side uses to deduplicate. The same discipline shows up on the consuming side of an integration, covered in the API integration patterns I wrote about earlier: a payment webhook that Stripe redelivers is only safe because the receiver checks the event ID before processing, and now your own product owes its customers that same guarantee going the other direction.

php
class WebhookEndpoint extends Model
{
    public function deliver(string $eventType, array $payload): WebhookDelivery
    {
        $delivery = $this->deliveries()->create([
            'tenant_id' => $this->tenant_id,
            'event_id' => (string) Str::uuid(),
            'event_type' => $eventType,
            'payload' => $payload,
            'status' => 'pending',
            'next_attempt_at' => now(),
        ]);

        DispatchWebhookDelivery::dispatch($delivery)->onQueue('webhooks');

        return $delivery;
    }
}

The row is committed before the job is dispatched, and the job carries the delivery's ID, not the payload — the same lesson from building queue architecture for Laravel SaaS: a job should reference state, not smuggle a snapshot of it through the queue, because the retry needs to read the current attempt count and status, not whatever the payload looked like when it was first queued.

2. Signing outbound payloads so customers can trust what you send#

A webhook a customer can't verify is a webhook they can't safely act on — anyone who guesses or discovers the endpoint URL could POST a fake "subscription cancelled" event and trigger real behavior on their end. Every payload gets an HMAC-SHA256 signature, computed against a secret unique to that customer's endpoint, and sent as a header alongside a timestamp that protects against replay:

php
class SignsWebhookPayloads
{
    public function sign(WebhookEndpoint $endpoint, string $body, int $timestamp): string
    {
        $signedContent = $timestamp . '.' . $body;

        return hash_hmac('sha256', $signedContent, $endpoint->signing_secret);
    }
}
php
$timestamp = now()->getTimestamp();
$body = json_encode([
    'event_id' => $delivery->event_id,
    'event_type' => $delivery->event_type,
    'created_at' => $delivery->created_at->toIso8601String(),
    'data' => $delivery->payload,
]);

$signature = app(SignsWebhookPayloads::class)->sign($delivery->webhookEndpoint, $body, $timestamp);

$response = Http::withHeaders([
    'Content-Type' => 'application/json',
    'X-Webhook-Signature' => "t={$timestamp},v1={$signature}",
    'X-Webhook-Event-Id' => $delivery->event_id,
])->timeout(8)->post($delivery->webhookEndpoint->url, json_decode($body, true));

I publish the verification snippet in the API docs for exactly this shape, so a customer receiving a SignageFlow or ReplyVibe webhook can confirm both that the payload wasn't tampered with and that it isn't a replayed request from three days ago:

php
// what the receiving customer's code does
$timestamp = explode(',', $request->header('X-Webhook-Signature'))[0];
$timestamp = (int) str_replace('t=', '', $timestamp);

if (abs(time() - $timestamp) > 300) {
    abort(400, 'Signature timestamp too old');
}

$expected = hash_hmac('sha256', $timestamp . '.' . $request->getContent(), $secret);
$provided = str_replace('v1=', '', explode(',', $request->header('X-Webhook-Signature'))[1]);

if (! hash_equals($expected, $provided)) {
    abort(401, 'Invalid signature');
}

hash_equals() rather than === matters here for the same reason it matters anywhere signatures get compared — a naive string comparison leaks timing information about how many leading bytes matched, which is a real side channel against a secret worth protecting.

3. Retries need exponential backoff with jitter — and a real dead-letter path#

A fixed retry interval is how you turn one flaky customer endpoint into a self-inflicted traffic spike: if every failed delivery retries in exactly 60 seconds, and an endpoint goes down for ten minutes, every event queued during that window retries in the same synchronized burst the moment it comes back up. Exponential backoff with jitter spreads that out, and a hard attempt ceiling is what turns "endlessly retrying into the void" into a deliberate, visible dead-letter state instead of a queue that silently fills up forever.

php
class DispatchWebhookDelivery implements ShouldQueue
{
    use Queueable, InteractsWithQueue, SerializesModels;

    public int $tries = 1; // retries are modeled explicitly, not via queue re-attempts

    public function __construct(public WebhookDelivery $delivery) {}

    public function handle(SignsWebhookPayloads $signer): void
    {
        if ($this->delivery->status === 'delivered') {
            return; // already succeeded on a prior attempt racing this one
        }

        try {
            $response = $this->send($signer);

            if ($response->successful()) {
                $this->delivery->update([
                    'status' => 'delivered',
                    'delivered_at' => now(),
                    'last_response_code' => $response->status(),
                ]);
                return;
            }

            $this->scheduleRetry($response->status(), $response->body());
        } catch (ConnectionException $e) {
            $this->scheduleRetry(null, $e->getMessage());
        }
    }

    protected function scheduleRetry(?int $statusCode, string $responseBody): void
    {
        $attempts = $this->delivery->attempts + 1;
        $maxAttempts = 7;

        if ($attempts >= $maxAttempts) {
            $this->delivery->update([
                'status' => 'dead',
                'attempts' => $attempts,
                'last_response_code' => $statusCode,
                'last_response_body' => Str::limit($responseBody, 2000),
            ]);

            WebhookDeliveryFailed::dispatch($this->delivery);
            return;
        }

        $baseSeconds = min(2 ** $attempts, 3600); // 2s, 4s, 8s ... capped at 1h
        $jitter = random_int(0, (int) ($baseSeconds * 0.3));

        $this->delivery->update([
            'status' => 'retrying',
            'attempts' => $attempts,
            'last_response_code' => $statusCode,
            'last_response_body' => Str::limit($responseBody, 2000),
            'next_attempt_at' => now()->addSeconds($baseSeconds + $jitter),
        ]);
    }
}

Retries are modeled as application state (next_attempt_at, attempts) rather than the queue driver's own retry mechanism, deliberately — Laravel's backoff()/tries on the job class controls queue-level retry for infrastructure failures, but a webhook retry is a business decision with its own schedule, cap, and dead-letter outcome that needs to survive independently of whatever the queue driver does. A scheduled command sweeps for what's due and re-dispatches:

php
class DispatchDueWebhookRetries extends Command
{
    protected $signature = 'webhooks:dispatch-due';

    public function handle(): void
    {
        WebhookDelivery::where('status', 'retrying')
            ->where('next_attempt_at', '<=', now())
            ->chunkById(200, function ($deliveries) {
                foreach ($deliveries as $delivery) {
                    DispatchWebhookDelivery::dispatch($delivery)->onQueue('webhooks');
                }
            });
    }
}

WebhookDeliveryFailed — dispatched once a delivery hits dead — is where the dead-letter path earns its keep: it's a real event I listen for to notify the tenant (a banner in their dashboard: "we couldn't reach your endpoint after 7 attempts"), not a row that quietly rots in a table nobody looks at. A dead delivery is also replayable — a "Redeliver" button in the endpoint's settings resets attempts to 0 and status to pending, so a customer who's fixed their server doesn't need to wait for the next real event to confirm the integration works again.

4. Per-endpoint rate limiting so one dead server doesn't starve the queue#

Without a limit, a customer endpoint that's timing out on every request can absorb a disproportionate share of your webhook workers — each attempt blocks a worker for up to the HTTP timeout, and if enough events are queued for that one bad endpoint, deliveries to every other, perfectly healthy endpoint start queuing up behind them. A Redis-backed token bucket per endpoint, the same primitive used for Redis stampede protection elsewhere in the stack, keeps one struggling integration from degrading service to everyone else:

php
class EndpointRateLimiter
{
    public function attempt(WebhookEndpoint $endpoint, int $maxPerMinute = 60): bool
    {
        $key = "webhook:ratelimit:{$endpoint->id}";

        $count = Redis::connection('cache')->incr($key);

        if ($count === 1) {
            Redis::connection('cache')->expire($key, 60);
        }

        return $count <= $maxPerMinute;
    }
}

If an endpoint is over its budget, the job releases itself back onto the queue with a short delay instead of blocking a worker on a call that's likely to fail anyway — the same ->release() pattern from queued third-party API calls, applied to the outbound side this time.

Step-by-Step: Building the Delivery Pipeline#

  1. Model webhook endpoints per tenant, with their own signing secret and subscribed event types. A tenant registers one or more endpoint URLs and chooses which event types they care about — WebhookEndpoint belongsTo Tenant, hasMany WebhookDeliveries, scoped through the same tenant-isolation discipline as everything else in a multi-tenant Laravel app, because a signing secret leaking across tenants is exactly the kind of boundary failure that turns into a real incident.
  1. Fire events through a single dispatch point, never scattered across controllers. A model observer or domain event listener is the one place guaranteed to run regardless of which code path triggered the change — on ReplyVibe, a ReviewRepliedTo event fans out to every subscribed endpoint for that tenant, rather than each controller action that can produce a reply remembering to notify webhooks itself.
php
class ReviewObserver
{
    public function updated(Review $review): void
    {
        if ($review->wasChanged('replied_at')) {
            $review->tenant->webhookEndpoints()
                ->subscribedTo('review.replied')
                ->each(fn (WebhookEndpoint $endpoint) => $endpoint->deliver('review.replied', [
                    'review_id' => $review->id,
                    'sentiment' => $review->sentiment,
                    'replied_at' => $review->replied_at->toIso8601String(),
                ]));
        }
    }
}
  1. Dispatch through a queued job on a dedicated queue, with a strict timeout on the outbound call. webhooks gets its own queue, separate from application jobs, so a burst of webhook traffic can't delay time-sensitive work like email sending or report generation — the same isolation principle from Laravel queue architecture applied specifically to outbound calls that depend on infrastructure you don't control.
  1. Record the real outcome, sign the payload, and schedule the retry deterministically using the patterns above — the delivery row is the source of truth for what happened, not the job's return value or a log line that scrolls out of retention.
  1. Expose delivery logs and manual redelivery to the customer, not just to your own support team. A settings page showing the last 50 deliveries for an endpoint — status, response code, timestamp, and a "Redeliver" action — turns "webhooks aren't working" from a support ticket into something a customer can diagnose and fix themselves half the time.
  1. Circuit-break endpoints that are consistently failing, rather than retrying them at full cost indefinitely. If an endpoint's last 20 deliveries all failed, pause new deliveries to it for an hour and surface that state clearly — better than quietly burning through retry budget against a server everyone already knows is down.
php
class WebhookEndpoint extends Model
{
    public function isCircuitOpen(): bool
    {
        $recentFailures = $this->deliveries()
            ->latest()
            ->limit(20)
            ->pluck('status');

        return $recentFailures->count() === 20 && $recentFailures->every(fn ($s) => $s !== 'delivered');
    }
}

Real-World Pitfalls to Avoid#

Sending the webhook inline, inside the request or transaction that triggered it. An HTTP call to infrastructure you don't control has no place blocking a user-facing request — a customer's slow endpoint becomes your slow API response. Always queue it, even when it feels like overkill for something that "usually" responds in 200ms.

No idempotency guidance for the receiver, and no idempotency guarantee from the sender. If your own retry logic can send the same event twice — which it can, the instant a response is lost after the receiving server actually processed it but before you got the 200 back — the receiver needs a stable event_id to dedupe against. Document it, and never regenerate the ID on retry.

Treating a 3xx redirect as success. A webhook endpoint that's been moved and now 301s to a new URL isn't succeeding — Http::post() follows redirects by default, which can silently mask a misconfigured customer endpoint for weeks. Disable automatic redirect-following on webhook calls and treat a redirect as a failure worth surfacing.

No maximum payload size or timeout, so one slow event blocks the whole worker pool. An Http::timeout(8) call that actually hangs for 30 seconds because of a misconfigured client is a worker that isn't processing anything else in the meantime. Set a real timeout, and size-check payloads before serializing them — nobody's webhook endpoint should be receiving a 40MB request body.

Notifying on every field change instead of coalescing. SignageFlow's content-sync events used to fire once per field update during a bulk content push, which meant a single playlist edit could fan out dozens of near-identical webhook calls to the same endpoint in the same second. Debouncing related changes into one event within a short window — a few hundred milliseconds — cut outbound volume dramatically without losing any information the receiver actually needed.

A dead-letter table nobody looks at. Marking a delivery dead after the retry budget is exhausted is only half the job — without a WebhookDeliveryFailed listener that actually notifies someone (the tenant, your own on-call, both), dead deliveries just accumulate silently until a customer notices their integration stopped working weeks ago.

Key Takeaways#

A webhook system earns the word "reliable" when it's built as a small distributed system with its own state machine, not a Http::post() call bolted onto a model event.

  • Every outbound event is a durable delivery record — written before the HTTP call happens, not represented only by a queued job's payload
  • Payloads are HMAC-signed with a timestamp, so customers can verify authenticity and reject replays, using hash_equals() for the comparison
  • Retries use exponential backoff with jitter and a hard attempt ceiling, ending in a real, actionable dead-letter state — never an endless retry loop or a silently dropped event
  • Per-endpoint rate limiting and circuit-breaking keep one customer's broken server from degrading delivery to every other tenant
  • Delivery logs and manual redelivery are customer-facing, not just something your own support team can see internally

I've built this discipline into a signage platform whose third-party integrations depend on events actually arriving, a brand-reputation platform where alert delivery reliability is part of the product promise, and a logistics ticketing flow where a dropped notification is a dropped customer conversation. The event payloads change; the requirement that "we sent it" mean the same thing as "they got it" doesn't.

If your product is sending webhooks with a Http::post() and a prayer — or you're scaling an integration surface that customers are starting to depend on — get in touch about your webhook architecture or see the full case studies from platforms running these delivery patterns in production today.

Share this technical insight with your network

Share to LinkedIn or Facebook with key takeaways, featured media, and direct links.

📁 Production Case Study

Case Study: Multi-Carrier Bulk SIM Activation Platform

Wireless dealers and distributors needed to activate SIMs and eSIMs across a dozen-plus disparate carrier and MVNO backends one line at a time; I built a bulk CSV-driven activation engine inside the existing CelleUp dealer platform that validates multi-tier dealer funds, routes each row to the correct carrier API, processes activations asynchronously with retries, and streams live progress back to the dealer.

Related Technical Articles

View all articles →
✦ Let's Build Together

Have a complex technical project in mind?

Available for full-stack engineering, performance audits, cloud deployments, and high-concurrency systems architecture.

Need a web or software development partner?

Tell me what you’re building, what’s getting in the way, and where you need help. Whether you need a custom web application, SaaS platform, API integration, or full-stack development, I’ll give you a clear answer on scope, cost, and timeline usually within one business day.

AqibJavaid

Senior Full-Stack Engineer building backend systems, cloud infrastructure and product platforms for teams that need them to stay up.

Available for new projects

Get in touch

© 2026 Aqib Javaid. All rights reserved.

Built and maintained by Aqib Javaid