Costruire API webhook robuste in Laravel: delivery garantita, retry e sicurezza

Costruire API webhook robuste in Laravel: delivery garantita, retry e sicurezza

A giugno 2025 un'azienda del settore piattaforme marketplace B2B con circa 200 integratori terzi attivi mi ha affidato la riprogettazione del proprio sistema di webhook, dopo due anni di implementazione artigianale che aveva accumulato debito tecnico insopportabile. La piattaforma inviava notifiche a clienti esterni per eventi di business - nuovo ordine, cambio stato spedizione, fattura emessa, documento firmato digitalmente - attraverso un sistema di delivery HTTP che era stato scritto "rapido e sporco" nel 2022 quando gli integratori erano ancora 20, ed era rimasto così fino al 2025 quando erano diventati 200. Il codice consisteva in un singolo job Laravel che, alla ricezione di un evento, faceva un Http::post($client->webhook_url, $payload) sincrono: se il client rispondeva 2xx, bene; se rispondeva errore o andava in timeout, il job falliva, veniva ritentato tre volte dalla configurazione default di Laravel con 30 secondi di backoff fra i tentativi, e poi finiva in failed_jobs dove nessuno lo guardava mai. Il risultato era che circa il 4-5% dei webhook inviati non raggiungeva mai il destinatario per problemi transitori (un riavvio del server del client, un timeout di rete, un burst di carico che rallentava il ricevente), e questi 4-5% erano diventati il contenuto di una quindicina di email di lamentela al mese da parte degli integratori più rigorosi.

Il punto che il CEO della piattaforma non aveva capito, prima della mia consulenza, è che la delivery dei webhook non è opzionale nei contratti B2B maturi. Se il tuo client ha integrato il webhook per generare automaticamente ordini nel suo gestionale, una delivery persa è un ordine che non viene processato, con cascata di conseguenze commerciali. Se il tuo client usa il webhook per aggiornare lo stato di un documento nel suo CRM, una delivery persa è un cliente finale che riceve telefonate sbagliate del team commerciale del tuo client. In sei settimane ho riprogettato il sistema da zero: coda dedicata ad alta priorità per i webhook critici, retry con backoff esponenziale fino a 24 ore di tentativi totali, firma HMAC per garantire autenticità al ricevente, logging completo di ogni tentativo (successo, errore, timeout, response body, duration), dashboard di monitoring real-time che gli integratori possono consultare per verificare lo stato delle loro delivery. Al nono mese dopo il rollout, su un volume di circa 1,8 milioni di webhook inviati al mese, il tasso di delivery effettiva è del 99,94% - il restante 0,06% sono casi dove il client è effettivamente irraggiungibile per ore consecutive e nemmeno 24 ore di retry bastano. Zero lamentele degli integratori negli ultimi otto mesi.

La differenza fra "inviare un POST" e "garantire la delivery": cosa manca nella maggioranza delle implementazioni

Il pattern naïve che vedo in 8 progetti Laravel su 10 è quello che descrivevo nell'apertura: Http::post sincrono in un job, retry Laravel standard, failed_jobs come discarica finale. Questo pattern ha tre problemi strutturali che diventano evidenti solo con il volume. Primo: il retry di Laravel con backoff 30-secondi è completamente inadeguato per internet reale. Un client che ha un rolling restart di un Kubernetes pod può essere irraggiungibile per 90-120 secondi; tre retry a distanza di 30 secondi finiscono prima che il client torni vivo. Secondo: non c'è distinzione fra errori recuperabili (5xx, timeout, connessione rifiutata) e non recuperabili (4xx che indicano problemi permanenti di configurazione del client, come un webhook URL scaduto che ritorna 404). Ritentare un webhook che ritorna 404 per 24 ore è inutile spreco di risorse; non ritentare un webhook che ritorna 503 per una manutenzione pianificata è perdita di dati. Terzo: non c'è tracciamento granulare. Se un integratore segnala "non ho ricevuto il webhook dell'ordine 12345", il team di support non ha strumenti per dire "è stato inviato 3 volte, la prima ha ricevuto timeout dopo 29 secondi, la seconda 502, la terza 200 OK alle 14:32:17, vedi qui i log". Senza questo tracciamento, la conversazione finisce in un classico "non so perché non è arrivato".

Il sistema corretto separa invece tre concetti: la registrazione dell'evento (sempre sincrona e atomica con la transazione di business che lo genera), la pianificazione del tentativo di delivery (asincrona via coda dedicata), e la valutazione del risultato (con decisione esplicita se ritentare, con quale backoff, e per quanto tempo totale). La RFC 9110 che definisce semantiche HTTP standard e documenta ufficialmente tutti i codici di stato dà le regole formali per classificare le risposte in recuperabili e non recuperabili, ed è il riferimento normativo che uso per scrivere la logica di valutazione.

L'architettura: entità Evento, entità WebhookDelivery e coda dedicata

Il primo tassello è la modellazione dei dati. Ogni evento di business che deve generare webhook viene prima salvato in una tabella webhook_events - indipendentemente dal successo o fallimento della delivery. Questa separazione è fondamentale perché l'evento esiste anche se nessun client riesce a riceverlo subito. Il pattern è outbox pattern adattato, familiare a chi ha lavorato con architetture event-driven - descritto nel mio articolo sull'integrazione con Kafka per architetture event-driven in PHP su progetti di PMI per scenari più sofisticati, ma qui implementato in forma più leggera.

La migration Laravel per le tabelle è questa:

<?php
// database/migrations/2025_06_01_000001_create_webhook_tables.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    public function up(): void
    {
        Schema::create('webhook_events', function (Blueprint $table) {
            $table->uuid('id')->primary();
            $table->string('event_type', 100);
            $table->jsonb('payload');
            $table->timestamp('occurred_at');
            $table->index(['event_type', 'occurred_at']);
        });

        Schema::create('webhook_deliveries', function (Blueprint $table) {
            $table->uuid('id')->primary();
            $table->foreignUuid('event_id')->references('id')->on('webhook_events');
            $table->foreignId('client_id')->references('id')->on('webhook_clients');
            $table->enum('status', [
                'pending', 'in_flight', 'delivered', 'failed_retriable', 'failed_permanent'
            ])->default('pending');
            $table->integer('attempts')->default(0);
            $table->timestamp('next_retry_at')->nullable();
            $table->timestamp('delivered_at')->nullable();
            $table->integer('last_response_code')->nullable();
            $table->integer('last_duration_ms')->nullable();
            $table->text('last_error')->nullable();
            $table->timestamps();
            $table->index(['status', 'next_retry_at']);
        });

        Schema::create('webhook_attempts', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->foreignUuid('delivery_id')->references('id')->on('webhook_deliveries');
            $table->timestamp('attempted_at');
            $table->integer('response_code')->nullable();
            $table->integer('duration_ms');
            $table->text('response_body_excerpt')->nullable();
            $table->text('error_message')->nullable();
            $table->index(['delivery_id', 'attempted_at']);
        });
    }
};

Tre scelte meritano una spiegazione. Prima: uso UUID per gli identificatori di evento e delivery invece di integer incrementali, perché questi ID vengono esposti ai client esterni (nel payload del webhook come X-Event-Id header) e gli integer sequenziali rivelano informazioni sul volume interno della piattaforma. Seconda: la tabella webhook_attempts registra ogni tentativo separato con timing e risposta, mantenendo lo storico completo per debugging e audit. Su 1,8M di webhook al mese con una media di 1,3 tentativi per delivery, questa tabella cresce di 2,3M righe al mese; ho configurato un job di archiving che sposta gli attempts più vecchi di 6 mesi in una tabella storica con partizionamento, mantenendo la tabella principale a dimensioni gestibili. Terza: il campo response_body_excerpt salva solo i primi 2KB del body della risposta del client. Alcuni client rispondono con HTML di errore enormi (una pagina di errore generica del web server), e salvare tutto generebbe GB di dati inutili. 2KB è tipicamente sufficiente per catturare il JSON di errore strutturato che ci serve per il debugging.

Stai cercando un Consulente Informatico esperto per progettare o riprogettare un sistema di webhook Laravel con garanzie di delivery, sicurezza HMAC, dashboard per integratori e SLA misurabile per i tuoi client B2B? Nel mio profilo professionale trovi l'esperienza concreta su architetture event-driven, code asincrone, integrazione di piattaforme B2B italiane con decine o centinaia di integratori terzi attivi.

Il retry con backoff esponenziale: la matematica e il jitter

La logica di retry è il cuore tecnico del sistema. Il pattern che applico usa backoff esponenziale con jitter casuale, ispirato al pattern Exponential Backoff documentato ufficialmente nelle Google Cloud API best practices e in molte altre guide di resilienza distribuita. La sequenza di ritentativi che uso per i webhook è: 10 secondi, 1 minuto, 5 minuti, 15 minuti, 1 ora, 3 ore, 6 ore, 12 ore, 24 ore - per un totale di 9 tentativi distribuiti su 24 ore. La distribuzione non lineare è intenzionale: i primi tentativi ravvicinati catturano errori transitori brevi (rete, restart di pod Kubernetes), i tentativi successivi più dilatati catturano errori più prolungati (manutenzioni pianificate del client, problemi infrastrutturali che durano ore).

Il jitter casuale aggiunto a ogni intervallo (tipicamente ±20% del valore base) è un dettaglio cruciale per non creare pattern sincronizzati. Se 200 integratori ricevono lo stesso webhook allo stesso istante e tutti 200 hanno un problema temporaneo che si risolve in 10 secondi, senza jitter tutti i retry partirebbero esattamente insieme al secondo 10, creando un burst di 200 richieste sincrone che potrebbe saturare la rete del ricevente. Con jitter, i 200 retry si distribuiscono fra 8 e 12 secondi, smussando il burst.

Il job Laravel che implementa questa logica è strutturato così:

<?php
// app/Jobs/DeliverWebhook.php
namespace App\Jobs;

use App\Models\WebhookDelivery;
use App\Services\WebhookSignatureService;
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;

    private const BACKOFF_SCHEDULE_SECONDS = [
        10, 60, 300, 900, 3600, 10800, 21600, 43200, 86400,
    ];

    public int $tries = 1; // gestiamo noi la logica retry

    public function __construct(public string $deliveryId) {}

    public function handle(WebhookSignatureService $signer): void
    {
        $delivery = WebhookDelivery::with(['event', 'client'])->findOrFail($this->deliveryId);
        $attempt = $delivery->attempts + 1;
        $payload = $delivery->event->payload;
        $signature = $signer->sign($payload, $delivery->client->webhook_secret);

        $startTime = microtime(true);
        try {
            $response = Http::timeout(30)
                ->withHeaders([
                    'Content-Type' => 'application/json',
                    'User-Agent' => 'Azienda-Webhooks/2.0',
                    'X-Event-Id' => $delivery->event->id,
                    'X-Event-Type' => $delivery->event->event_type,
                    'X-Attempt-Number' => (string) $attempt,
                    'X-Signature-HMAC-SHA256' => $signature,
                    'X-Timestamp' => now()->timestamp,
                ])
                ->post($delivery->client->webhook_url, $payload);

            $durationMs = (int) ((microtime(true) - $startTime) * 1000);
            $this->recordAttempt($delivery, $attempt, $response->status(), $durationMs, null);

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

            // 4xx: non retriabile. 5xx e altri: retriable
            if ($response->clientError()) {
                $delivery->update([
                    'status' => 'failed_permanent',
                    'last_response_code' => $response->status(),
                    'last_error' => 'Client error ' . $response->status(),
                ]);
                return;
            }

            $this->scheduleRetry($delivery, $attempt);
        } catch (\Throwable $e) {
            $durationMs = (int) ((microtime(true) - $startTime) * 1000);
            $this->recordAttempt($delivery, $attempt, null, $durationMs, $e->getMessage());
            $this->scheduleRetry($delivery, $attempt);
        }
    }

    private function scheduleRetry(WebhookDelivery $delivery, int $attempt): void
    {
        if ($attempt >= count(self::BACKOFF_SCHEDULE_SECONDS)) {
            $delivery->update(['status' => 'failed_retriable']);
            return;
        }
        $baseDelay = self::BACKOFF_SCHEDULE_SECONDS[$attempt - 1];
        $jitter = (int) ($baseDelay * (rand(-20, 20) / 100));
        $nextRetryAt = now()->addSeconds($baseDelay + $jitter);
        $delivery->update([
            'status' => 'pending',
            'attempts' => $attempt,
            'next_retry_at' => $nextRetryAt,
        ]);
        self::dispatch($delivery->id)->delay($nextRetryAt);
    }

    private function recordAttempt(
        WebhookDelivery $delivery,
        int $attempt,
        ?int $responseCode,
        int $durationMs,
        ?string $error,
    ): void {
        $delivery->attemptsLog()->create([
            'attempted_at' => now(),
            'response_code' => $responseCode,
            'duration_ms' => $durationMs,
            'error_message' => $error,
        ]);
    }
}

Due dettagli critici. Primo: la distinzione fra $response->clientError() (4xx) e $response->serverError() (5xx). Un 4xx è una risposta chiara del client che dice "non accetto questo payload" - ritentare è inutile e fastidioso. Un 5xx dice "problema temporaneo dal mio lato" - ritentare ha senso. Le eccezioni di connessione (timeout, DNS non risolto, rete irraggiungibile) sono trattate come retriable. Questa classificazione è allineata con la semantica HTTP standardizzata. Secondo: l'uso di $tries = 1 disabilita il retry automatico di Laravel - gestisco io esplicitamente la logica di retry nel metodo scheduleRetry() per avere controllo preciso su backoff, jitter, e classificazione.

Firma HMAC: l'unico modo corretto per autenticare i webhook

La sicurezza dei webhook si riduce a una domanda: come fa il ricevente a essere sicuro che il webhook viene davvero dal servizio legittimo e non da un attaccante che ha scoperto il suo URL? La risposta standard è la firma HMAC. Il pattern: il servizio mittente e il ricevente condividono un segreto; per ogni webhook inviato, il mittente calcola HMAC-SHA256 del body del payload usando il segreto, e lo invia come header X-Signature-HMAC-SHA256; il ricevente ricalcola HMAC-SHA256 sul payload ricevuto con lo stesso segreto, e verifica che corrisponda. Se non corrisponde, il webhook è stato manomesso o viene da un attaccante.

La implementazione in Laravel del servizio di firma è banale:

<?php
// app/Services/WebhookSignatureService.php
namespace App\Services;

class WebhookSignatureService
{
    public function sign(array $payload, string $secret): string
    {
        return hash_hmac('sha256', json_encode($payload), $secret);
    }

    public function verify(array $payload, string $signature, string $secret): bool
    {
        return hash_equals($this->sign($payload, $secret), $signature);
    }
}

L'uso di hash_equals invece del semplice == è critico - previene timing attack dove un attaccante potrebbe inferire byte per byte la firma corretta misurando tempi di risposta. La documentazione PHP di hash_hmac e hash_equals copre in dettaglio questi aspetti di sicurezza criptografica. Il segreto per ogni client viene generato alla sua registrazione (una stringa random di 64 caratteri cifrata in rest nel database), esposto al client una sola volta alla creazione, e mai più recuperabile in chiaro - se lo perde deve richiedere una rotazione, che invalida il vecchio e ne genera uno nuovo. Questo pattern è lo stesso usato da Stripe, GitHub, Slack e ogni servizio serio che espone webhook.

Un dettaglio operativo: ho notato che molti integratori implementano la verifica HMAC in modo leggermente sbagliato, confrontando su JSON rigenerato invece che sul body raw. Se il body originale ha uno spazio in più o la chiave JSON in ordine alfabetico diverso, la firma non matcha. Per evitare questo problema, nel mio sistema firmo il body come stringa JSON canonicalizzata (campi in ordine alfabetico, no whitespace) e documento esplicitamente agli integratori che devono verificare la firma sul body raw ricevuto, non su una ri-serializzazione. La documentazione che consegno agli integratori ha un esempio di codice Python, uno Node.js e uno PHP di verifica corretta, esattamente nella forma che li proteggerà dal problema di mismatch di rappresentazione.

La dashboard per gli integratori: il single point of truth per ogni delivery

Il componente che trasforma il sistema da "funzionale" a "amato dagli integratori" è la dashboard. Ogni integratore accede con le sue credenziali a /integrator-dashboard/webhooks e vede in tempo reale: elenco dei webhook recenti (ultimi 30 giorni), con stato (delivered/pending/failed_permanent/failed_retriable), timestamp del primo e ultimo tentativo, numero di tentativi effettuati, response code dell'ultimo tentativo, durata dell'ultimo tentativo. Può filtrare per tipo di evento, per stato, per intervallo temporale. Per ogni delivery può espandere il dettaglio e vedere la timeline dei tentativi con response body (troncato) di ognuno. Può scaricare il payload originale dell'evento per analisi offline. Può triggerare manualmente un retry se il suo sistema ha avuto un downtime e vuole riprocessare le delivery failed_retriable.

Questa dashboard è stata il singolo cambiamento che ha eliminato il 90% delle email di supporto legate ai webhook. Prima, quando un integratore segnalava "non ho ricevuto il webhook X", il team di support dell'azienda doveva scavare nei log Laravel, correlare per event_id, ricostruire la timeline dei tentativi - tipicamente 30-45 minuti di lavoro a segnalazione. Dopo la dashboard, l'integratore ha direttamente accesso alle stesse informazioni, verifica in 30 secondi che il webhook è stato inviato e ha ricevuto timeout, capisce che il suo server era probabilmente giù in quel momento, e risolve il problema dal suo lato. L'impatto sul costo operativo del team di support è stato misurabile: da 15-20 email di webhook/mese a 1-2.

Monitoring interno: le metriche che il team deve tenere sotto controllo

Dall'altro lato, il team interno dell'azienda ha bisogno di metriche aggregate per capire come sta il sistema complessivo. Le quattro metriche che espongo in una dashboard Grafana dedicata sono: delivery rate (percentuale di delivery andate a buon fine entro 24 ore dal primo tentativo), mean time to deliver (tempo medio tra la creazione dell'evento e la delivery effettiva), attempts per delivery (quanti tentativi in media servono per andare a buon fine), top failing clients (i 10 client con il maggior numero di delivery fallite nelle ultime 24 ore). Queste metriche, alimentate da query PostgreSQL efficienti sulla tabella webhook_deliveries e webhook_attempts, danno una vista operativa completa senza dover aprire i log applicativi.

Sul marketplace del 2025 queste metriche hanno fatto emergere due pattern che nessuno aveva notato prima. Primo: un integratore specifico aveva un tasso di fallimento del 12% costante - molto più alto del 0.6% medio degli altri. Un'indagine ha rivelato che il loro webhook URL era dietro un reverse proxy che aveva un timeout di 10 secondi, e il loro backend impiegava occasionalmente 12-15 secondi a rispondere. Abbiamo comunicato il problema all'integratore, che ha alzato il timeout del reverse proxy, e il tasso di fallimento è sceso al 0.4%. Secondo: il delivery rate medio del lunedì mattina era sensibilmente più basso del resto della settimana - investigando abbiamo scoperto che molti integratori avevano finestre di manutenzione pianificate domenica notte che si estendevano fino alle 07-08 del lunedì, e i webhook inviati in quel intervallo vedevano i loro server ancora non ripristinati. Abbiamo comunicato il fenomeno al team commerciale, che ha incluso nel contratto di servizio una raccomandazione esplicita ai client di finestre di manutenzione minori di 4 ore per minimizzare l'impatto.

Evoluzione continua: come il sistema ha retto il salto a 5x il volume

Nove mesi dopo il rollout, l'azienda ha avuto una crescita del business che ha portato il volume di webhook da 1,8M/mese a circa 9M/mese (un fattore 5x). Il sistema ha assorbito l'aumento senza modifiche architetturali: il collo di bottiglia principale era la coda dedicata webhook su Redis, che ha richiesto un upgrade della memoria RAM del Redis dedicato da 4GB a 12GB; il secondo collo era la tabella webhook_attempts che stava crescendo più rapidamente del previsto, risolto accelerando la rotazione verso la tabella storica da 6 mesi a 3 mesi; il terzo è stato aggiungere un secondo worker Horizon dedicato ai soli webhook, separato dal worker per altre code - per evitare che un picco di webhook saturasse anche le code di altri job. Queste modifiche incrementali sono state fatte in totale 8-10 giornate-uomo distribuite su tre mesi, senza downtime mai.

Se gestisci una piattaforma PHP/Laravel che invia webhook a integratori terzi e stai notando aumento di lamentele per delivery perse, oppure stai progettando un nuovo servizio che esporrà webhook e vuoi partire con le fondamenta giuste per non accumulare debito operativo, contattami per una consulenza architetturale: in una settimana di lavoro analizzo il tuo pattern di eventi, dimensiono la coda dedicata, disegno lo schema delle tabelle per tracciamento completo, implemento la logica di retry con backoff esponenziale e jitter calibrata sui tuoi volumi, aggiungo la firma HMAC per sicurezza e ti consegno una dashboard minima per i tuoi integratori - con la certezza che da quel momento la delivery dei webhook smetterà di essere un problema operativo e diventerà un vantaggio competitivo misurabile nei tuoi contratti di servizio.

Ultima modifica: