Torna al blog
Webhook: Firmare, Verificare e Ritentare le Notifiche Senza Perdere Eventi

Webhook: Firmare, Verificare e Ritentare le Notifiche Senza Perdere Eventi

8 min di lettura1 visualizzazioni
di Marco Salmaso
Un webhook sembra semplice: ricevi una POST, leggi il JSON, fai qualcosa. In produzione emergono firme da verificare, duplicati, retry e ordini saltati. Guida pratica a firmare, verificare ed elaborare gli eventi senza perdere nulla.

Un webhook è una chiamata HTTP che un sistema esterno fa verso un tuo endpoint pubblico nel momento in cui accade qualcosa di rilevante: un ordine confermato, una fattura emessa, un file caricato, un pagamento fallito. È il modo in cui due applicazioni si parlano senza che nessuno debba chiedere “è successo niente?” ogni trenta secondi.

Sulla carta è semplice: ricevi una POST, leggi il JSON, fai qualcosa. In produzione diventa subito meno semplice, perché il webhook introduce tre problemi nuovi: un endpoint esposto a Internet, una consegna che può fallire o duplicarsi, un ordine che nessuno garantisce. Questo articolo mette in fila le contromisure pratiche, sia dal lato di chi invia sia dal lato di chi riceve.

Polling, push e il prezzo della comodità

Con il polling il tuo sistema interroga periodicamente l'API del fornitore (“ci sono ordini nuovi dall'ultima volta?”). Vantaggi: controllo totale, nessun endpoint pubblico, nessun segreto condiviso. Svantaggi: latenza legata all'intervallo, traffico sprecato, e limiti di rate che prima o poi ti fermano.

Con il webhook è il fornitore a chiamarti. Vantaggi: latenza bassa, zero polling inutile. Svantaggi: devi avere un endpoint pubblico, devi difenderlo, e devi gestire i fallimenti dalla parte sbagliata della rete.

Nella pratica la scelta non è binaria: un webhook come trigger e un polling di riconciliazione periodico come rete di sicurezza è spesso la combinazione più robusta. Il webhook ti dà la reattività, il job notturno ti dice se hai perso qualcosa.

Il contratto: cosa deve garantire chi invia

Prima di scrivere una riga di codice, vale la pena scrivere cosa ci si aspetta dal mittente. Un webhook fatto bene dichiara:

  • Consegna at-least-once: ogni evento viene consegnato almeno una volta, quindi può arrivare più volte. Non “exactly once”, che in un sistema distribuito è una promessa quasi impossibile da mantenere.
  • Firma crittografica del payload, con un segreto condiviso, per autenticare la chiamata.
  • Timestamp firmato, per limitare il replay di una richiesta vecchia.
  • Identificatore univoco dell'evento, stabile tra i tentativi.
  • Retry con backoff e un numero massimo di tentativi dichiarato.
  • Documentazione su cosa succede dopo l'ultimo tentativo fallito.

Se il fornitore che stai integrando non garantisce almeno firma e id dell'evento, la tua resilienza dovrà compensare: riconciliazione periodica e deduplicazione euristica.

Firmare il payload

Lo schema più diffuso è HMAC-SHA256 su una stringa che combina timestamp e corpo grezzo della richiesta:

import hmac, hashlib

def firma(body: bytes, secret: bytes, timestamp: int) -> str:
    # il timestamp entra nella firma: non è modificabile senza invalidarla
    firmato = str(timestamp).encode() + b'.' + body
    return hmac.new(secret, firmato, hashlib.sha256).hexdigest()

Due dettagli che fanno la differenza:

  1. Si firma il body grezzo, cioè la sequenza esatta di byte inviata. Se il ricevente ri-serializza il JSON prima di verificare, gli spazi e l'ordine delle chiavi cambiano e la firma non torna più.
  2. Il timestamp è dentro la firma, non solo in un header separato. Altrimenti chi intercetta una richiesta può cambiarne il timestamp e rigiocarla.

La firma va in un header (per esempio X-Signature), il timestamp in un altro (X-Timestamp). Il segreto condiviso è per endpoint, non globale: così puoi ruotarlo senza rifare tutte le integrazioni insieme, e tenere segreti diversi per ambienti diversi.

Verificare la firma lato ricevente

Lato server la verifica è poche righe, ma leggermente controintuitive:

import hmac, hashlib, time

TOLLERANZA = 300  # secondi

def verifica(body: bytes, firma_ricevuta: str, ts_ricevuto: str, secret: bytes) -> bool:
    ts = int(ts_ricevuto)
    if abs(time.time() - ts) > TOLLERANZA:
        return False  # troppo vecchia: possibile replay
    atteso = hmac.new(secret, str(ts).encode() + b'.' + body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(atteso, firma_ricevuta)

Il confronto va fatto con compare_digest, che non esce al primo byte diverso: un == normale è vulnerabile a timing attack, anche se in pratica la finestra è stretta.

Se la verifica fallisce, rispondi 401 e non elaborare nulla. Ma non loggare il segreto né il payload completo: nei webhook passano spesso dati personali.

Ricevere in fretta, elaborare dopo

L'errore più comune è elaborare l'evento dentro la richiesta HTTP. Se l'elaborazione richiede due secondi — una query lenta, una chiamata a un'API esterna — il mittente può andare in timeout, considerare fallita la consegna e ritentare. Risultato: elabori lo stesso evento due volte e non capisci perché.

Lo schema che regge è questo:

  1. Verifica la firma.
  2. Salva il payload grezzo in una tabella, con id dell'evento e timestamp di ricezione.
  3. Rispondi subito con 200 o 202.
  4. Un worker separato legge gli eventi pending ed elabora.
create table webhook_event (
  id            bigserial primary key,
  provider      text        not null,
  event_id      text        not null,
  received_at   timestamptz not null default now(),
  payload       jsonb       not null,
  status        text        not null default 'pending',
  attempts      int         not null default 0,
  last_error    text,
  processed_at  timestamptz,
  unique (provider, event_id)
);

create index on webhook_event (status, received_at);

Il payload grezzo in tabella ti dà tre cose: una coda di lavoro, una traccia per il debug e la possibilità di rigiocare un evento dopo aver corretto un bug, senza chiedere al fornitore di rimandarlo.

L'idempotenza non è un optional

Con la consegna at-least-once, il duplicato è la norma: un retry per timeout, un evento rimandato dopo un deploy, un doppio invio per un bug lato fornitore. La difesa più semplice è un vincolo di unicità su (provider, event_id):

insert into webhook_event (provider, event_id, payload)
values ('gestionale', 'evt_123', '{}'::jsonb)
on conflict (provider, event_id) do nothing;

Se l'insert non ha inserito righe, è un duplicato: rispondi 200 e vai avanti. Questo non basta se l'effetto dell'evento è già stato applicato in un altro sistema (mandare una mail, creare un ordine in ERP): in quel caso serve una chiave di idempotenza anche a valle, cioè un identificatore che l'API di destinazione sappia riconoscere e scartare.

Ricorda: idempotente significa “applicato due volte produce lo stesso stato di applicato una volta”. Un insert con vincolo lo è. Un update col = col + 1 no.

Retry, backoff e dead letter queue

Chi invia, di solito, ritenta con backoff esponenziale e jitter:

attesa = min(cap, base * 2 ** tentativo) * random(0.5, 1.5)

con base di qualche secondo e un tetto di qualche minuto. Il jitter evita che migliaia di client ritentino tutti nello stesso istante dopo un disservizio, trasformando un problema in un attacco DDoS autoinflitto.

ScenarioCosa fare
2xxEvento accettato, si passa oltre
4xx (400, 401, 404)Errore del client: ritentare non serve, va indagato
429Troppe richieste: rispettare Retry-After
5xx o timeoutRitentare con backoff esponenziale
Tentativi esauritiSpostare in dead letter queue e avvisare

Lato ricevente vale la stessa logica, ma interna: se il worker fallisce, non è il mittente a doverlo sapere. Hai già salvato l'evento, quindi gestisci tu i tentativi, aggiorni attempts e last_error, e dopo N fallimenti lo marchi come failed per intervento manuale.

Ordine, concorrenza e versioni

Nessun webhook garantisce l'ordine di consegna. Due eventi created e updated possono arrivare invertiti, e il risultato è un record vecchio che sovrascrive quello nuovo. Le contromisure:

  • Includere una versione o un timestamp dell'oggetto nel payload e scartare gli aggiornamenti con valore inferiore a quello già applicato.
  • Trattare gli eventi come fatti immutabili (“l'ordine X è passato a stato Y in questo istante”) invece che come comandi, e ricostruire lo stato applicandoli in ordine di timestamp.
  • Se il sistema non regge la concorrenza, partizionare l'elaborazione per chiave (un solo worker per entity_id) così due eventi dello stesso oggetto non vengono processati in parallelo.

Testare e fare debug

Tre strumenti che ripagano subito:

  • Un tunnel verso la macchina locale per ricevere webhook reali in sviluppo, invece di testare solo con curl.
  • Replay dai payload salvati: avendo il body grezzo in tabella, puoi riprodurre esattamente l'evento che ha rotto la produzione.
  • Test dei casi noiosi: firma sbagliata, timestamp vecchio, body vuoto, JSON malformato, evento duplicato, evento con un campo mancante. Sono i casi che si vedono davvero.

Errori che si vedono in produzione

  • Ri-serializzare il JSON prima di verificare la firma.
  • Confrontare le stringhe con == invece di compare_digest.
  • Non controllare la freschezza del timestamp: replay possibile per sempre.
  • Elaborare in modo sincrono dentro la richiesta e generare duplicati per timeout.
  • Nessun vincolo di unicità sull'id dell'evento.
  • Loggare payload e segreti in chiaro.
  • Rispondere 200 anche quando l'elaborazione è fallita, “per non far ritentare”: il fornitore pensa che sia andata bene e l'evento è perso.
  • Fidarsi dell'IP di provenienza come unica autenticazione: gli IP cambiano e si falsificano.

Conclusione

Un webhook affidabile è meno “ricevere una POST” e più “gestire una coda distribuita con un contratto debole”. I punti chiave:

  • Firma sul body grezzo e sul timestamp, verificata con confronto a tempo costante.
  • Salva prima, elabora dopo: rispondi in fretta, metti il payload in tabella, processa con un worker.
  • Idempotenza obbligatoria: vincolo su (provider, event_id) e chiavi di idempotenza a valle.
  • Retry con backoff e jitter, dead letter queue per gli eventi che non passano.
  • Nessun ordine garantito: versioni, timestamp e partizionamento per chiave.
  • Riconciliazione periodica come rete di sicurezza, perché qualche evento perso capita sempre.

Foto di copertina di Jordan Harrison su Unsplash.