php 118 lines · 3 tabs

Debounce Duplicate Webhooks in Laravel by Dispatching a Delayed Queued Job

Shared by codesnips Jul 2026
3 tabs
<?php

namespace App\Http\Controllers;

use App\Jobs\ProcessWebhook;
use App\Support\WebhookSignature;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;

class WebhookController extends Controller
{
    public function __construct(private WebhookSignature $signature)
    {
    }

    public function handle(Request $request)
    {
        if (! $this->signature->valid($request)) {
            return response()->json(['error' => 'invalid signature'], 401);
        }

        $payload = $request->json()->all();
        $key = $this->debounceKey($payload);

        $token = Cache::store('redis')->increment("{$key}:token");
        Cache::store('redis')->put("{$key}:payload", $payload, now()->addMinutes(10));

        ProcessWebhook::dispatch($key, $token)->delay(now()->addSeconds(5));

        return response()->json(['status' => 'queued'], 202);
    }

    private function debounceKey(array $payload): string
    {
        $objectId = data_get($payload, 'data.object.id', 'unknown');
        $type = data_get($payload, 'type', 'event');

        return "webhook:{$type}:{$objectId}";
    }
}
3 files · php Explain with highlit

Some providers fire a burst of webhooks for what is logically a single change — a Stripe subscription, a Shopify order, a GitHub push that triggers three edits in a second. Reacting to every delivery wastes work and can produce inconsistent results when the events race. This snippet debounces those bursts: the controller validates the signature, records the latest payload, and schedules a single delayed job that only runs the expensive work once the noise has settled.

In WebhookController, handle() first verifies the provider signature via the injected WebhookSignature guard, returning 401 early on failure so nothing gets queued for a spoofed request. The stable identity of the resource is derived in debounceKey() from the event's object.id, not the delivery id, because each delivery has a unique id but the same object. The latest payload is stashed in the cache under that key, and a ProcessWebhook job is dispatched with ->delay(now()->addSeconds(5)). Every fresh delivery bumps a monotonic token and overwrites the stored payload, so several rapid deliveries all point at the same key.

The debounce itself lives in ProcessWebhook. Each job carries the token it was dispatched with; when it finally fires, handle() compares its token against the current value in the cache. If a newer delivery arrived in the delay window, the stored token is higher, the job sees it is stale, and returns without doing anything. Only the last-dispatched job for a key survives, which is the classic trailing-edge debounce — the work runs once, using the freshest payload, after the burst stops.

Using the cache (Cache::store('redis')) rather than the payload passed into the job matters: the job reads whatever is current at execution time, so it always processes the newest data even though it was scheduled by an earlier delivery. uniqueId() and the ShouldBeUnique contract prevent piling up identical jobs, and middleware() adds WithoutOverlapping so two workers can never process the same resource concurrently. Trade-offs: the fixed 5-second delay adds latency and cannot debounce bursts longer than that window, and a lost cache entry degrades to processing every delivery. For most webhook fan-in problems this is a cheap, reliable way to collapse chatter into a single, correct update.


Related snips

ruby
timestamp = request.headers.fetch('X-Signature-Timestamp')
signature = request.headers.fetch('X-Signature')
payload = request.raw_post

data = "#{timestamp}.#{payload}"
expected = OpenSSL::HMAC.hexdigest('SHA256', ENV.fetch('WEBHOOK_SECRET'), data)

HMAC signed API requests for webhook and partner integrity

hmac api-signing webhooks
by Kai Nakamura 2 tabs
typescript
export interface RetryOptions {
  retries: number;
  baseMs: number;
  maxMs: number;
  signal?: AbortSignal;
  onRetry?: (attempt: number, delay: number, err: unknown) => void;

Exponential backoff with jitter for retries

typescript reliability retry
by codesnips 2 tabs
go
package dbutil

import (
  "context"

  "github.com/jackc/pgconn"

Retry Postgres serialization failures with bounded attempts

go postgres transactions
by Leah Thompson 1 tab
ruby
require "csv"

class PeopleCsvStream
  include Enumerable

  HEADERS = %w[id full_name email signed_up_at plan].freeze

Resilient CSV Export as a Streamed Response

rails performance streaming
by codesnips 3 tabs
javascript
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["form"]
  static values = { delay: { type: Number, default: 250 } }

Debounced live search with Stimulus + Turbo Streams

rails hotwire stimulus
by codesnips 4 tabs
ruby
json.array! @posts do |post|
  json.cache! ['v1', post], expires_in: 1.hour do
    json.id post.id
    json.title post.title
    json.excerpt post.excerpt
    json.published_at post.published_at

Fragment caching for expensive JSON serialization

rails caching performance
by Alex Kumar 1 tab

Share this code

Here's the card — post it anywhere.

Debounce Duplicate Webhooks in Laravel by Dispatching a Delayed Queued Job — share card
Link copied