Webhook: Firmare, Verificare e Ritentare le Notifiche Senza Perdere Eventi
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:
- 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ù.
- 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:
- Verifica la firma.
- Salva il payload grezzo in una tabella, con id dell'evento e timestamp di ricezione.
- Rispondi subito con 200 o 202.
- 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.
| Scenario | Cosa fare |
|---|---|
| 2xx | Evento accettato, si passa oltre |
| 4xx (400, 401, 404) | Errore del client: ritentare non serve, va indagato |
| 429 | Troppe richieste: rispettare Retry-After |
| 5xx o timeout | Ritentare con backoff esponenziale |
| Tentativi esauriti | Spostare 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 dicompare_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.