Chi ha già implementato Meta CAPI o Google Ads Enhanced Conversions riconoscerà l’impianto: un pixel JavaScript lato browser, una API server-to-server, un identificatore di click da far sopravvivere lungo il funnel. Il tracciamento delle ChatGPT Ads segue lo stesso schema, ma con tre differenze che rendono le abitudini acquisite pericolose invece che utili: il consenso non passa da Google Consent Mode, l’identificatore di click non viene raccolto automaticamente lato server, e GA4 — dal maggio 2026 — ha un canale dedicato al traffico AI che il tagging delle campagne a pagamento fa saltare.
Questa guida copre l’intera catena di misurazione, dallo snippet nel <head> — che va su tutte le pagine del funnel, non solo sulla landing e non solo sulla thank you page — fino alla riconciliazione dei numeri in Ads Manager e GA4. È scritta a partire dalla documentazione ufficiale OpenAI per inserzionisti, riverificata il 6 settembre 2026, e dal lavoro di specifica tecnica che facciamo sui progetti di consulenza e gestione delle campagne ChatGPT Ads: quello che trovate nelle sezioni su oppref, cookie e canali GA4 nasce dai punti in cui questi progetti si rompono, non da una parafrasi della documentazione.
Se il sito è in WordPress, il percorso più rapido è la versione operativa di questa guida: tracciare le ChatGPT Ads su WordPress, cinque file da incollare in mu-plugins con pixel, consenso, oppref, eventi WooCommerce e Conversions API già cablati. Qui trovate il ragionamento che c’è dietro; lì l’implementazione.
Un avvertimento di metodo. ChatGPT Ads è in beta e la documentazione cambia. Ogni affermazione tecnica qui riportata ha il link alla fonte ufficiale: prima di un rilascio in produzione, riaprite quei link.
Le tre architetture di misurazione, e perché non si sostituiscono
Il primo errore, quello che si paga per tutta la durata della campagna, è trattare il tracciamento come un unico sistema. Non lo è. Sono tre sistemi indipendenti, con tre proprietari, tre finestre di attribuzione e tre modi diversi di rompersi.
| Sistema | Dove gira | Cosa misura | Chi lo consuma |
|---|---|---|---|
| Measurement Pixel | Browser, SDK oaiq da bzrcdn.openai.com | Eventi di funnel, cattura oppref, scrive i cookie first-party | Ads Manager (attribuzione nativa) |
| Conversions API | Backend, POST verso bzr.openai.com | Evento di conversione autorevole, resiliente ad ad blocker e ITP | Ads Manager (attribuzione nativa) |
| GA4 | Browser (gtag/GTM) + Measurement Protocol | Comportamento on-site, sessioni, canali, funnel | Reportistica interna, BI, attribuzione multicanale |
I primi due alimentano l’ottimizzazione della campagna: sono ciò che permette a una campagna oCPC di imparare. Il terzo non alimenta niente lato piattaforma — serve a voi, per capire se quel traffico vale qualcosa a valle del click.
GA4 non conosce oppref e non lo conoscerà mai. Non è una limitazione da aggirare: è una separazione di responsabilità. Se non intervenite, il traffico delle vostre campagne a pagamento finisce mescolato al traffico organico che ChatGPT già vi manda, e in GA4 diventa indistinguibile. Il collegamento tra i due mondi lo dovete costruire voi, e nella sezione dedicata a GA4 spiego come farlo con un parametro evento invece che a occhio.
I quattro identificatori che vengono regolarmente scambiati
Sono quattro nomi che si assomigliano. In ogni code review di questo tipo di implementazione ne trovo almeno uno al posto sbagliato.
| Nome | Cos’è | Dove vive | Chi lo gestisce |
|---|---|---|---|
oppref | Identificatore opaco di attribuzione click-through, presente nell’URL di atterraggio | Query string | Il pixel lo cattura da solo; la CAPI no |
__oppref | Cookie first-party in cui il pixel conserva oppref | Browser | Scritto dal pixel |
obref | Riferimento opaco del browser, da inviare dentro l’oggetto user della CAPI | Payload CAPI | Manuale |
__obref | Cookie first-party da cui si legge obref | Browser | Il pixel scrive, voi leggete |
Tre regole che valgono più di qualsiasi altra cosa in questa guida:
opprefè un campo a livello di evento;obrefsta dentro l’oggettouser. Non sono intercambiabili e invertirli non produce un errore: produce un evento accettato e non attribuito.- Entrambi si passano senza hashing e senza normalizzazione. Sono stringhe opache: non troncatele, non fatele passare per un sanitizer, non applicate
strtolower(). - La Conversions API non cattura
opprefautonomamente. La documentazione è esplicita: “Unlike the pixel, the API does not captureoppreffor you”. Questa singola frase è la ragione per cui metà delle implementazioni misura zero conversioni server-side.
Fonti: Measurement Pixel · Conversions API
Il Measurement Pixel: installazione e comportamento reale
Nonostante il nome, non è un pixel-immagine: è un SDK JavaScript (oaiq) caricato da bzrcdn.openai.com/sdk/oaiq.min.js. Funziona come gtag o come il pixel di Meta. Esiste anche un vero <img> 1×1 — l’Image Tag — ma serve solo come fallback in <noscript>: cattura esclusivamente eventi legati al page load, non le interazioni.
Lo snippet base
<script>
(function (w, d, s, u) {
if (w.oaiq) return;
var q = function () { q.q.push(arguments); };
q.q = [];
w.oaiq = q;
var js = d.createElement(s);
js.async = true;
js.src = u;
var f = d.getElementsByTagName(s)[0];
f.parentNode.insertBefore(js, f);
})(window, document, "script", "https://bzrcdn.openai.com/sdk/oaiq.min.js");
oaiq("init", {
pixelId: "<PIXEL-ID>",
debug: false // true solo in staging
});
</script>
Le opzioni di init documentate sono tre: pixelId (obbligatorio), debug (logging in console) e user (oggetto per l’advanced matching, vedi più avanti). Il pattern è il classico command queue: le chiamate fatte prima che l’SDK sia scaricato finiscono in q.q e vengono riprodotte al caricamento. Questo significa che potete chiamare oaiq("measure", ...) immediatamente dopo l’init senza attendere l’onload.
Dove va lo snippet: la domanda che ricevo più spesso
Su tutte le pagine rilevanti del funnel, il più in alto possibile nel <head> — non solo sulla thank you page. La documentazione OpenAI lo dice in due frasi: “Add the following snippet to the <head> section [of] every page where you want to capture conversions” e “Put the script near the top of your <head> to ensure early conversions aren’t lost while other content loads”.
Le ragioni tecniche per cui la sola pagina di conferma non basta sono quattro, e vale la pena tenerle a portata di mano perché è l’obiezione standard di ogni team di sviluppo che vuole limitare l’intervento:
opprefsi cattura sulla landing page, che quasi mai coincide con la thank you page. Pixel solo sulla conferma significa nessunopprefdisponibile: la conversione arriva orfana e non viene attribuita.- Il cookie
__opprefva scritto all’atterraggio e riletto lungo il funnel. Senza pixel sulle pagine intermedie non c’è continuità di lettura. - Gli eventi di funnel (
contents_viewed,checkout_started,items_added) partono da pagine intermedie per definizione. - Solo l’evento di conversione è specifico della thank you page — ma il pixel deve già essere presente ovunque prima di arrivarci.
In produzione: snippet nel template globale, incluse le pagine del sistema transazionale, incluso l’eventuale dominio separato del checkout o del booking engine. Su WordPress il posto giusto è un mu-plugin agganciato a wp_head con priorità 1, non il functions.php del tema: il codice pronto è nella guida pratica per WordPress.
Cosa fa lo script da solo
Non serve reimplementare nulla di quanto segue, ed è controproducente provarci:
- cattura
opprefdall’URL di atterraggio e lo conserva nel cookie first-party__opprefper le page view successive; - aggiunge l’origin corrente come
source_urldegli eventi; - applica il timestamp agli eventi e raggruppa le chiamate
measureravvicinate; - se l’advanced matching automatico è attivo, rileva le informazioni cliente supportate dalla pagina, le normalizza e le hasha in SHA-256 nel browser prima dell’invio.
Quello che non fa: non propaga oppref cross-domain, non lo espone al vostro backend, non lo scrive in un cookie leggibile lato server in modo affidabile. Sono esattamente i tre pezzi che dovete costruire voi.
Content Security Policy: sbloccatela per prima
Su qualsiasi sito enterprise con una CSP attiva il pixel viene silenziosamente bloccato finché i domini OpenAI non sono autorizzati.
| Direttiva | Sorgente | Scopo |
|---|---|---|
script-src | https://bzrcdn.openai.com | Caricamento dell’SDK |
connect-src | https://bzr.openai.com | Invio eventi via fetch / sendBeacon |
connect-src | https://bzrcdn.openai.com | Configurazione per-pixel |
img-src | https://bzr.openai.com | Fallback via image request |
Due note che fanno la differenza in fase di rilascio. La prima: non aggiungete 'unsafe-inline' per far passare lo snippet di init. Usate un nonce o l’hash dello script inline — declassare la CSP dell’intero sito per un tag di misurazione è una scelta che non supera nessuna review di sicurezza seria. La seconda: se la policy definisce script-src-elem, la direttiva più specifica vince su script-src e va aggiornata anch’essa.
Il passaggio dalla CSP dipende dall’IT e ha tempi propri, spesso settimane. È il primo task da aprire, non l’ultimo.
Fonte: Measurement Pixel
Il consenso: perché Consent Mode v2 non basta
Questa è la sezione in cui si perdono le conversioni in modo definitivo, e il motivo è controintuitivo: il pixel OpenAI ha un meccanismo di consenso proprio e indipendente, che non legge i segnali ad_storage e analytics_storage di Google Consent Mode.
Chi ha già implementato la Consent Mode v2 tende a dare per scontato che la CMP governi anche questo tag. Non lo fa. Non si “estende la consent mode”: si aggiunge un secondo canale di consenso, che la CMP deve pilotare esplicitamente.
Il default che vi costa le conversioni
Se non gli dite nulla, il pixel inizializza il consenso a true. Questo va detto chiaramente al DPO, perché significa che un’implementazione fatta senza integrazione CMP raccoglie dati per default.
L’ordine corretto è questo, e l’ordine conta:
<script>
// 1. Diniego esplicito PRIMA dell'init
oaiq("consent", false);
oaiq("init", { pixelId: "<PIXEL-ID>" });
// 2. Alla concessione (aggancio da adattare alla CMP in uso)
window.addEventListener("app:consent:granted", function () {
oaiq("consent", true);
});
// 3. Alla revoca
window.addEventListener("app:consent:revoked", function () {
oaiq("consent", false);
});
</script>
La race condition sulla pagina di conversione
Il comportamento documentato è netto: con consenso false il pixel non invia i ping di misurazione, e gli eventi bloccati non vengono ripetuti quando il consenso arriva dopo. Un evento perso è perso, senza retry e senza coda.
Tradotto sul campo: se sulla pagina di conferma l’evento di conversione parte al DOMContentLoaded mentre la CMP sta ancora risolvendo lo stato del consenso in modo asincrono, quella conversione non esiste. Non è un caso limite — è il comportamento normale di qualsiasi CMP che carichi il proprio stato via rete o che debba interrogare il TCF.
La forma corretta è accodare l’evento alla risoluzione del consenso, mai al ready del DOM:
// Pattern generico: l'evento parte SOLO dopo che il consenso è risolto.
// Vale sia se lo stato è già memorizzato, sia se arriva dall'interazione.
function whenMeasurementConsent(callback) {
if (window.__consentState === "granted") return callback();
if (window.__consentState === "denied") return; // niente evento
window.addEventListener("app:consent:granted", callback, { once: true });
}
whenMeasurementConsent(function () {
oaiq("measure", "order_created", {
type: "contents",
amount: 24800, // centesimi
currency: "EUR"
}, {
event_id: window.__orderEventId // generato lato server, vedi deduplica
});
});
Con una CMP conforme al TCF, l’aggancio passa da __tcfapi:
// addEventListener del TCF si richiama a ogni cambio di stato:
// va gestita anche la revoca, non solo la concessione.
window.__tcfapi("addEventListener", 2, function (tcData, success) {
if (!success) return;
if (tcData.eventStatus !== "useractioncomplete" &&
tcData.eventStatus !== "tcloaded") return;
// Sostituire <VENDOR_ID> con l'ID vendor verificato nella vendor list,
// oppure agganciarsi alla finalità/consenso custom configurato in CMP.
var ok = !!(tcData.purpose &&
tcData.purpose.consents &&
tcData.purpose.consents[7]); // 7 = measure ad performance
oaiq("consent", ok);
});
Nota di accuratezza: la presenza di OpenAI nella vendor list IAB TCF va verificata sulla vostra CMP prima di scrivere codice che dipenda da un vendor ID. La documentazione OpenAI non la dichiara, e non è un dettaglio da dare per scontato davanti a un DPO. In assenza di un vendor TCF, l’aggancio si fa su una finalità o su una categoria custom configurata nella CMP.
Il consenso vale anche lato server
Il punto che sfugge quasi sempre: obref è un cookie del browser che voi inoltrate al vostro server e poi a OpenAI. Alla revoca del consenso dovete smettere di inoltrarlo nei payload CAPI. Non basta spegnere il pixel: il flusso server-side è un canale separato e va spento separatamente.
Concretamente significa che lo stato di consenso deve essere persistito lato server insieme all’ordine, non ricalcolato al momento dell’invio: quando il job che manda gli eventi gira, il browser dell’utente non c’è più.
| Segnale | Governa | Chi lo legge |
|---|---|---|
ad_storage / ad_user_data (Consent Mode v2) | Tag Google: gtag, GA4, Google Ads | Solo prodotti Google |
oaiq("consent", …) | Pixel OpenAI, invio ping di misurazione | Solo pixel OpenAI |
| Flag persistito lato server | Inoltro di obref e dei dati di advanced matching in CAPI | Il vostro backend |
Tre canali, tre punti di configurazione, un solo stato logico da tenere coerente. Se la vostra CMP li governa da un’unica categoria di consenso, tanto meglio — ma il cablaggio va scritto tre volte.
Fonte: Measurement Pixel
Gli eventi: tassonomia, campi ammessi e l’errore da due ordini di grandezza
La sintassi è oaiq("measure", <nome_evento>, <dati_evento>, <opzioni>). Gli eventi built-in sono dodici, e ognuno impone una data.type precisa: usare la shape sbagliata fa fallire l’evento. Due dei dodici non sono utilizzabili dal pixel, e la tabella lo dichiara colonna per colonna perché è una distinzione che non emerge finché non si guarda l’Event Stream e non si trova nulla.
| Evento | data.type | Pixel JS | Uso tipico |
|---|---|---|---|
page_viewed | contents | sì | Caricamento pagina |
contents_viewed | contents | sì | Vista scheda prodotto o servizio |
items_added | contents | sì | Aggiunta al carrello |
checkout_started | contents | sì | Avvio checkout |
order_created | contents | sì | Acquisto o prenotazione completata |
lead_created | customer_action | sì | Invio form lead |
registration_completed | customer_action | sì | Registrazione account |
appointment_scheduled | customer_action | sì | Prenotazione appuntamento |
subscription_created | plan_enrollment | sì | Abbonamento a pagamento |
trial_started | plan_enrollment | sì | Avvio prova gratuita |
app_installed | customer_action | no, solo CAPI | Installazione app |
app_opened | customer_action | no, solo CAPI | Apertura app |
In sintesi: dieci eventi web utilizzabili dal pixel, due eventi app disponibili solo via Conversions API, più l’evento custom. Sui due eventi app la documentazione è esplicita: “app_installed and app_opened are available through the Conversions API only. Send them with action_source set to mobile_app. They are not supported by the JavaScript Pixel currently.” Chiamarli con oaiq("measure", ...) non produce un errore visibile: produce semplicemente un evento che non arriva.
custom non è un evento standard: è la via d’uscita per ciò che non mappa sui built-in, e richiede custom_event_name (1–64 caratteri, minuscoli, fra lettere, cifre, underscore e trattini; la CAPI converte comunque a minuscolo, ma non contateci) con il divieto esplicito di riusare il nome di un evento built-in. Ha inoltre un vincolo che decide l’architettura di campagna: gli eventi custom non possono essere obiettivo di una campagna oCPC. Se il vostro KPI non mappa su un evento standard, va forzato su uno standard prima del lancio, non dopo.
Fonte: Supported Events
I campi ammessi dentro data e contents[]
I campi ammessi dipendono dalla shape, e questa è la fonte di errore più insidiosa della sezione: leggere un elenco aggregato e applicarlo ovunque. type, amount e currency (obbligatorio se c’è amount) valgono per tutte e quattro; il resto no.
data.type | contents[] | plan_id | Campi arbitrari |
|---|---|---|---|
contents | sì | no | no |
customer_action | no | no | no |
plan_enrollment | sì | sì | no |
custom | sì | sì | sì |
Tradotto: infilare contents[] dentro un lead_created — che è customer_action — è fuori specifica, anche se l’elenco “generale” dei campi di data lo lascerebbe pensare. Campi arbitrari sono ammessi solo con type: "custom".
Dentro ogni oggetto di contents[]: id, name, content_type (product, plan, page), quantity, amount, currency — più group_id e variant_dict, che però sono marcati “Conversions API only” e non vanno usati nelle chiamate del pixel. Nient’altro. Aggiungere campi non documentati — la tentazione di infilarci sku_interno o categoria — è il modo più rapido per far rifiutare un evento senza capire perché.
oaiq("measure", "order_created", {
type: "contents",
amount: 24800, // 248,00 € in centesimi
currency: "EUR",
contents: [{
id: "SKU-1042",
name: "Giacca tecnica 3 strati",
content_type: "product",
quantity: 2,
amount: 12400, // valore unitario, in centesimi
currency: "EUR"
// group_id e variant_dict NON vanno qui: sono "Conversions API only".
// Lato pixel si usano solo i campi supportati dal browser SDK.
}]
}, {
event_id: "ORD-2026-10-14-8891" // deve coincidere con l'id CAPI
});
L’errore monetario: perché passa inosservato per settimane
I valori monetari vanno inviati come interi nell’unità minore ISO 4217. Per l’euro, i centesimi.
| Importo reale | Valore corretto | Valore sbagliato |
|---|---|---|
| € 248,00 | 24800 | 248.00 |
| € 1.250,50 | 125050 | 1250.50 |
| € 89,90 | 8990 | 89.9 |
Inviare il decimale è un bug silenzioso: l’evento viene accettato, la conversione viene contata, e tutto il reporting di valore risulta falsato di due ordini di grandezza. Su un e-commerce o un booking engine è l’errore più frequente in assoluto, e si scopre in genere alla prima riconciliazione con il gestionale — cioè settimane dopo, con lo storico già compromesso.
Il controllo da mettere in QA non è “l’evento parte”: è prendere un ordine reale, aprire l’Event Stream in Ads Manager e verificare che il numero corrisponda all’importo × 100.
Le altre tre regole della stessa famiglia: quantity sempre intero, currency sempre presente quando c’è amount, e nessun campo fuori da quelli documentati.
Le opzioni del quarto parametro
Tre opzioni, tutte rilevanti:
event_id— chiave di deduplica browser/server. È l’argomento della prossima sezione.custom_event_name— obbligatorio percustom, in minuscolo, e non può riusare il nome di un evento built-in.opt_out— setrue, esclude l’evento dalla personalizzazione futura. Va valutato con il DPO per le categorie di prodotto sensibili: è il meccanismo con cui si evita che un acquisto in una categoria delicata alimenti il profiling.
Conversions API: payload, vincoli e il tranello della deduplica
La documentazione indica la Conversions API come fonte di tracciamento più affidabile del solo pixel, ed è vero per le ragioni consuete: ad blocker, ITP, browser che non eseguono lo script, utenti che chiudono la pagina prima dell’invio. Su un flusso transazionale il delta tra pixel e server è tipicamente a due cifre percentuali.
È un’integrazione server-to-server. Non è uno script di pagina, e la API key non deve mai comparire in un bundle JavaScript, in un repository o in una variabile esposta al client.
Endpoint e struttura della richiesta
POST https://bzr.openai.com/v1/events?pid=<PIXEL-ID>
Authorization: Bearer <API-KEY>
Content-Type: application/json
Il body ha tre chiavi a livello root: validate_only (booleano, valida senza salvare), integration_source (identificatore stabile dell’integratore, 1–64 caratteri ASCII) e events[].
{
"validate_only": false,
"integration_source": "custom-backend-v1",
"events": [
{
"id": "ORD-2026-10-14-8891",
"type": "order_created",
"timestamp_ms": 1791000000000,
"oppref": "<VALORE_CATTURATO_ALL_ATTERRAGGIO>",
"source_url": "https://www.example.com/checkout/conferma",
"action_source": "web",
"user": {
"obref": "<VALORE_DAL_COOKIE___obref>",
"emails_sha256": ["b4c9a289323b21a01c3e940f150eb9b8c542587f1abfd8f0e1cc1ffc5e475514"],
"external_ids_sha256": ["18f69bcd2f9cc9c38195e722b2a5590429840ea5090971d2256e026926e55fa1"],
"countries": ["IT"],
"cities": ["Milano"],
"postal_codes": ["20121"],
"ip_address": "203.0.113.1",
"user_agent": "Mozilla/5.0 ..."
},
"data": {
"type": "contents",
"amount": 24800,
"currency": "EUR",
"contents": [
{
"id": "SKU-1042",
"group_id": "cat-outdoor",
"name": "Giacca tecnica 3 strati",
"content_type": "product",
"quantity": 2,
"variant_dict": { "size": "M", "color": "blue" }
}
]
}
}
]
}
action_source accetta web, mobile_app, offline, physical_store, phone_call, email, other. Per gli eventi web è obbligatorio anche source_url, con schema e host.
I vincoli che decidono l’architettura del job di invio
| Vincolo | Dettaglio | Conseguenza progettuale |
|---|---|---|
| Batch massimo | 1.000 eventi per richiesta | Chunking obbligatorio su volumi alti |
| Fallimento parziale | Se un evento fallisce, fallisce l’intero batch | Validazione a monte, o batch da 1 evento in produzione |
| Finestra timestamp | Entro gli ultimi 7 giorni, non oltre 10 minuti nel futuro | Le riconciliazioni tardive dal gestionale non passano |
id | Stringa non vuota, identica nei retry | Generato lato server, mai casuale |
validate_only | true valida senza salvare | Da usare in staging e nei test |
Il vincolo sul fallimento parziale merita una decisione esplicita. La documentazione dice “If one event in the batch fails, the full batch fails”: un singolo ordine con un campo malformato può far perdere 999 conversioni valide. In produzione ci sono due strategie sensate, e vanno scelte in base ai volumi:
- Validazione a monte + retry granulare: si valida ogni evento contro lo schema prima dell’invio, si spedisce in batch, e in caso di errore si ri-spedisce evento per evento per isolare il colpevole e salvare gli altri.
- Batch da un evento: costa più richieste HTTP ma elimina il problema. Su volumi sotto qualche migliaio di conversioni al giorno è la scelta pragmatica.
La finestra dei 7 giorni ha un impatto operativo che va valutato in fase di design, non scoperto dopo: se la conferma di una conversione dipende da un processo a valle — spedizione, riconciliazione contabile, check-in effettivo su una prenotazione — oltre i 7 giorni l’evento non viene accettato. In quei casi l’evento va inviato al momento della transazione, non della sua conferma finale.
Un’implementazione server-side minimale
"""Invio di un evento order_created alla Conversions API di ChatGPT Ads.
Punti chiave dell'implementazione:
- oppref e obref vengono passati SENZA modifiche (stringhe opache)
- l'id è derivato dall'ordine, quindi identico nei retry e uguale all'event_id del pixel
- il flag di consenso è quello persistito insieme all'ordine, non ricalcolato ora
"""
import hashlib
import os
import time
import requests
PIXEL_ID = os.environ["OAI_PIXEL_ID"]
API_KEY = os.environ["OAI_CAPI_KEY"] # segreto server-side, mai nel client
ENDPOINT = "https://bzr.openai.com/v1/events"
def sha256_hex(value: str) -> str:
"""SHA-256 in esadecimale minuscolo, 64 caratteri."""
return hashlib.sha256(value.encode("utf-8")).hexdigest()
def build_event(order, *, consent_granted: bool) -> dict:
user = {
"ip_address": order["ip"],
"user_agent": order["user_agent"],
"countries": [order["country"]],
}
# obref e advanced matching solo con consenso attivo
if consent_granted:
if order.get("obref"):
user["obref"] = order["obref"]
if order.get("email"):
user["emails_sha256"] = [sha256_hex(order["email"].strip().lower())]
event = {
"id": order["id"], # es. "ORD-2026-10-14-8891"
"type": "order_created",
"timestamp_ms": int(order["created_at"] * 1000),
"source_url": order["confirmation_url"],
"action_source": "web",
"user": user,
"data": {
"type": "contents",
"amount": order["total_cents"], # INTERO in centesimi
"currency": "EUR",
"contents": [
{
"id": line["sku"],
"name": line["name"],
"content_type": "product",
"quantity": int(line["qty"]),
"amount": line["unit_price_cents"],
"currency": "EUR",
}
for line in order["lines"]
],
},
}
# oppref è opzionale nello schema, ma senza di lui non c'è click matching
if order.get("oppref"):
event["oppref"] = order["oppref"]
return event
def send(events: list[dict], *, validate_only: bool = False) -> dict:
payload = {
"validate_only": validate_only,
"integration_source": "custom-backend-v1",
"events": events,
}
r = requests.post(
ENDPOINT,
params={"pid": PIXEL_ID},
headers={"Authorization": f"Bearer {API_KEY}"},
json=payload,
timeout=15,
)
if r.status_code >= 400:
# Un evento invalido fa cadere tutto il batch: si isola inviandoli
# singolarmente, così le conversioni valide non si perdono.
if len(events) > 1:
return {"isolated": [send([e], validate_only=validate_only) for e in events]}
raise RuntimeError(f"CAPI {r.status_code}: {r.text[:500]}")
return r.json()
def guard_timestamp(ts_ms: int) -> bool:
"""La finestra ammessa è: ultimi 7 giorni, max +10 minuti nel futuro."""
now_ms = int(time.time() * 1000)
return (now_ms - 7 * 24 * 3600 * 1000) <= ts_ms <= (now_ms + 10 * 60 * 1000)
La deduplica, e la conseguenza che quasi nessuno considera
order_created viene inviato due volte — dal pixel e dalla CAPI — e la deduplica evita il doppio conteggio. La regola: stesso valore in event_id (pixel) e id (CAPI), stesso Pixel ID su entrambi i lati. La chiave di corrispondenza è Pixel ID + nome evento + id.
Fin qui è lo standard del settore. La parte da leggere con attenzione è come viene risolto il conflitto: “OpenAI uses the first event it receives for a matching key and ignores later duplicates.”
Vince il primo che arriva, non il più completo. E il primo, quasi sempre, è il browser: il pixel parte al render della thank you page, mentre il job server-side gira dopo il commit della transazione, spesso in coda asincrona. La conseguenza pratica è che il payload ricco che avete costruito lato server — con obref, advanced matching, IP, dettaglio righe — viene scartato in favore di quello del browser.
Tre modi di gestirla, in ordine di robustezza decrescente:
- Rendere il payload browser già completo. Se l’evento pixel è la fonte che vincerà nella maggior parte dei casi, deve contenere valore, valuta e
contents[]corretti. La CAPI diventa allora la rete di sicurezza per i casi in cui il browser non ha sparato: ad blocker, chiusura anticipata, consenso negato al pixel ma non alla misurazione server. - Invertire l’ordine dove è possibile. Se la conferma dell’ordine avviene server-side prima del render della pagina di ringraziamento, l’invio CAPI sincrono (o su una coda a bassissima latenza) fa vincere il server.
- Solo server. Rinunciare all’evento pixel di conversione e mandare esclusivamente la CAPI. Massimo controllo sul payload, ma si perde la copertura nei casi in cui il job server fallisce, e va comunque garantita la presenza di
oppref— che senza pixel dipende interamente dalla vostra cattura.
L’identificativo va generato lato server a partire dal codice ordine ed esposto alla pagina di conferma. Generarlo nel client con un valore casuale garantisce chiavi diverse fra browser e server, e quindi doppio conteggio sistematico.
Fonte: Conversions API
oppref: il punto in cui l’attribuzione si rompe davvero
Se dovessi indicare un solo punto in cui questi progetti falliscono, è questo. Tutto il resto è configurazione; questo è architettura.

Il pixel cattura oppref da solo e lo mette in __oppref. Ma __oppref è un cookie first-party scritto via JavaScript, e ha due problemi che la documentazione non affronta perché non sono problemi di OpenAI: non attraversa i confini di dominio e non sopravvive alle politiche di storage dei browser quanto vorreste.
Il ciclo di vita che dovete implementare
1. Atterraggio → leggere ?oppref= dalla query string
2. Persistere → sessione server-side o cookie scritto via Set-Cookie
3. Attraversare → propagare verso il dominio transazionale, se diverso
4. Ricongiungere → associare oppref al codice ordine nel backend
5. Inviare → campo di livello evento "oppref" nel payload CAPI
Lo step 2 è quello che fa la differenza fra un’implementazione che regge e una che degrada in silenzio. Catturate oppref lato server e scrivetelo voi come cookie via header Set-Cookie, non affidatevi al cookie del pixel.
<?php
// Middleware da eseguire il prima possibile nel ciclo di richiesta,
// prima di qualsiasi output. Vale per ogni pagina, non solo la landing.
//
// Perché duplicare quello che fa già il pixel:
// - questo cookie è leggibile lato server (il pixel scrive per il browser)
// - Set-Cookie non subisce il cap di 7 giorni di Safari ITP sui cookie JS
// - sopravvive anche se lo script del pixel viene bloccato
if (isset($_GET['oppref']) && $_GET['oppref'] !== '') {
// Stringa opaca: nessuna normalizzazione, nessun troncamento.
// Si valida solo la forma, per non riflettere input arbitrario.
$oppref = $_GET['oppref'];
if (preg_match('/^[A-Za-z0-9_\-\.]{1,512}$/', $oppref) === 1) {
setcookie('app_oppref', $oppref, [
'expires' => time() + 60 * 60 * 24 * 90, // allineare alla finestra di attribuzione
'path' => '/',
'domain' => '.example.com', // dominio padre: copre i sottodomini
'secure' => true,
'httponly' => true, // non serve al JS di pagina
'samesite' => 'Lax', // 'None' solo se serve in contesti cross-site
]);
// In parallelo: se esiste già una sessione, legarlo lì.
$_SESSION['oppref'] = $oppref;
}
}
Il domain impostato sul dominio padre è ciò che fa funzionare lo scenario più comune — sito su www.example.com, checkout su booking.example.com — senza altre acrobazie.
Perché il cookie del pixel non basta: ITP e il cap sui cookie JavaScript
Safari applica da anni un limite che va conosciuto quando si progetta una finestra di attribuzione. Il comportamento documentato da WebKit è che ITP limita a sette giorni la scadenza dei cookie client-side, cioè quelli scritti via document.cookie. I cookie impostati dal server con header Set-Cookie non ricadono in quel cap.
C’è un secondo meccanismo, più aggressivo, introdotto con ITP 2.3: “when a webpage is navigated to from a domain classified by ITP and the landing URL has a query string or fragment, the expiry of persistent client-side cookies created on that page is 24 hours”. La condizione è che il dominio di provenienza sia classificato da ITP come tracker cross-site.
Va detto con precisione, perché è il tipo di affermazione su cui è facile sbagliare: non risulta che chatgpt.com sia oggi classificato da ITP, e la lista di classificazione non è pubblica né statica — è calcolata sul dispositivo dell’utente in base al comportamento osservato. L’implicazione non è “oggi siete rotti”, è che state costruendo l’attribuzione su una struttura che può degradare a 24 ore senza preavviso e senza errori in console. Un cookie scritto dal vostro server non ha quel profilo di rischio. Se volete approfondire i meccanismi di persistenza lato browser, ne ho scritto in dettaglio nella guida tecnica ai cookie.
Fonti: WebKit — ITP 2.3 · WebKit — Full Third-Party Cookie Blocking
La matrice degli scenari cross-domain
| Scenario | Cosa succede a __oppref | Intervento |
|---|---|---|
| Checkout sullo stesso dominio | Cookie leggibile per tutto il funnel | Nessuno oltre alla cattura server-side |
| Sottodominio controllato | Cookie non condiviso se scritto su host esatto | Cookie sul dominio padre via Set-Cookie |
| Dominio diverso, backend accessibile | Cookie non disponibile alla conversione | Propagazione di oppref nell’URL di passaggio + cattura lato destinazione |
| Fornitore terzo, backend chiuso | Nessun accesso al momento della conversione | Stitching su codice ordine, o invio CAPI dal vostro backend con i dati ricevuti via webhook |
| Checkout in iframe | Contesto cross-site, cookie soggetti al blocco third-party | postMessage verso il parent, o passaggio nell’URL dell’iframe |
| Fornitore non modificabile | Attribuzione impossibile lato piattaforma | Va dichiarato prima del lancio, non dopo |
Nel terzo scenario la propagazione via URL è banale ma va fatta bene: oppref deve sopravvivere all’URL-encoding e non deve essere ricostruito da logiche di redirect che rimontano la query string.
Il killer silenzioso: la query string che sparisce prima di tutto
Prima ancora dei cookie, c’è un livello che vanifica tutto e che nessuno controlla: oppref deve arrivare al browser. Ho visto implementazioni perfette misurare zero perché il parametro veniva rimosso a monte.
I punti da verificare, in ordine di frequenza:
- Redirect che perdono la query string. Il più comune:
http → https,non-www → www, aggiunta di trailing slash. Molte regole di rewrite scritte a mano non riportano?$1. Si verifica con uncurl -sILsull’URL completo e si guarda laLocationdi ogni hop. - Canonicalizzazione lato CMS. Alcuni plugin e alcuni framework normalizzano l’URL e fanno un 301 verso la versione “pulita”, senza parametri sconosciuti.
- Cache key del CDN. Se la cache ignora la query string va bene per il rate di hit, ma se il CDN striscia i parametri non in allowlist il valore non arriva mai all’origin né alla pagina. Su Cloudflare va controllata la Cache Key e ogni Transform Rule attiva.
- Consent banner con reload. Alcune CMP ricaricano la pagina dopo la scelta dell’utente: se il reload punta all’URL canonico invece che a quello corrente,
opprefevapora prima che il pixel possa leggerlo. - Single Page Application. Se la landing è una SPA che riscrive l’URL con
history.replaceStateprima dell’init del pixel, il parametro non c’è più al momento della lettura.
Il test è di trenta secondi e va fatto per primo:
# Verifica che oppref sopravviva a tutta la catena di redirect.
curl -sIL "https://example.com/landing?oppref=TEST123&utm_source=chatgpt" \
| grep -iE '^(HTTP/|location:)'
# Atteso: nessun redirect, oppure ogni Location che riporta ?oppref=TEST123
GA4: dove finisce davvero il traffico delle ChatGPT Ads
Qui si concentra la parte che la documentazione OpenAI non copre e che i team di analytics scoprono a campagna avviata. GA4 è un sistema completamente separato: non conosce oppref, non riceve nulla dalla piattaforma, e classifica il traffico con le sue regole.

Il canale “AI Assistant” e il conflitto con il tagging a pagamento
Nel 2026 il Default Channel Group di GA4 ha guadagnato un canale AI Assistant. La regola è precisa e vale la pena leggerla nella formulazione ufficiale: il canale raccoglie il traffico in cui “the medium exactly matches ai-assistant“, e GA4 imposta automaticamente medium = ai-assistant con campaign = (ai-assistant) quando il referrer corrisponde a una lista interna di AI Assistant. Google cita a titolo di esempio ChatGPT, Gemini, DeepSeek, Copilot e Grok, ma l’elenco completo non è pubblicato: trattatelo come indicativo, non come contratto.
Ed è qui che nasce il problema, perché è un conflitto strutturale e non un bug di configurazione:
Se taggate le vostre ChatGPT Ads con utm_medium=cpc, quel traffico non finirà mai in AI Assistant. Il medium manuale sovrascrive quello dedotto dal referrer, e la regola del canale AI Assistant richiede una corrispondenza esatta su ai-assistant.
Dove finisce, allora? Dipende da una condizione verificabile:
| Medium impostato | Canale risultante | Cosa ottenete | Cosa perdete |
|---|---|---|---|
cpc (o ppc, paid*) | Paid Search se chatgpt.com è nella lista SOURCE_CATEGORY_SEARCH, altrimenti Paid Other | Il traffico è riconosciuto come a pagamento; entra nei report di spesa e nei confronti con gli altri canali paid | Sparisce dal canale AI Assistant; il confronto con il traffico AI organico non è più immediato |
ai-assistant | AI Assistant | Coerenza con il traffico AI organico nello stesso canale | Il traffico a pagamento diventa indistinguibile da quello organico; nessuna classificazione paid |
referral o nessun UTM | AI Assistant (per auto-detection sul referrer) | Zero lavoro | Nessuna distinzione fra click pagati e citazioni organiche: il caso peggiore |
La raccomandazione, senza ambiguità: usate utm_medium=cpc e ricostruite la vista AI con un canale personalizzato. Il motivo è che la classificazione “a pagamento” è irrecuperabile — nessun report standard di GA4 ve la restituisce se il medium non la dichiara — mentre l’appartenenza alla famiglia AI si ricostruisce in due minuti con un Custom Channel Group. Il contrario non è vero.
Se chatgpt.com finisca in Paid Search o in Paid Other dipende dalla lista SOURCE_CATEGORY_SEARCH di Google, che cambia nel tempo. Non fidatevi di quello che leggete in giro, incluso quello che leggete qui: verificatelo sulla vostra proprietà in due minuti, con un hit reale.
1. Aprite in incognito un URL della vostra landing con i parametri completi:
https://www.example.com/landing?utm_source=chatgpt&utm_medium=cpc&utm_campaign=test_dcg
2. GA4 → Report → Tempo reale, oppure Amministrazione → DebugView
3. Aggiungete la dimensione "Gruppo di canali predefinito della sessione"
4. Leggete il valore: Paid Search o Paid Other. È la vostra risposta, oggi.
Fonte: Google — Default channel group
Il Custom Channel Group da configurare
In GA4, Amministrazione → Impostazioni dati → Gruppi di canali → Crea nuovo gruppo di canali. Il gruppo personalizzato si affianca al default e va applicato nei report che usate davvero, sostituendo la dimensione predefinita.
I due canali da aggiungere in cima all’ordine di valutazione (le regole si valutano dall’alto verso il basso, la prima che corrisponde vince):
Canale "AI Paid"
Condizione: Source corrisponde all'espressione regolare
^(chatgpt|chatgpt\.com|openai|perplexity|claude|gemini|copilot|grok|deepseek)
AND Medium corrisponde all'espressione regolare
^(.*cp.*|ppc|retargeting|paid.*)$
Canale "AI Organic"
Condizione: Source corrisponde all'espressione regolare
^(chatgpt|chatgpt\.com|openai|perplexity|perplexity\.ai|claude|claude\.ai|
gemini|copilot|grok|deepseek)
OR Medium corrisponde esattamente a ai-assistant
Due avvertenze operative. La prima: il gruppo di canali personalizzato non è retroattivo oltre il periodo di rielaborazione previsto da GA4, quindi va creato prima del lancio della campagna, non alla prima riunione di consuntivo. La seconda: poiché la lista delle fonti del canale nativo non è pubblicata e può cambiare, non date per scontato che copra tutti gli assistenti che vi interessano. Il modo di saperlo non è dedurlo: è guardare, per un periodo noto, quali sorgenti la vostra proprietà sta effettivamente classificando come AI Assistant e quali restano in Referral. Il gruppo personalizzato vi serve comunque, perché è l’unico che controllate voi. Su questo tema ho scritto una guida dedicata al traffico referral dalle piattaforme AI in GA4.
La convenzione UTM
Gli UTM non sono un parametro di OpenAI e non compaiono nella documentazione ChatGPT Ads: sono uno standard Google Analytics, e la convenzione qui sotto è una proposta operativa, non un requisito di piattaforma.
utm_source=chatgpt
utm_medium=cpc
utm_campaign={brand}_{trimestre}_{mercato}_{tema}
utm_content={id_annuncio}
utm_term={id_adgroup}
Esempio completo su un URL di destinazione:
https://www.example.com/offerte/autunno
?utm_source=chatgpt
&utm_medium=cpc
&utm_campaign=acme_q4_2026_it_outdoor
&utm_content=ad_variant_a
Tre verifiche da fare prima di mandare in campagna gli URL definitivi:
chatgpt.comnon deve essere nell’elenco delle esclusioni referral del data stream, altrimenti perdete anche la fallback di attribuzione quando gli UTM saltano.- Nessuna regola di raggruppamento canali preesistente deve riclassificare
utm_source=chatgptin modo indesiderato: molte proprietà hanno gruppi custom scritti anni fa con catch-all aggressivi. - Nessun data filter o impostazione di stream deve rimuovere i parametri di query dalla
page_location. Serve per il punto successivo.
Per la sintassi e le convenzioni di naming vale quanto scritto nella guida completa ai parametri UTM; qui l’unico vincolo aggiuntivo è la coerenza fra utm_content e l’ID annuncio in Ads Manager, senza la quale la riconciliazione a livello di creatività diventa manuale.
Fonte: Google — Collect campaign data with custom URLs
Portare oppref dentro GA4: l’anello che unisce i due mondi
Questo è il pezzo che trasforma GA4 da “report parallelo” a strumento di diagnosi dell’attribuzione, e costa pochissimo.
Il metodo a costo zero. GA4 conserva la query string completa in page_location, e la dimensione “Pagina di destinazione + stringa di query” la espone nei report. Un’esplorazione filtrata su quella dimensione con contiene oppref= isola esattamente le sessioni atterrate da un click su annuncio ChatGPT, anche se gli UTM sono stati persi per strada. È il controincrociato perfetto: se avete 400 sessioni con oppref= e 250 sessioni con utm_medium=cpc, sapete che state perdendo UTM da qualche parte lungo la catena di redirect.
Il metodo strutturato. Registrare oppref come dimensione personalizzata event-scoped e popolarla su page_view. Serve quando volete usarlo nei report standard, nei segmenti e nei confronti, non solo nelle esplorazioni.
// Da eseguire dopo il consenso analytics e prima (o insieme) al page_view.
// Attenzione alla cardinalità: oppref è ad alta cardinalità per definizione.
// Valutare se registrarlo come dimensione o se limitarsi a un flag booleano.
(function () {
var oppref = new URLSearchParams(window.location.search).get("oppref");
if (!oppref) return;
gtag("event", "chatgpt_ads_landing", {
oppref: oppref, // dimensione personalizzata event-scoped
has_oppref: true // alternativa a bassa cardinalità
});
})();
Poi in Amministrazione → Definizioni personalizzate → Crea dimensione personalizzata, ambito Evento, parametro oppref.
La scelta consigliata sui volumi alti è la dimensione booleana has_oppref: GA4 applica limiti di cardinalità che sull’alta varietà producono il temuto (other), e per la diagnosi vi serve sapere quante sessioni erano attribuite, non quale identificatore avesse ognuna. Il valore puntuale, se vi serve, ce l’avete già nell’export BigQuery dentro page_location.
Con l’export BigQuery attivo, la riconciliazione diventa una query:
-- Sessioni atterrate con oppref, per giorno, con conversioni e ricavi.
-- Confrontare il conteggio con le conversioni riportate in Ads Manager:
-- il delta è il vostro tasso di perdita lungo il funnel.
WITH landing AS (
SELECT
event_date,
(SELECT value.int_value FROM UNNEST(event_params)
WHERE key = 'ga_session_id') AS session_id,
user_pseudo_id,
MAX(REGEXP_CONTAINS(
(SELECT value.string_value FROM UNNEST(event_params)
WHERE key = 'page_location'), r'[?&]oppref=')) AS has_oppref
FROM `progetto.analytics_XXXXXXXXX.events_*`
WHERE _TABLE_SUFFIX BETWEEN '20261001' AND '20261031'
GROUP BY event_date, session_id, user_pseudo_id
)
SELECT
event_date,
COUNTIF(has_oppref) AS sessioni_da_chatgpt_ads,
COUNT(*) AS sessioni_totali,
ROUND(COUNTIF(has_oppref) / COUNT(*) * 100, 2) AS quota_pct
FROM landing
GROUP BY event_date
ORDER BY event_date;
Il traffico senza referrer, e perché una parte non la vedrete mai
Va messo in conto e va detto agli stakeholder prima, non dopo: una quota del traffico da ChatGPT arriva senza referrer. Le app native desktop e mobile, alcune configurazioni di privacy e i client che aprono i link in contesti isolati non trasmettono l’header Referer. Quel traffico, senza UTM, in GA4 è Direct.
È la ragione più forte per taggare gli URL delle campagne: gli UTM sopravvivono all’assenza di referrer, il referrer no. Su una campagna a pagamento l’URL di destinazione lo controllate voi, quindi non avete scuse; è il traffico organico da ChatGPT quello che resterà irrimediabilmente sottostimato.
Cross-domain measurement: due sistemi, lo stesso problema, due fix diversi
Se il flusso attraversa più domini, il problema architetturale è uno solo — l’identificatore non sopravvive al salto — ma i due sistemi si rompono in modo diverso e vanno riparati separatamente. È il punto in cui i team fanno metà del lavoro e credono di averlo finito.
| ChatGPT Ads | GA4 | |
|---|---|---|
| Cosa si rompe | oppref / __oppref non disponibili alla conversione | Nuova sessione, sorgente = referral dal primo dominio |
| Sintomo | Conversioni non attribuite in Ads Manager | Conversioni attribuite al vostro stesso dominio |
| Fix | Cookie su dominio padre, propagazione in URL, o stitching server-side | Configurazione cross-domain nel data stream (parametro _gl) |
| Chi lo implementa | Backend | Chi presidia GA4 |
Configurare il cross-domain in GA4 — Amministrazione → Stream di dati → Configura impostazioni tag → Configura domini — non risolve oppref, e propagare oppref non risolve la sessione GA4. Servono entrambi, e vanno testati entrambi.
Il mirror server-side in GA4: Measurement Protocol
Se mandate la conversione a OpenAI via CAPI, ha senso mandarla anche a GA4 via Measurement Protocol, così i due sistemi vedono lo stesso evento server. Attenzione a due differenze rilevanti rispetto alla CAPI:
| OpenAI CAPI | GA4 Measurement Protocol | |
|---|---|---|
| Endpoint | bzr.openai.com/v1/events | www.google-analytics.com/mp/collect (o region1. per l’UE) |
| Autenticazione | Authorization: Bearer <API-KEY> | api_secret in query string |
| Finestra di backdating | 7 giorni | 72 ore |
| Eventi per richiesta | 1.000 | 25 |
| Identificatore utente | oppref / obref | client_id (dal cookie _ga) |
| Risposta | Stato HTTP significativo | 2xx anche con payload invalido |
Le due trappole del Measurement Protocol: la finestra di 72 ore è molto più stretta di quella OpenAI, quindi lo stesso job non può servire entrambi se gestite conversioni differite; e serve il client_id reale dell’utente, letto dal cookie _ga lato browser e persistito insieme all’ordine — inventarlo genera un utente nuovo per ogni conversione e distrugge i report di sessione.
Fonte: Google — Measurement Protocol per GA4
GTM: cosa mettere dove, e cosa non mettere affatto
La domanda arriva sempre, e la risposta non è “tutto in GTM”.
Snippet di init: hardcoded nel template. La documentazione chiede di posizionare lo script vicino all’inizio del <head> per non perdere le conversioni precoci. GTM è asincrono e non garantisce quella posizione: fra il caricamento del container, la valutazione dei trigger e l’eventuale attesa del consenso passano centinaia di millisecondi durante i quali un utente veloce può già aver convertito. Se avete dubbi sull’installazione del container, la verifica dell’installazione di GTM è il primo controllo da fare.
Eventi di funnel: via GTM. Qui la flessibilità vale più della latenza. Due accorgimenti obbligatori:
- Tag sequencing: il tag evento deve avere il tag di init come “setup tag”, con l’opzione che impedisce l’esecuzione se il setup fallisce. Senza, un evento può partire prima che
oaiqesista — e finisce in coda, ma solo se lo stub della command queue è già presente. Con l’init hardcoded lo stub c’è sempre: è un altro argomento a favore della scelta precedente. - Consent settings: nella scheda “Consenso” del tag, configurate i controlli aggiuntivi del consenso sui segnali che la vostra CMP usa per la categoria marketing. Ricordando che questi segnali governano l’esecuzione del tag GTM, non lo stato interno del pixel:
oaiq("consent", …)va comunque chiamato.
Conversions API: server-side GTM, se lo avete già. Un container server-side è il posto naturale per l’invio CAPI, con il vantaggio di poter leggere e riscrivere i cookie lato server. Se non lo avete, non introducetelo per questo: un endpoint nel vostro backend fa lo stesso lavoro con meno infrastruttura da governare.
Una nota sui template di terze parti. Esistono template GTM community per il pixel OpenAI. Non sono ufficiali né approvati da OpenAI. Vanno valutati leggendone il codice, come qualsiasi template custom, e non vanno presentati a un cliente come soluzione ufficiale.
Advanced matching: cos’è, quando serve e come si implementa
Il problema che risolve
Fin qui l’attribuzione ha funzionato così: la persona clicca, arriva con oppref, quel valore sopravvive lungo il funnel e alla conversione dice “questa vendita nasce da quel click”. Tutto poggia su un identificatore che deve restare disponibile dal primo all’ultimo passaggio.
Nella realtà, in una quota non trascurabile dei casi non lo è. Il cookie è scaduto perché la persona ha comprato tre settimane dopo il click. Ha cliccato dal telefono e comprato dal desktop. Un ad blocker ha impedito al pixel di scrivere qualsiasi cosa. Il passaggio al dominio del checkout ha rotto la continuità. In tutti questi casi la conversione esiste e voi la vedete nel gestionale, ma arriva a OpenAI senza il documento d’identità del click, e resta non attribuita.
L’advanced matching è il piano B: invece dell’identificatore di click, fornite dei dati che identificano la persona — email, telefono, il vostro ID cliente. Se corrispondono a un utente ChatGPT che ha visto o cliccato l’annuncio, la conversione viene ricongiunta comunque. La documentazione lo dice in una riga: “Automatic advanced matching improves website conversion measurement by helping match more conversions to your ads when a click identifier is unavailable.”
I dati non viaggiano mai in chiaro. Vengono normalizzati e hashati in SHA-256 nel browser, prima di partire: “Raw customer information is not sent to OpenAI through automatic advanced matching.” Resta comunque un trattamento di dati personali con una finalità in più rispetto alla misurazione di base, quindi va nel registro dei trattamenti e passa dal DPO. Un hash non è un dato anonimo: è uno pseudonimo, e serve esattamente a riconoscere una persona.
Due modi di attivarlo, e uno non richiede codice
Questa distinzione decide quanto lavoro avete davanti, e nella documentazione è facile non notarla.
| Advanced matching automatico | Advanced matching manuale | |
|---|---|---|
| Chi raccoglie i dati | Il pixel, dai campi presenti nella pagina | Voi, passando l’oggetto user |
| Codice da scrivere | Nessuno | Normalizzazione + hashing + init |
| Controllo su cosa parte | Basso: decide il pixel cosa riconoscere | Totale: mandate solo ciò che decidete |
| Quando conviene | Attivazione rapida, form standard | Dati dietro login, ID cliente da CRM, requisiti privacy stringenti |
Sull’automatico la documentazione è netta: “You do not need to manually pass customer information or make any changes to your Pixel implementation.” Il pixel rileva da solo le informazioni supportate presenti nella pagina, le normalizza e le hasha.
Il rovescio della medaglia è proprio quel “da solo”. Su un checkout con campi email, telefono e indirizzo, il pixel raccoglie ciò che trova senza che voi abbiate deciso cosa. Se il DPO vuole controllo puntuale su quali categorie di dati lasciano il browser — ed è la posizione ragionevole su un sito con volumi — la strada è il matching manuale, dove parte solo l’hash che avete costruito voi.
Lato pixel: perché user va in init e non in measure
L’oggetto user non è un parametro dell’evento: è una proprietà del contesto della pagina. La documentazione lo chiama request-scoped, e in pratica significa questo: lo dichiarate una volta in init, e da quel momento tutti gli eventi sparati su quella pagina lo ereditano automaticamente. Non va ripetuto a ogni oaiq("measure", ...) — e infatti la documentazione lo vieta: “User data is request-scoped, so don’t add it to individual oaiq("measure", ...) calls.”
// I dati utente si dichiarano UNA VOLTA, nell'init.
// Da qui in poi ogni measure sulla pagina li porta con sé.
oaiq("init", {
pixelId: "<PIXEL-ID>",
user: {
email_sha256: "<sha256>",
phone_number_sha256: "<sha256>",
external_id_sha256: "<sha256>",
// i campi geografici NON si hashano: stringhe grezze
country: "IT", city: "Milano", region: "Lombardia", postal_code: "20121"
}
});
La trappola dell’ordine: “ereditano” vale solo per gli eventi successivi
È il punto che fa fallire l’advanced matching in produzione con l’implementazione formalmente corretta.
Gli eventi ereditano l’oggetto user presente al momento in cui partono. Non c’è nessun recupero retroattivo: un evento sparato prima che i dati utente siano stati dichiarati parte senza, e resta senza per sempre. Sulla pagina di conferma dell’ordine, dove i dati del cliente ci sono per definizione, la sequenza corretta è quindi obbligata:
- calcolare gli hash rispettando le regole di normalizzazione del paragrafo successivo;
- chiamare
initcon l’oggettousercompleto; - solo allora sparare
order_created.
Invertire i punti 2 e 3 non produce alcun errore: produce una conversione senza dati di matching, cioè esattamente ciò che l’advanced matching doveva evitare.
Lo stesso vale quando i dati arrivano a metà navigazione, tipicamente dopo il login: “If user data becomes available after the first init call, such as after login, call init again with the complete user object.” Due dettagli operativi che vale la pena fissare, perché sono le due domande che arrivano subito dopo:
- L’oggetto va passato completo, non in forma incrementale: la seconda
initnon aggiunge campi alla prima, la sostituisce. Se al login avete email e ID cliente, mandateli entrambi, non solo quello nuovo. pixelIdsi può omettere nelleinitsuccessive, ma solo con un pixel solo sulla pagina: “When a page initializes only one pixel, you can omitpixelIdafter the first successful initialization. If a page initializes more than one pixel, always include the intendedpixelIdwhen updating user data.” Con più pixel — succede sui siti che convivono con un container ereditato — ometterlo significa aggiornare i dati del pixel sbagliato.
Le regole di normalizzazione, da rispettare alla lettera
| Dato | Regola | Esempio |
|---|---|---|
| Trim, minuscolo | [email protected] → [email protected] | |
| Telefono | Prefisso internazionale, rimuovere spazi, parentesi, punti e trattini, poi il + iniziale e gli zeri iniziali. Risultato 8–15 cifre | +1 (415) 555-2671 → 14155552671 (esempio ufficiale) · +39 (02) 555-2671 → 39025552671 |
| External ID | Solo trim. Preservare maiuscole e ogni altro carattere | Cust_A91 → Cust_A91 |
| Nome e cognome | Minuscolo, rimozione di spazi e punteggiatura ASCII. Preservare i caratteri non-ASCII | D'Amico → damico · José → josé |
L’ultima riga è quella che si sbaglia sistematicamente: niente traslitterazione degli accenti. Un José normalizzato in jose produce un hash diverso da quello atteso e non corrisponde mai. È l’opposto della convenzione a cui la maggior parte degli sviluppatori è abituata dalle slug e dagli URL.
Ogni valore normalizzato va codificato in UTF-8, se ne calcola il digest SHA-256 e si invia come stringa esadecimale minuscola di 64 caratteri. I valori geografici (country, city, region, postal code) si inviano invece come stringhe grezze, mai hashati.
import hashlib
import re
import unicodedata
def norm_email(v: str) -> str:
return v.strip().lower()
def norm_phone(v: str) -> str:
"""Prefisso internazionale mantenuto, separatori rimossi, poi + e zeri iniziali."""
digits = re.sub(r"[\s().\-]", "", v)
digits = digits.lstrip("+").lstrip("0")
if not (8 <= len(digits) <= 15) or not digits.isdigit():
raise ValueError(f"telefono fuori specifica: {v!r}")
return digits
def norm_name(v: str) -> str:
"""Minuscolo, via spazi e punteggiatura ASCII. I non-ASCII restano."""
v = v.strip().lower()
out = []
for ch in v:
if ch.isspace():
continue
# rimuove SOLO la punteggiatura ASCII; è preservato tutto il resto,
# accenti inclusi: 'josé' resta 'josé', non diventa 'jose'
if ord(ch) < 128 and not ch.isalnum():
continue
out.append(ch)
return "".join(out)
def norm_external_id(v: str) -> str:
return v.strip() # nessun lowercase, nessuna rimozione
def h(v: str) -> str:
return hashlib.sha256(v.encode("utf-8")).hexdigest()
assert norm_phone("+1 (415) 555-2671") == "14155552671" # esempio ufficiale
assert norm_phone("+39 (02) 555-2671") == "39025552671" # lo 0 urbano NON si perde
assert norm_name("D'Amico") == "damico"
assert norm_name("José") == "josé"
assert unicodedata.normalize("NFC", norm_name("José")) == "josé"
I nomi cambiano fra pixel e CAPI
Il pixel usa il singolare, la CAPI usa liste al plurale. Copiare i nomi da un lato all’altro è l’errore più veloce da fare e il più lento da diagnosticare, perché non produce errori: produce semplicemente zero match.
| Pixel (singolare) | CAPI (lista plurale) |
|---|---|
email_sha256 | emails_sha256 |
phone_number_sha256 | phone_numbers_sha256 |
external_id_sha256 | external_ids_sha256 |
first_name_sha256 | first_names_sha256 |
last_name_sha256 | last_names_sha256 |
country / city / region / postal_code | countries / cities / regions / postal_codes |
Per ogni lista l’API usa i primi tre valori validi e unici nell’ordine fornito e ignora gli altri, senza rifiutare l’evento. L’oggetto user della CAPI accetta inoltre ip_address, user_agent e android_advertising_id (formato UUID, non IDFA).
Fonti: Measurement Pixel · Conversions API
Riconciliazione Ads Manager ↔ GA4: perché i numeri non torneranno mai
Non torneranno, e non perché uno dei due sbagli. È lo stesso fenomeno che si osserva da sempre fra Google Ads e Analytics, e vale la pena rileggere le ragioni per cui le conversioni di Ads e Analytics non coincidono: cambiano gli attori, non la meccanica.
| Aspetto | Ads Manager | GA4 |
|---|---|---|
| Identificatore | oppref / obref | client_id + UTM |
| Attribuzione click | Finestra configurata a livello campagna | Modello di attribuzione della proprietà |
| View-through | Finestra fissa 1 giorno, metrica separata | Non disponibile |
| Cosa entra in “Conversions” | Solo click-through | Secondo configurazione dei key event |
| Precedenza | Click prevale su view-through | Non applicabile |
| Base di CPA e bidding | Sempre click-through | Non applicabile |
| Momento di attribuzione | Data del click | Data della conversione |
L’ultima riga è quella che genera le discussioni più lunghe: Ads Manager attribuisce la conversione al giorno del click, GA4 al giorno della conversione. Su un ciclo di acquisto lungo, i due grafici giornalieri sono sfasati per costruzione, e confrontarli giorno per giorno non ha senso. Si confrontano su finestre che coprono l’intero ciclo, tipicamente mensili.
La decisione da mettere per iscritto prima del lancio è una sola: quale numero è il numero ufficiale. Se la discussione arriva a consuntivo, diventa una discussione sulla fiducia invece che sui risultati. La risposta ragionevole quasi sempre è: Ads Manager per giudicare e ottimizzare la campagna (è la fonte su cui l’algoritmo impara), GA4 e il gestionale per giudicare il valore commerciale a valle. Con un delta atteso dichiarato in anticipo.
Il terzo numero, quello che conta davvero. Il conteggio del vostro gestionale, filtrato sugli ordini che hanno un oppref associato. È l’unico che non dipende dalla finestra di attribuzione di nessuno: è il fatturato realmente riconducibile al canale, ed è disponibile solo se avete implementato la persistenza server-side descritta prima. È un altro argomento per non trattarla come un dettaglio implementativo.
Casistiche: sintomo, causa, verifica
La tabella che uso in fase di QA e di troubleshooting post-lancio. La colonna della verifica è la parte utile: la diagnosi a occhio, su questi sistemi, fa perdere giorni.
| Sintomo | Causa più probabile | Come verificare |
|---|---|---|
| Zero conversioni in Ads Manager, il sito converte | oppref non arriva o non viene propagato | curl -sIL sull’URL con ?oppref=TEST, poi controllo del cookie in DevTools → Application |
| Conversioni contate il doppio | event_id pixel ≠ id CAPI | Confronto dei due valori nell’Event Stream, filtrando su un ordine reale |
| Valori 100 volte troppo bassi | amount inviato in unità intera anziché in centesimi | Un ordine reale nell’Event Stream, confronto con il gestionale |
| Conversioni “orfane”, senza campagna | Pixel installato solo sulla thank you page | Ricerca dello snippet nel sorgente della landing page |
| Il pixel non carica affatto | CSP che blocca bzrcdn.openai.com | Console del browser: violazioni CSP; Network: richiesta bloccata |
| Conversioni mancanti solo su Safari | Cookie __oppref scaduto per il cap ITP | Test su Safari con ritorno differito, controllo scadenza cookie |
| Conversioni mancanti in modo intermittente | Evento sparato prima della risoluzione del consenso | Rallentare artificialmente la CMP in staging e verificare se l’evento parte |
| Batch CAPI che falliscono in blocco | Un evento invalido fa cadere l’intero batch | Ri-invio evento per evento con validate_only: true |
| GA4: traffico ChatGPT Ads in “Direct” | UTM persi in un redirect, o assenza di referrer | Confronto fra sessioni con oppref= in page_location e sessioni con utm_medium=cpc |
| GA4: traffico in “Referral” da chatgpt.com | URL di campagna senza UTM | Controllo degli URL di destinazione in Ads Manager |
| GA4: conversioni attribuite al vostro dominio | Cross-domain non configurato | Report Acquisizione: sorgente = vostro dominio |
| SPA: nessun evento dopo la prima pagina | Eventi legati al page load anziché al routing | Hook sul router, non su DOMContentLoaded |
| Eventi CAPI rifiutati per timestamp | Conversione confermata oltre 7 giorni dopo | Log del job di invio, confronto fra timestamp_ms e now |
| Advanced matching senza effetto | Normalizzazione errata (accenti traslitterati, telefono con +) | Ricalcolo degli hash su un caso noto e confronto |
QA pre-lancio: la checklist
Il setup di misurazione va chiuso prima del lancio, e non per igiene: su una campagna oCPC l’evento obiettivo non è modificabile dopo la creazione della campagna. Un evento sbagliato non si corregge, si ricrea la campagna — perdendo tutto l’apprendimento accumulato. Vale la pena rileggere i vincoli delle campagne ottimizzate per conversioni prima di creare la prima campagna, non dopo.
Pixel e pagine
- Snippet presente nel
<head>di tutte le pagine del funnel, in posizione alta - Snippet presente anche sull’eventuale dominio del sistema transazionale
debug: truein staging, disattivato in produzione- Nessun errore CSP in console, su tutti i template
Consenso
oaiq("consent", false)chiamato prima diinit- Callback CMP verificato in entrambe le direzioni, concessione e revoca
- Sulla pagina di conferma: il consenso è risolto prima dell’invio dell’evento
- Lo stato di consenso è persistito lato server insieme all’ordine
- Alla revoca,
obrefnon viene più inoltrato nei payload CAPI
Identificatori
opprefsopravvive a tutta la catena di redirect (verificato concurl -sIL)opprefcatturato lato server e scritto viaSet-Cookiesul dominio padreopprefpresente nel payload CAPI come campo di livello eventoobrefletto da__obrefe inserito dentrouser, non a livello eventoopprefpropagato o ricongiunto attraverso il confine di dominio
Eventi e valori
event_id(pixel) identico aid(CAPI), generato lato server dal codice ordine- Valori monetari in unità minore, verificati su un ordine reale
currencypresente ogni volta che c’èamount- Solo campi documentati dentro
contents[] timestamp_msentro la finestra di 7 giorni / +10 minuti- Un evento invalido non fa cadere l’intero batch in produzione
- Test CAPI con
validate_only: truesuperato
GA4
- UTM applicati a tutti gli URL di destinazione e verificati in Tempo reale
- Gruppo di canali personalizzato creato prima del lancio
chatgpt.comnon presente nelle esclusioni referral- Cross-domain configurato se il funnel attraversa più domini
- Dimensione
opprefohas_opprefregistrata e popolata
Sicurezza
- API key assente da qualsiasi codice lato client, bundle e repository
- Cookie applicativi con
Secure,HttpOnlyeSameSitecoerenti
Gli strumenti di verifica
- Console del browser con
debug: true— logging dell’attività dell’SDK - Event Stream in Ads Manager (scheda Conversions → View event stream) — il payload come lo riceve OpenAI, l’unico posto dove si verificano davvero i valori monetari
- Pannello Network — richieste verso
bzr.openai.comebzrcdn.openai.com - GA4 DebugView e Tempo reale — verifica di UTM, canale e dimensioni personalizzate
validate_only: true— test CAPI senza inquinare i daticurl -sIL— la catena di redirect, che è il primo posto in cui si perde tutto
L’ordine di implementazione
Il percorso critico non è il codice: sono i due punti che dipendono da soggetti esterni al team di sviluppo. Vanno aperti per primi.
- Sblocco CSP con l’IT e verifica dell’architettura di dominio del sistema transazionale — in parallelo, subito
- Avvio della validazione privacy con il DPO — in parallelo, subito
- Snippet base + integrazione del consenso
- Cattura e persistenza server-side di
oppref - Eventi browser di funnel
- Conversions API e cattura di
obref - Deduplica e allineamento degli identificativi
- Advanced matching, dopo l’ok del DPO
- UTM, gruppo di canali GA4, dimensioni personalizzate
- QA end-to-end con
validate_onlye verifica in Event Stream
I punti 1 e 2 sono quelli che determinano se il go-live è a due settimane o a due mesi. Ogni progetto in cui sono stati affrontati per ultimi è slittato. Se state valutando l’attivazione di una campagna su ChatGPT Ads, la domanda da fare per prima al team tecnico non è “quanto ci mettete a installare il pixel”: è “il sistema transazionale è sullo stesso dominio, e possiamo intervenire lato server?”.
Serve una mano sull’implementazione?
Questa guida è la specifica tecnica che consegniamo ai team di sviluppo prima di attivare una campagna: se avete un’implementazione da progettare, da validare o da diagnosticare perché i numeri non tornano, ci occupiamo di disegno della misurazione, affiancamento tecnico agli sviluppatori e controllo qualità pre-lancio, oltre alla gestione delle campagne.
Scopri il servizio di consulenza e gestione ChatGPT Ads — audit dell’account, disegno del tracciamento e gestione delle campagne.
Fonti ufficiali
Documentazione OpenAI per inserzionisti, verificata il 6 settembre 2026. Aggiungendo .md all’URL si ottiene la versione Markdown della pagina, comoda per copiare gli snippet senza formattazione.
| Argomento | URL |
|---|---|
| Measurement Pixel | developers.openai.com/ads/measurement-pixel |
| Conversions API | developers.openai.com/ads/conversions-api |
| Supported Events | developers.openai.com/ads/supported-events |
| Image Tag | developers.openai.com/ads/image-tag |
| Conversion-Optimized Campaigns | developers.openai.com/ads/conversion-optimized-campaigns |
| Campaign Targeting | developers.openai.com/ads/campaign-targeting |
Riferimenti Google e WebKit:
| Argomento | URL |
|---|---|
| Default channel group GA4 | support.google.com/analytics/answer/9756891 |
| Parametri UTM per GA4 | support.google.com/analytics/answer/10917952 |
| Measurement Protocol GA4 | developers.google.com/analytics/devguides/collection/protocol/ga4 |
| ITP 2.3 e link decoration | webkit.org/blog/9521 |
| Blocco completo dei cookie di terze parti | webkit.org/blog/10218 |
Autore
Mi chiamo Giovanni Sacheli e dal 2009 aiuto le aziende a farsi trovare online. Sono specializzato in SEO tecnica e PPC, competenze che applico quotidianamente nella mia agenzia, Searcus Swiss Sagl. Mi piace sviluppare strumenti a supporto del mio lavoro, ho creato SEOdata.app e cluster.army e co-scritto il libro SEO Audit Avanzato. Curo maniacalmente questo blog per colleghi e appassionati, dove mi "appunto" quello che imparo. Sono un NERD anni '80, motociclista e orgoglioso papà di due bambini.
Link:
Giovanni Sacheli
SEO Audit Avanzato
Searcus Swiss Sagl
SEOdata.app
cluster.army