<?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}";
}
}
<?php
namespace App\Jobs;
use App\Services\SubscriptionSync;
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\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Cache;
class ProcessWebhook implements ShouldQueue, ShouldBeUnique
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 5;
public int $backoff = 10;
public function __construct(public string $key, public int $token)
{
}
public function handle(SubscriptionSync $sync): void
{
$current = (int) Cache::store('redis')->get("{$this->key}:token", 0);
// A newer delivery superseded this one during the delay window.
if ($this->token < $current) {
return;
}
$payload = Cache::store('redis')->get("{$this->key}:payload");
if ($payload === null) {
return;
}
$sync->apply($payload);
}
public function uniqueId(): string
{
return $this->key;
}
public function middleware(): array
{
return [(new WithoutOverlapping($this->key))->releaseAfter(30)];
}
}
<?php
namespace App\Support;
use Illuminate\Http\Request;
class WebhookSignature
{
public function __construct(private string $secret)
{
}
public function valid(Request $request): bool
{
$header = $request->header('X-Signature');
if (! is_string($header) || $header === '') {
return false;
}
$expected = hash_hmac('sha256', $request->getContent(), $this->secret);
return hash_equals($expected, $header);
}
}
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
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
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
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
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
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
Share this code
Here's the card — post it anywhere.