5 min di lettura
Webhook affidabili: idempotenza, retry e firme HMAC spiegati con codice
Un webhook arriva almeno una volta, non esattamente una volta. Come verificare la firma HMAC sul corpo grezzo, rispondere in fretta, deduplicare con un vincolo nel database e sopravvivere a retry, ritardi e messaggi fuori ordine.
- Automazioni
- Integrazioni
- Backend
Quasi ogni automazione seria prima o poi riceve un webhook: Stripe che avvisa di un pagamento, un CRM che segnala un contatto nuovo, un modulo che invia una richiesta. Sembra semplice: arriva una POST, si fa qualcosa. In produzione però succedono tre cose che un test in locale non mostra mai: lo stesso evento arriva due volte, gli eventi arrivano fuori ordine, e ogni tanto arriva qualcosa che non viene dal mittente che pensi.
Ecco come li gestisco, con il codice.
La regola da cui partire: almeno una volta
I fornitori di webhook consegnano gli eventi at-least-once: se il tuo server non risponde con un 2xx entro pochi secondi (spesso 5–30), il mittente riprova, di solito con attese crescenti, per ore o giorni. Basta un timeout di rete dopo che hai già elaborato l'evento perché lo stesso evento arrivi di nuovo.
Quindi la domanda non è «come evito i doppioni?» ma «cosa succede se elaboro questo evento due volte?». La risposta deve essere: niente.
1. Verificare la firma sul corpo grezzo
Quasi tutti i fornitori firmano il messaggio con HMAC-SHA256: calcolano un hash del corpo usando un segreto condiviso e lo mettono in un'intestazione. Tu ricalcoli lo stesso hash e confronti. Due errori comuni:
- Firmare il JSON già interpretato. Se il framework fa
JSON.parsee tu ri-serializzi, spazi e ordine delle chiavi cambiano e la firma non torna. Serve il corpo grezzo, così come è arrivato. - Confrontare con
===. Un confronto normale si ferma al primo carattere diverso e, in teoria, il tempo di risposta rivela quanto sei vicino alla firma giusta. Si usa un confronto a tempo costante.
import { createHmac, timingSafeEqual } from "node:crypto";
export function firmaValida(corpoGrezzo: Buffer, intestazione: string, segreto: string): boolean {
// formato tipico: "t=1760500000,v1=5f2b..." (timestamp + firma)
const parti = Object.fromEntries(intestazione.split(",").map((p) => p.split("=")));
const t = Number(parti.t);
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false; // max 5 minuti: niente replay
const attesa = createHmac("sha256", segreto).update(t + "." + corpoGrezzo).digest("hex");
const a = Buffer.from(attesa);
const b = Buffer.from(parti.v1 ?? "");
return a.length === b.length && timingSafeEqual(a, b);
}
Il timestamp dentro la firma serve a bloccare i replay: chi intercetta un messaggio valido non può rimandarlo tra un'ora. Lo stesso principio lo uso nei badge di FormaliaSuite HR: il QR contiene matricola, finestra temporale e una firma HMAC, quindi uno screenshot smette di valere dopo pochi secondi.
2. Rispondere subito, elaborare dopo
Il gestore del webhook dovrebbe fare tre cose e basta: verificare la firma, salvare l'evento, rispondere 200. L'elaborazione vera (chiamare altre API, generare PDF, mandare email) va fatta dopo, da una coda o da un job.
Se elabori dentro la richiesta e l'elaborazione dura 20 secondi, il mittente va in timeout, considera l'evento fallito e lo rimanda mentre tu lo stai ancora elaborando. È così che nascono i doppi addebiti e le doppie email.
3. Deduplicare con un vincolo, non con un «if»
Il modo sbagliato: «cerco se l'evento esiste, se no lo inserisco». Due consegne quasi simultanee passano entrambe il controllo. Il modo giusto è lasciar decidere il database, con un vincolo di unicità:
create table eventi_ricevuti (
id_evento text primary key, -- l'id che manda il fornitore
tipo text not null,
corpo jsonb not null,
ricevuto_il timestamptz not null default now(),
elaborato_il timestamptz
);
-- nel gestore del webhook
insert into eventi_ricevuti (id_evento, tipo, corpo)
values ($1, $2, $3)
on conflict (id_evento) do nothing
returning id_evento;
Se il returning non restituisce righe, l'evento era già arrivato: rispondi 200 e non fai altro. Attenzione a rispondere 200 anche per i doppioni, altrimenti il mittente continua a riprovare.
Lo stesso schema funziona quando il mittente sei tu. Nel tablet di timbratura di FormaliaSuite HR l'id di ogni timbratura lo genera il tablet, non il server: se la rete cade e il tablet rimanda la stessa timbratura, la chiave primaria la rifiuta e non nasce un doppione.
4. Gli eventi arrivano fuori ordine
«Abbonamento aggiornato» può arrivare prima di «abbonamento creato». Due strategie:
- Usa il webhook solo come campanello. Ricevi l'evento, poi rileggi lo stato attuale dall'API del fornitore e salvi quello. L'ordine smette di contare.
- Confronta una versione. Se l'evento porta un
updated_ato un numero di versione, aggiorna solo se è più recente di quello che hai:
update abbonamenti
set stato = $2, aggiornato_il = $3
where id = $1 and aggiornato_il < $3;
5. Retry dalla tua parte: attese crescenti con un po' di caso
Quando sei tu a chiamare un'API esterna e fallisce, non riprovare subito e non riprovare tutti insieme. La formula classica è l'exponential backoff con jitter: attesa = casuale tra 0 e min(massimo, base × 2^tentativo). Il caso evita che cento job falliti nello stesso secondo riprovino tutti nello stesso secondo successivo.
Distingui anche gli errori: un 4xx (dati sbagliati, permesso negato) non migliora riprovando, va segnalato; un 5xx o un timeout sì. Dopo N tentativi l'evento va in una coda degli errori (dead letter) che qualcuno guarda.
Checklist
- Firma HMAC verificata sul corpo grezzo, con confronto a tempo costante e timestamp.
- Risposta 2xx in meno di un secondo; elaborazione in coda.
- Vincolo unico sull'id dell'evento; 200 anche per i doppioni.
- Logica che regge eventi fuori ordine (rilettura o versione).
- Retry con backoff e jitter, solo per errori temporanei; coda degli errori.
- Log con l'id dell'evento, per poter rispondere a «questo pagamento è arrivato?» in un minuto.
Se le tue integrazioni ogni tanto «perdono» o «raddoppiano» qualcosa, quasi sempre manca uno di questi punti. Scrivimi: ci guardiamo insieme.