C’è un buco nell’attribuzione che quasi tutte le aziende che fanno campagne su WhatsApp hanno, e quasi nessuna ha risolto.
Investi in inserzioni click-to-WhatsApp. L’utente clicca, si apre la chat con un messaggio precompilato, scrive. Il messaggio arriva nella casella condivisa del CRM, il commerciale risponde, magari nasce una trattativa. Tutto bene, tranne che nel CRM quel contatto ha il campo “provenienza” vuoto.
Maia Management
Manager senior a ore per la tua PMI.
IT Manager, Marketing Manager, CDO, Odoo, HubSpot. Solo professionisti senior — 0 junior nel team. Risposta entro 24h.
A fine trimestre qualcuno chiede quanto ha reso la campagna, e la risposta onesta è che non si sa. I contatti ci sono, i ricavi anche, ma il collegamento tra i due è andato perso nel momento esatto in cui il messaggio è arrivato.
Ecco come abbiamo chiuso quel buco per un cliente, il CRM in questione è HubSpot, e cosa abbiamo scoperto sul funzionamento reale delle API lungo la strada.
Il vincolo di partenza: non esiste uno storico
Prima cosa da mettere in chiaro, perché condiziona tutta l’architettura: le WhatsApp Business API non espongono un endpoint per rileggere i messaggi ricevuti. Né quelle native di Meta, né i provider che ci stanno sopra. I messaggi in ingresso arrivano solo in push, via webhook, nell’istante in cui vengono ricevuti. Non puoi fare una GET “dammi i messaggi di oggi”.
Questo significa che, se il tuo aggancio è il provider WhatsApp, il sistema deve essere event-driven: se non catturi il messaggio quando arriva, l’informazione è persa per sempre.
Ma c’è una seconda strada, spesso trascurata: se i messaggi WhatsApp confluiscono già dentro il CRM, come succede quando la casella di posta condivisa è collegata al numero aziendale, allora i messaggi sono già archiviati lì. E il CRM, a differenza di WhatsApp, un’API di lettura ce l’ha.
È la strada che abbiamo scelto, per un motivo pratico: non richiede di toccare la configurazione del provider WhatsApp, non introduce un nuovo punto di rottura, e funziona anche in retroattivo sui messaggi già arrivati.
L’architettura
Un workflow schedulato, non un webhook. Nel nostro caso gira su un’istanza n8n self-hosted. Ogni ora:
- Trova le conversazioni aggiornate nelle ultime 48 ore
- Filtra solo quelle del canale WhatsApp, deduplicando i contatti
- Legge i contatti in batch e scarta quelli che hanno già una provenienza
- Per i rimanenti, legge i messaggi del thread
- Cerca la frase della campagna nel primo messaggio in entrata
- Scrive la provenienza sul contatto
La finestra di 48 ore con esecuzione oraria dà un margine di sicurezza abbondante: anche se il workflow resta fermo mezza giornata, al riavvio recupera tutto senza buchi.
Le tre cose che l’API fa in modo diverso da come è documentata
Qui sta il valore vero di questo articolo, perché sono tre comportamenti che si scoprono solo sbattendoci contro.
1. L’endpoint stabile ignora i filtri, in silenzio
L’endpoint v3 della Conversations API di HubSpot accetta parametri come il filtro sul timestamp dell’ultimo messaggio, l’ordinamento e il filtro sul canale di origine. Li accetta nel senso letterale del termine: non restituisce nessun errore. Semplicemente li ignora, e ti risponde con i thread in ordine crescente a partire dal più vecchio della storia del portale.
È il tipo di bug che ti fa perdere un’ora, perché la risposta è HTTP 200 e i dati sembrano validi. Sono solo i dati sbagliati.
La soluzione è usare l’endpoint in versione beta, che quei parametri li valida davvero. Con una regola precisa: il filtro sul timestamp funziona solo se accompagnato dal parametro di ordinamento corrispondente. Passato da solo viene respinto.
2. I timestamp devono essere in UTC, non in ora locale
Passando un timestamp con offset locale, quello che in Italia diventa +02:00, l’API risponde con un errore di parsing. Il motivo è banale e insidioso: il segno + in una query string viene interpretato come uno spazio codificato. L’offset arriva dall’altra parte come 02:00 e la data non è più valida.
Due accortezze risolvono definitivamente:
- convertire sempre in UTC prima di inviare;
- usare il pannello dei parametri di query del client HTTP invece di concatenare i valori dentro la stringa dell’URL, così la codifica la gestisce il client.
In n8n, l’espressione corretta è:
{{ $now.minus({ hours: 48 }).toUTC().toISO() }}Nota a margine: Date.now() non funziona nei campi espressione di n8n, va usato $now.toMillis(). Dentro un nodo Code invece funziona regolarmente. È una differenza che non è documentata da nessuna parte.
3. La lettura in batch nasconde i contatti cancellati
Per non fare una chiamata per contatto, si usa l’endpoint di lettura in batch. Qui c’è la trappola più costosa delle tre.
I contatti trovati tornano nell’array results. I contatti non trovati — perché nel frattempo sono stati cancellati, non tornano affatto in results: finiscono in un array separato errors, con sottocategoria OBJECT_NOT_FOUND.
La conseguenza è che un filtro scritto in logica di esclusione (“passa avanti tutti i contatti che non hanno già la provenienza”) lascia passare anche i contatti inesistenti, perché di loro non sai nulla e quindi il campo risulta vuoto. Il workflow poi tenta di aggiornarli, fallisce, e ogni ciclo riprova.
La forma corretta del filtro è in inclusione: passano avanti solo i contatti esplicitamente trovati in results e con il campo provenienza vuoto. Tutto il resto viene scartato.
const risultati = $json.results ?? [];
const daAggiornare = risultati.filter(c =>
!c.properties.provenienza_lead ||
c.properties.provenienza_lead.trim() === ''
);
return daAggiornare.map(c => ({ json: c }));Leggere il messaggio giusto
I messaggi di un thread tornano in ordine cronologico decrescente: il più recente per primo. Il primo messaggio della conversazione — quello che contiene la frase della campagna — è quindi l’ultimo dell’array.
Ma non basta prendere l’ultimo elemento, perché nell’array non ci sono solo messaggi. Ci sono anche oggetti di tipo cambio di stato del thread e assegnazione a un operatore. Vanno filtrati prima:
const messaggi = ($json.results ?? []).filter(m =>
m.type === 'MESSAGE' && m.direction === 'INCOMING'
);
const primoMessaggio = messaggi[messaggi.length - 1];
const testo = primoMessaggio?.text ?? '';Sul matching della frase, un consiglio: usa una regex tollerante, non un confronto esatto. L’utente può modificare il messaggio precompilato prima di inviarlo, aggiungere un saluto, correggere un refuso. Cercare una sottostringa caratteristica funziona molto meglio che cercare la frase intera.
L’errore che costa più di tutti: l’idempotenza
Questo vale per qualunque workflow di polling, non solo per questo.
Nella prima versione filtravamo i contatti su un campo e scrivevamo il risultato su un campo diverso. Sembra ragionevole: leggi la provenienza principale, scrivi quella secondaria.
Il risultato è che il campo di controllo resta vuoto per sempre. Ogni ora il contatto rientra nel filtro, viene riprocessato, riaggiornato. Il valore finale è corretto, quindi nessuno se ne accorge ma stai bruciando chiamate API a ogni ciclo su contatti già sistemati, e dopo qualche settimana inizi a toccare i rate limit senza capire perché.
Regola: in un workflow di polling, il campo su cui filtri e il campo su cui scrivi devono essere lo stesso. Se ti servono più campi, scrivili tutti, ma il filtro deve leggere uno di quelli scritti.
Un’alternativa più solida, se puoi averla
Il matching testuale ha un limite intrinseco: se l’utente cancella il messaggio precompilato e scrive di suo pugno, l’attribuzione salta.
I webhook nativi di Meta per le inserzioni click-to-WhatsApp includono un oggetto referral con l’ID dell’inserzione, il tipo di sorgente, il titolo dell’annuncio e un identificativo di clic. È infinitamente più affidabile: identifica la campagna anche senza il messaggio precompilato.
Il problema è che non tutti i provider intermedi inoltrano quel campo nel proprio payload, e quasi nessuno lo documenta. Vale un test reale prima di scegliere la strada: clicca sulla tua inserzione, manda un messaggio, e guarda il payload grezzo che arriva. Se il referral c’è, usa quello e dimenticati le regex.
Cosa cambia in azienda
Il risultato tecnico è un campo popolato su un contatto. Il risultato pratico è un altro.
Il commerciale che apre la scheda vede da quale campagna arriva la persona, e apre la conversazione con il contesto giusto invece che con “buongiorno, come posso aiutarla?”. Il marketing, a fine mese, ha un numero di lead per campagna che non è una stima. E la domanda “quanto ci ha reso quell’investimento” ha finalmente una risposta che non richiede di andare a leggere le chat a mano.
È uno dei tanti punti in cui un CRM configurato bene fa la differenza tra dati e stime: è il tipo di lavoro che facciamo nella consulenza HubSpot per PMI, e che abbiamo raccontato anche in altri casi reali di automazione.
I vostri lead WhatsApp arrivano nel CRM senza sapere da dove. È un problema che si risolve in pochi giorni di lavoro e che continua a rendere ogni mese. Scriveteci.