Hai una campagna su ChatGPT Ads e un sito WordPress. Perché quella campagna funzioni, la piattaforma deve sapere quali click hanno prodotto un ordine o un contatto: senza quel ritorno di informazione l’algoritmo compra click e non clienti, e tu non sai quali annunci ripagano e quali stanno bruciando budget.
Questa guida installa quel collegamento. Si copiano e si incollano dei file PHP — nessun plugin da comprare, nessuna modifica al tema — e alla fine hai:
- il pixel di misurazione su tutte le pagine, con il consenso pilotato dalla tua CMP;
- l’identificatore di click (
oppref) catturato all’atterraggio e conservato fino alla conversione; - gli eventi del funnel e la conversione, inviata sia dal browser sia dal server;
- gli UTM e i canali GA4 allineati, per ritrovare quel traffico nei tuoi report.
Quanto ci vuole: mezz’ora se raccogli solo contatti da form, circa un’ora se hai WooCommerce. Le due variabili che possono allungare i tempi sono la tua CMP e l’eventuale Content Security Policy: entrambe hanno una sezione dedicata.
Cosa non è. Non è una traduzione della documentazione OpenAI. Gli snippet sono scritti per WordPress, verificati con php -l su PHP 8.2 e controllati contro i sorgenti di WooCommerce 11. Il perché di ogni scelta — deduplica, finestre di attribuzione, cosa succede se oppref non sopravvive, come GA4 classifica questo traffico — sta nella guida tecnica completa al tracciamento delle ChatGPT Ads. Qui si incolla e si verifica. Se il dubbio è ancora più a monte — se cioè questo canale abbia senso per la tua azienda — la risposta sta in come funziona ChatGPT Ads.
Cosa ti serve prima di iniziare
| Cosa | Dove si prende | Dove va |
|---|---|---|
| Pixel ID | Ads Manager → scheda Conversions | File 1 (browser) e File 5 (server) |
| Conversions API key | Ads Manager → Conversions → Create conversion key | Solo in wp-config.php, mai in un file del tema |
| Accesso FTP/SFTP o File Manager | Il tuo hosting | Cartella wp-content/mu-plugins/ |
Quali file ti servono davvero
Non servono tutti a tutti. Questa tabella è la mappa: leggila prima di copiare qualsiasi cosa.
| File | A cosa serve | Ti serve se… |
|---|---|---|
| File 1 — pixel | Carica lo script, gestisce il consenso, mette in coda gli eventi | Sempre. È la base di tutto |
File 2 — oppref | Cattura e conserva l’identificatore di click | Sempre. Senza, le conversioni non vengono attribuite |
| File 3 — consenso | Collega la tua CMP al pixel | Sempre, se hai un banner cookie (cioè quasi certamente) |
| File 4 — eventi WooCommerce | Prodotto visto, checkout avviato, ordine concluso | Solo con WooCommerce. Senza shop, salta al File 6 |
| File 5 — Conversions API | Rimanda l’ordine dal server: la fonte più affidabile | Solo con WooCommerce. Tecnicamente opzionale, in pratica è quello che recupera le conversioni perse |
| File 6 — lead da form | Contatto inviato, con o senza pagina di ringraziamento | Se raccogli lead invece di vendere online, o in aggiunta |
Detto in due righe: sito vetrina o di servizi → file 1, 2, 3, 6. E-commerce WooCommerce → file 1, 2, 3, 4, 5, più il 6 se hai anche form di contatto.
Cos’è la cartella mu-plugins e perché usarla
**MU sta per Must Use. È una cartella dentro wp-content/ che WordPress carica automaticamente a ogni richiesta**, prima dei plugin normali. Ogni file PHP che ci metti diventa un plugin sempre attivo: non compare nell’elenco con un pulsante “Attiva”, non si può disattivare per sbaglio, e nessun aggiornamento di WordPress, del tema o dei plugin lo tocca.
Se non esiste, creala: wp-content/mu-plugins/. Non serve installare niente, WordPress la cerca da sé.
Perché lì e non nel functions.php del tema:
- Sopravvive al cambio tema. Il codice nel
functions.phpsparisce il giorno che cambi tema, e il tracciamento si spegne senza che nessuno se ne accorga per settimane. - Non si disattiva per errore. Un plugin di tracciamento spento da un collega è un classico.
- Un file, una funzione. Se qualcosa va storto, cancelli un file e hai isolato il problema.
Due vincoli da conoscere: i mu-plugin vengono caricati solo se stanno nella root della cartella — un file dentro una sottocartella viene ignorato — e non si disattivano dal pannello: per spegnerne uno si rinomina o si cancella il file.
L’ordine dei file non conta: tutto gira dentro gli hook di WordPress, quindi al momento dell’esecuzione sono già stati caricati tutti.
File 1 — Il pixel su tutte le pagine
Serve a tutti. wp-content/mu-plugins/chatgpt-ads-pixel.php
Mette lo snippet in cima al <head> di ogni pagina, con il consenso negato di default, e aggiunge una coda per gli eventi. Quella coda è la parte che conta: con consenso negato il pixel scarta gli eventi e non li ripete quando il consenso arriva dopo. La coda li trattiene e li rilascia al momento giusto.
<?php
/**
* Plugin Name: ChatGPT Ads - Pixel
* Description: Measurement Pixel su tutte le pagine, consenso negato di default,
* coda eventi in attesa del consenso.
*/
defined( 'ABSPATH' ) || exit;
// <<< UNICA COSA DA MODIFICARE IN QUESTO FILE
const CGPT_PIXEL_ID = 'INSERISCI-IL-TUO-PIXEL-ID';
add_action( 'wp_head', 'cgpt_pixel_snippet', 1 ); // priorità 1 = il più in alto possibile
function cgpt_pixel_snippet() {
if ( ! cgpt_pixel_ready() ) {
return;
}
?>
<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");
// Consenso negato PRIMA dell'init: senza questa riga il pixel parte con consenso true.
oaiq("consent", false);
oaiq("init", { pixelId: <?php echo wp_json_encode( CGPT_PIXEL_ID ); ?> });
// Coda eventi. Gli eventi bloccati dal consenso non vengono ripetuti dall'SDK:
// li tratteniamo qui e li rilasciamo quando la CMP conferma.
window.cgptConsent = null;
window.cgptQueue = [];
window.cgptMeasure = function () {
var args = [].slice.call(arguments);
if (window.cgptConsent === true) { oaiq.apply(null, ["measure"].concat(args)); }
else if (window.cgptConsent === null) { window.cgptQueue.push(args); }
// consenso negato: l'evento viene scartato, non accodato
};
window.cgptGrant = function (granted) {
window.cgptConsent = !!granted;
oaiq("consent", !!granted);
if (granted) {
while (window.cgptQueue.length) {
oaiq.apply(null, ["measure"].concat(window.cgptQueue.shift()));
}
} else {
window.cgptQueue.length = 0;
}
};
</script>
<?php
}
/** Evita di sparare richieste con il Pixel ID segnaposto ancora dentro. */
function cgpt_pixel_ready() {
return defined( 'CGPT_PIXEL_ID' )
&& CGPT_PIXEL_ID
&& 'INSERISCI-IL-TUO-PIXEL-ID' !== CGPT_PIXEL_ID;
}
Da qui in avanti non chiamerai mai oaiq("measure", ...) direttamente: userai sempre cgptMeasure(...), che rispetta il consenso da solo.
File 2 — Catturare oppref lato server
Serve a tutti. wp-content/mu-plugins/chatgpt-ads-oppref.php
oppref è l’identificatore che ChatGPT Ads aggiunge all’URL quando qualcuno clicca il tuo annuncio. È il filo che collega quel click alla conversione: se si spezza, la campagna non sa di aver funzionato.
Il pixel lo salva in un cookie suo, ma quel cookie è scritto via JavaScript: PHP non lo vede in modo affidabile e Safari ne limita la durata a sette giorni. Questo file lo cattura dalla query string e lo riscrive con Set-Cookie lato server, sul dominio padre.
<?php
/**
* Plugin Name: ChatGPT Ads - Cattura oppref
* Description: Salva oppref dalla query string in un cookie leggibile lato server.
* Espone cgpt_oppref() e cgpt_obref() per gli altri file.
* Requisiti: PHP 7.3+ (opzioni setcookie in array).
*/
defined( 'ABSPATH' ) || exit;
const CGPT_OPPREF_COOKIE = 'cgpt_oppref';
const CGPT_OPPREF_DAYS = 90; // allinea alla tua finestra di attribuzione
add_action( 'init', 'cgpt_capture_oppref', 1 );
function cgpt_capture_oppref() {
if ( empty( $_GET['oppref'] ) ) {
return;
}
$oppref = cgpt_opaque( wp_unslash( $_GET['oppref'] ) );
if ( '' === $oppref ) {
return;
}
$host = (string) wp_parse_url( home_url(), PHP_URL_HOST );
$domain = ( 0 === strpos( $host, 'www.' ) ) ? substr( $host, 4 ) : $host;
setcookie(
CGPT_OPPREF_COOKIE,
$oppref,
array(
'expires' => time() + DAY_IN_SECONDS * CGPT_OPPREF_DAYS,
'path' => '/',
'domain' => '.' . $domain, // dominio padre: leggibile anche su shop.dominio.it
'secure' => is_ssl(),
'httponly' => true, // non serve al JavaScript di pagina
'samesite' => 'Lax',
)
);
$_COOKIE[ CGPT_OPPREF_COOKIE ] = $oppref; // disponibile già in questa richiesta
}
/**
* oppref e obref sono stringhe opache: si validano, non si normalizzano.
* Niente lowercase, niente trim, niente sanitize che possa alterarle.
*/
function cgpt_opaque( $value ) {
$value = is_string( $value ) ? $value : '';
return preg_match( '/^[A-Za-z0-9_.~-]{1,512}$/', $value ) ? $value : '';
}
function cgpt_oppref() {
return isset( $_COOKIE[ CGPT_OPPREF_COOKIE ] )
? cgpt_opaque( wp_unslash( $_COOKIE[ CGPT_OPPREF_COOKIE ] ) )
: '';
}
function cgpt_obref() {
// __obref lo scrive il pixel via JS, ma il browser lo rimanda comunque
// al server nelle richieste successive: PHP può leggerlo direttamente.
return isset( $_COOKIE['__obref'] )
? cgpt_opaque( wp_unslash( $_COOKIE['__obref'] ) )
: '';
}
Attenzione alla cache. Questo file gira solo se PHP viene eseguito. Se hai una page cache che serve HTML statico ignorando la query string, oppref non viene mai catturato e tutto il resto è inutile. La sezione sulle esclusioni cache è obbligatoria, non opzionale.
File 3 — Collegare la CMP al consenso
Serve a tutti quelli che hanno un banner cookie. wp-content/mu-plugins/chatgpt-ads-consent.php
Questo file fa una cosa sola, ed è il punto in cui si perdono più conversioni: traduce “l’utente ha accettato i cookie di marketing” in “il pixel può inviare”. Senza, il File 1 lascia il consenso a false per sempre e non parte niente.
Va detto chiaramente perché sorprende quasi tutti: il consenso di ChatGPT Ads non è Google Consent Mode. Anche se la tua CMP gestisce già ad_storage per Google Ads e GA4, il pixel OpenAI non legge quei segnali. È un canale separato e va cablato a mano, una volta sola.
Prima: quale CMP hai?
Guarda l’elenco plugin del sito. Nella grande maggioranza dei casi è uno di questi quattro nomi:
| Plugin | Categoria di consenso da controllare |
|---|---|
| Complianz | marketing |
| Cookiebot | marketing |
| CookieYes | advertisement |
| iubenda | finalità 5, Targeting e Pubblicità |
Se non trovi nessuno di questi, cerca “cookie” o “consent” nell’elenco: qualunque CMP espone un evento JavaScript al momento della scelta dell’utente, ed è quello che serve.
Il file
Incolla il blocco della tua CMP e cancella gli altri: tenerli tutti non rompe niente, ma rende il file illeggibile fra sei mesi.
<?php
/**
* Plugin Name: ChatGPT Ads - Consenso
* Description: Collega la CMP al consenso del pixel OpenAI. Tieni UN SOLO blocco.
* Richiede: chatgpt-ads-pixel.php
*/
defined( 'ABSPATH' ) || exit;
add_action( 'wp_footer', 'cgpt_consent_bridge', 5 );
function cgpt_consent_bridge() {
?>
<script>
/* ============ COMPLIANZ ============ */
document.addEventListener("cmplz_enable_category", function (e) {
if (e.detail && e.detail.category === "marketing") { cgptGrant(true); }
});
document.addEventListener("cmplz_status_change", function () {
if (typeof cmplz_has_consent === "function") { cgptGrant(cmplz_has_consent("marketing")); }
});
// consenso già memorizzato da una visita precedente
if (typeof cmplz_has_consent === "function" && cmplz_has_consent("marketing")) { cgptGrant(true); }
/* ============ COOKIEBOT ============ */
// CookiebotOnConsentReady copre sia la scelta appena fatta sia quella già memorizzata.
window.addEventListener("CookiebotOnConsentReady", function () {
cgptGrant(!!(window.Cookiebot && Cookiebot.consent && Cookiebot.consent.marketing));
});
/* ============ COOKIEYES ============ */
document.addEventListener("cookieyes_consent_update", function (e) {
var accepted = e.detail && e.detail.accepted;
cgptGrant(!!(accepted && accepted.indexOf("advertisement") !== -1));
});
/* ============ IUBENDA ============ */
/* Non si aggancia da qui: va aggiunto al tuo _iub.csConfiguration, campo callback.
callback: {
onConsentGiven: function () { cgptGrant(true); },
onConsentRejected: function () { cgptGrant(false); }
} */
/* ============ NESSUNA CMP (nessun banner cookie sul sito) ============ */
/* cgptGrant(true); */
</script>
<?php
}
Se la tua CMP non è nell’elenco
Funzionano tutte allo stesso modo: espongono un evento al momento della scelta. Serve solo trovarne il nome nella documentazione del plugin e chiamare cgptGrant(true) o cgptGrant(false). Lo schema è sempre questo:
document.addEventListener("NOME_EVENTO_DELLA_TUA_CMP", function () {
cgptGrant( /* espressione che vale true se c'è consenso marketing */ );
});
Come verificare che funzioni davvero
Trenta secondi, ed è l’unico modo per esserne sicuri. Apri il sito in una finestra anonima e, nella console del browser:
window.cgptConsent // null → nessuna scelta ancora fatta, gli eventi sono in coda
Accetta i cookie dal banner e ricontrolla: deve diventare true. Poi revoca il consenso dalle impostazioni della CMP e controlla ancora: deve tornare false. È il test che quasi nessuno fa, e la revoca è la metà che di solito non è cablata.
Se dopo l’accettazione resta null, l’evento della tua CMP ha un nome diverso da quello che hai incollato.
File 4 — Eventi WooCommerce
Serve solo se hai WooCommerce. Se il tuo sito raccoglie soltanto contatti da form, salta direttamente al File 6: questo file e il prossimo non ti riguardano.
wp-content/mu-plugins/chatgpt-ads-woo.php
Segnala i tre momenti che contano in un e-commerce: prodotto visto, checkout avviato, ordine concluso. Usa wp_footer con i condizionali di WooCommerce invece degli hook dei template classici, così funziona anche con i temi a blocchi e con il checkout a blocchi, dove diversi hook storici non vengono più eseguiti.
<?php
/**
* Plugin Name: ChatGPT Ads - Eventi WooCommerce
* Description: contents_viewed, checkout_started, order_created lato browser.
* Richiede: chatgpt-ads-pixel.php
*/
defined( 'ABSPATH' ) || exit;
add_action( 'wp_footer', 'cgpt_woo_events', 20 );
function cgpt_woo_events() {
if ( ! function_exists( 'WC' ) ) {
return;
}
// 1. Scheda prodotto
if ( function_exists( 'is_product' ) && is_product() ) {
// Non usare la global $product: su wp_footer, e sui temi a blocchi,
// spesso non è più valorizzata.
$product = wc_get_product( get_the_ID() );
if ( $product instanceof WC_Product ) {
cgpt_emit( 'contents_viewed', array(
'type' => 'contents',
'amount' => cgpt_cents( $product->get_price() ),
'currency' => get_woocommerce_currency(),
'contents' => array(
array(
'id' => (string) $product->get_id(),
'name' => $product->get_name(),
'content_type' => 'product',
'quantity' => 1,
),
),
) );
}
}
// 2. Checkout avviato
if ( function_exists( 'is_checkout' ) && is_checkout() && ! is_order_received_page()
&& WC()->cart && ! WC()->cart->is_empty() ) {
$items = array();
foreach ( WC()->cart->get_cart() as $line ) {
$p = $line['data'];
if ( ! $p instanceof WC_Product ) {
continue;
}
$items[] = array(
'id' => (string) $p->get_id(),
'name' => $p->get_name(),
'content_type' => 'product',
'quantity' => (int) $line['quantity'],
'amount' => cgpt_cents( $p->get_price() ),
'currency' => get_woocommerce_currency(),
);
}
cgpt_emit( 'checkout_started', array(
'type' => 'contents',
'amount' => cgpt_cents( WC()->cart->get_total( 'edit' ) ),
'currency' => get_woocommerce_currency(),
'contents' => $items,
) );
}
// 3. Ordine concluso
if ( function_exists( 'is_order_received_page' ) && is_order_received_page() ) {
$order = cgpt_current_order();
if ( $order ) {
cgpt_emit(
'order_created',
array(
'type' => 'contents',
'amount' => cgpt_cents( $order->get_total() ),
'currency' => $order->get_currency(),
'contents' => cgpt_order_contents( $order ),
),
cgpt_event_id( $order ) // stesso valore che userà la Conversions API
);
}
}
}
/**
* Importi in unità minore ISO 4217: 248,00 EUR -> 24800.
* Per le valute senza decimali (JPY, KRW) restituire l'importo così com'è.
*/
function cgpt_cents( $amount ) {
return (int) round( (float) $amount * 100 );
}
function cgpt_event_id( WC_Order $order ) {
return 'wc_' . $order->get_id();
}
function cgpt_order_contents( WC_Order $order ) {
$out = array();
foreach ( $order->get_items() as $item ) {
$qty = max( 1, (int) $item->get_quantity() );
$out[] = array(
'id' => (string) $item->get_product_id(),
'name' => $item->get_name(),
'content_type' => 'product',
'quantity' => $qty,
'amount' => cgpt_cents( (float) $item->get_total() / $qty ),
'currency' => $order->get_currency(),
);
// group_id e variant_dict NON vanno qui: sono "Conversions API only".
}
return $out;
}
/**
* Recupera l'ordine della pagina di conferma.
*
* Si legge da $wp->query_vars come fa WooCommerce stesso: la chiave interna è
* sempre 'order-received' anche quando lo slug pubblico dell'endpoint è stato
* personalizzato nelle impostazioni.
*
* Il controllo sulla order key serve perché wp_footer viene eseguito comunque:
* senza, chiunque indovini un ID ordine farebbe partire un evento con i dati
* di un ordine altrui.
*/
function cgpt_current_order() {
global $wp;
$order_id = isset( $wp->query_vars['order-received'] )
? absint( $wp->query_vars['order-received'] )
: 0;
if ( ! $order_id ) {
return null;
}
$order = wc_get_order( $order_id );
if ( ! $order ) {
return null;
}
$key = isset( $_GET['key'] ) ? wc_clean( wp_unslash( $_GET['key'] ) ) : '';
return hash_equals( (string) $order->get_order_key(), (string) $key ) ? $order : null;
}
function cgpt_emit( $event, array $data, $event_id = '' ) {
$opts = $event_id ? array( 'event_id' => $event_id ) : array();
printf(
'<script>window.cgptMeasure && cgptMeasure(%s, %s, %s);</script>' . "\n",
wp_json_encode( $event ),
wp_json_encode( $data ),
wp_json_encode( (object) $opts )
);
}
File 5 — Conversions API: la conversione mandata dal server
Serve solo con WooCommerce, e richiede i file 1, 2 e 4. Prima di incollare, le tre domande che ricevo sempre.
È obbligatorio?
No. Il File 4 manda già order_created dal browser e la campagna funziona. Ma il browser è il canale che si perde più facilmente: ad blocker, pagina chiusa prima del render, un errore JavaScript causato da un altro plugin, la connessione mobile che cade sul più bello. La documentazione OpenAI indica la Conversions API come la fonte di tracciamento più affidabile, ed è quella che recupera gli ordini che il pixel non riesce a raccontare. Su uno shop con volumi, saltarla significa sottostimare le conversioni e far ottimizzare la campagna su dati parziali.
Dove lo “sparo”? Va nella thank you page?
No, e questa è la differenza che confonde di più: non è uno script di pagina. Non va nel tema, non va nella thank you page, non va in Google Tag Manager. È codice che gira sul tuo server, dentro WordPress, nel momento in cui WooCommerce segna l’ordine come pagato. Il browser del cliente non è coinvolto: potrebbe essere già chiuso.
Come si incastra con la thank you page?
Così:
- il cliente atterra sulla pagina di conferma → il File 4 manda
order_createddal browser conevent_id: wc_1042; - WooCommerce registra il pagamento → il File 5 manda lo stesso
order_createddal server conid: wc_1042; - OpenAI riceve due eventi con lo stesso identificativo e ne conta uno solo.
Non sono due conversioni: è la stessa conversione mandata per due strade, così che ne basti una che arrivi. Il momento in cui parte l’invio dal server dipende dal metodo di pagamento — con carta è quasi immediato, con bonifico o con notifiche asincrone può arrivare ore dopo. Va bene comunque: la finestra ammessa è di sette giorni.
Il codice
Prima, in wp-config.php, sopra la riga /* That's all, stop editing! */:
define( 'CGPT_CAPI_KEY', 'la-tua-conversion-api-key' );
define( 'CGPT_CAPI_TEST', false ); // true = valida senza salvare (validate_only)
Poi wp-content/mu-plugins/chatgpt-ads-capi.php:
<?php
/**
* Plugin Name: ChatGPT Ads - Conversions API
* Description: Invia order_created server-side, deduplicato con l'evento del pixel.
* Richiede: chatgpt-ads-pixel.php, chatgpt-ads-oppref.php, chatgpt-ads-woo.php
*/
defined( 'ABSPATH' ) || exit;
// 1. Al checkout lega oppref e obref all'ordine: quando partirà l'invio,
// il browser del cliente non ci sarà più.
add_action( 'woocommerce_checkout_create_order', 'cgpt_attach_refs', 10, 2 );
function cgpt_attach_refs( $order, $data ) {
if ( function_exists( 'cgpt_oppref' ) && cgpt_oppref() ) {
$order->update_meta_data( '_cgpt_oppref', cgpt_oppref() );
}
if ( function_exists( 'cgpt_obref' ) && cgpt_obref() ) {
$order->update_meta_data( '_cgpt_obref', cgpt_obref() );
}
// IP e user agent non servono: WooCommerce li salva già sull'ordine.
}
// 2. Invio quando l'ordine diventa pagato. Più hook, un solo invio:
// ci pensano il guard in memoria e quello su meta.
add_action( 'woocommerce_payment_complete', 'cgpt_send_conversion' );
add_action( 'woocommerce_order_status_processing', 'cgpt_send_conversion' );
add_action( 'woocommerce_order_status_completed', 'cgpt_send_conversion' );
function cgpt_send_conversion( $order_id ) {
static $done = array();
if ( ! defined( 'CGPT_CAPI_KEY' ) || ! function_exists( 'cgpt_pixel_ready' ) || ! cgpt_pixel_ready() ) {
return;
}
if ( isset( $done[ $order_id ] ) ) {
return; // già inviato in questa stessa richiesta
}
$order = wc_get_order( $order_id );
if ( ! $order || $order->get_meta( '_cgpt_capi_sent' ) ) {
return; // già inviato in una richiesta precedente
}
$done[ $order_id ] = true;
// Finestra ammessa: ultimi 7 giorni, max +10 minuti nel futuro.
$paid = $order->get_date_paid() ? $order->get_date_paid()->getTimestamp() : time();
if ( $paid < time() - 7 * DAY_IN_SECONDS ) {
$order->update_meta_data( '_cgpt_capi_sent', 'skipped-too-old' );
$order->save();
return;
}
$user = array(
'ip_address' => (string) $order->get_customer_ip_address(),
'user_agent' => (string) $order->get_customer_user_agent(),
);
if ( $order->get_meta( '_cgpt_obref' ) ) {
$user['obref'] = $order->get_meta( '_cgpt_obref' );
}
if ( $order->get_billing_email() ) {
// Email: trim + lowercase, poi SHA-256 esadecimale minuscolo.
$user['emails_sha256'] = array( hash( 'sha256', strtolower( trim( $order->get_billing_email() ) ) ) );
}
if ( $order->get_billing_country() ) {
$user['countries'] = array( $order->get_billing_country() );
}
if ( $order->get_billing_city() ) {
$user['cities'] = array( $order->get_billing_city() );
}
if ( $order->get_billing_postcode() ) {
$user['postal_codes'] = array( $order->get_billing_postcode() );
}
$event = array(
'id' => cgpt_event_id( $order ), // identico all'event_id del pixel
'type' => 'order_created',
'timestamp_ms' => $paid * 1000,
'source_url' => $order->get_checkout_order_received_url(),
'action_source' => 'web',
'user' => array_filter( $user ),
'data' => array(
'type' => 'contents',
'amount' => cgpt_cents( $order->get_total() ),
'currency' => $order->get_currency(),
'contents' => cgpt_order_contents( $order ),
),
);
if ( $order->get_meta( '_cgpt_oppref' ) ) {
$event['oppref'] = $order->get_meta( '_cgpt_oppref' ); // livello evento, non dentro user
}
$response = wp_remote_post(
add_query_arg( 'pid', rawurlencode( CGPT_PIXEL_ID ), 'https://bzr.openai.com/v1/events' ),
array(
'timeout' => 15,
'headers' => array(
'Authorization' => 'Bearer ' . CGPT_CAPI_KEY,
'Content-Type' => 'application/json',
),
'body' => wp_json_encode(
array(
'validate_only' => (bool) ( defined( 'CGPT_CAPI_TEST' ) && CGPT_CAPI_TEST ),
'integration_source' => 'wordpress-woocommerce',
'events' => array( $event ),
)
),
)
);
$code = is_wp_error( $response ) ? 0 : (int) wp_remote_retrieve_response_code( $response );
if ( $code >= 200 && $code < 300 ) {
$order->update_meta_data( '_cgpt_capi_sent', gmdate( 'c' ) );
} else {
$order->add_order_note(
'ChatGPT Ads CAPI fallita: ' .
( is_wp_error( $response ) ? $response->get_error_message() : $code . ' ' . wp_remote_retrieve_body( $response ) )
);
}
$order->save();
}
Quattro cose che questo file fa e che di solito mancano nelle implementazioni fai-da-te: non invia due volte lo stesso ordine (guard in memoria più guard su meta), non prova nemmeno a inviare ordini fuori dalla finestra dei sette giorni, scrive l’errore nelle note dell’ordine invece di fallire in silenzio, e per IP e user agent usa quelli che WooCommerce salva già sull’ordine invece di duplicarli in meta custom.
File 6 — Lead da form, con e senza pagina di ringraziamento
Serve se raccogli contatti: da solo su un sito di servizi, oppure in aggiunta a WooCommerce se hai anche form di preventivo.
L’evento è lead_created con data.type: "customer_action". Attenzione a una regola che si sbaglia spesso: quella shape non accetta contents[] né plan_id — solo type, e facoltativamente amount e currency. Infilarci l’elenco dei servizi richiesti è fuori specifica.
I form WordPress si comportano in due modi diversi, e il codice cambia di conseguenza. Guarda cosa succede dopo l’invio sul tuo sito e scegli il caso corrispondente.
Caso A — Il form invia senza ricaricare la pagina (AJAX)
È il comportamento predefinito di Contact Form 7 e di molti altri: il messaggio “Grazie” compare al posto del form e l’URL non cambia. Si ascolta l’evento che il plugin emette a invio riuscito.
<script>
// Contact Form 7. Nota: questi eventi partono SOLO in modalità AJAX.
document.addEventListener("wpcf7mailsent", function (e) {
// Per limitarlo a un form specifico: if (e.detail.contactFormId !== 123) return;
if (window.cgptMeasure) {
cgptMeasure("lead_created", { type: "customer_action" });
}
}, false);
</script>
Per Gravity Forms l’evento è gform_confirmation_loaded, per WPForms quello documentato dal plugin: cambia il nome dell’evento, il resto è identico.
Caso B — Il form fa un redirect a una pagina di ringraziamento
È il caso più comune sui siti aziendali: dopo l’invio l’utente atterra su /grazie/. Qui non c’è nessun evento JavaScript da ascoltare, perché la pagina si ricarica da zero: l’evento va sparato sulla pagina di arrivo, riconoscendola dal suo ID.
wp-content/mu-plugins/chatgpt-ads-lead.php
<?php
/**
* Plugin Name: ChatGPT Ads - Lead su thank you page
* Description: Invia lead_created sulle pagine di ringraziamento dei form.
* Richiede: chatgpt-ads-pixel.php
*/
defined( 'ABSPATH' ) || exit;
// ID delle pagine di ringraziamento. L'ID si legge nell'URL quando modifichi
// la pagina: wp-admin/post.php?post=123&action=edit -> 123
const CGPT_THANKYOU_PAGES = array( 123, 456 );
add_action( 'wp_footer', 'cgpt_lead_thankyou', 20 );
function cgpt_lead_thankyou() {
if ( ! is_page( CGPT_THANKYOU_PAGES ) ) {
return;
}
?>
<script>
(function () {
// Un refresh della pagina, o un ritorno con il tasto indietro,
// non devono contare un secondo lead.
var k = "cgpt_lead_" + location.pathname;
try {
if (sessionStorage.getItem(k)) { return; }
sessionStorage.setItem(k, "1");
} catch (e) { /* sessionStorage non disponibile: si procede comunque */ }
if (window.cgptMeasure) {
cgptMeasure("lead_created", { type: "customer_action" });
}
})();
</script>
<?php
}
Il limite da conoscere prima, non dopo: una pagina di ringraziamento è raggiungibile anche senza aver compilato il form — da un link, dalla cronologia, da un bot. Il conteggio dei lead sarà quindi un po’ più generoso del reale. Il guard su sessionStorage elimina i refresh, non le visite dirette. Se ti serve un conteggio esatto, la strada è il Caso C.
Caso C — Lead inviato dal server (il più affidabile)
Parte dal tuo server quando il form viene realmente inviato: niente falsi positivi, niente ad blocker, e funziona anche con i form che non usano AJAX. L’esempio è su Contact Form 7, ma l’aggancio vale per qualsiasi plugin che esponga un hook a invio riuscito.
<?php
/**
* Plugin Name: ChatGPT Ads - Lead server-side
* Description: Invia lead_created via Conversions API quando il form viene inviato.
* Richiede: chatgpt-ads-pixel.php, chatgpt-ads-oppref.php
*/
defined( 'ABSPATH' ) || exit;
add_action( 'wpcf7_mail_sent', 'cgpt_lead_from_cf7' );
function cgpt_lead_from_cf7( $form ) {
cgpt_send_lead( 'cf7_' . $form->id() . '_' . time() );
}
function cgpt_send_lead( $event_id ) {
if ( ! defined( 'CGPT_CAPI_KEY' ) || ! function_exists( 'cgpt_pixel_ready' ) || ! cgpt_pixel_ready() ) {
return;
}
$user = array_filter(
array(
'obref' => function_exists( 'cgpt_obref' ) ? cgpt_obref() : '',
'ip_address' => isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '',
'user_agent' => isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '',
)
);
$event = array(
'id' => $event_id,
'type' => 'lead_created',
'timestamp_ms' => time() * 1000,
'source_url' => wp_get_referer() ? wp_get_referer() : home_url( '/' ),
'action_source' => 'web',
'user' => $user,
'data' => array( 'type' => 'customer_action' ),
);
$oppref = function_exists( 'cgpt_oppref' ) ? cgpt_oppref() : '';
if ( $oppref ) {
$event['oppref'] = $oppref;
}
wp_remote_post(
add_query_arg( 'pid', rawurlencode( CGPT_PIXEL_ID ), 'https://bzr.openai.com/v1/events' ),
array(
'timeout' => 10,
'blocking' => false, // non rallentare la risposta all'utente
'headers' => array(
'Authorization' => 'Bearer ' . CGPT_CAPI_KEY,
'Content-Type' => 'application/json',
),
'body' => wp_json_encode(
array(
'validate_only' => (bool) ( defined( 'CGPT_CAPI_TEST' ) && CGPT_CAPI_TEST ),
'integration_source' => 'wordpress-forms',
'events' => array( $event ),
)
),
)
);
}
Scegline uno. Per i lead: browser oppure server, mai entrambi. A differenza degli ordini WooCommerce, qui non c’è un identificativo condiviso fra i due canali, quindi attivarli tutti e due significa contare ogni contatto due volte.
Un’ultima cosa se il lead è l’obiettivo della campagna: una campagna oCPC richiede esattamente un evento standard come obiettivo, e non può essere un evento custom.
Le esclusioni cache obbligatorie
Salta questa sezione e il tracciamento funzionerà in staging e non in produzione. Sono cinque righe di configurazione.
| Cosa | Perché | Dove si imposta |
|---|---|---|
Non mettere in cache le pagine con ?oppref= | Se serve HTML dalla cache, PHP non gira e il cookie non viene scritto | WP Rocket: Never Cache URLs · LiteSpeed: Do Not Cache Query Strings |
Non rimuovere oppref e gli utm_* dalla query string | Alcuni plugin “puliscono” gli URL e li perdono | Impostazioni del plugin di cache o di ottimizzazione |
| Mai in cache la thank you page | Serviresti a tutti l’ordine di qualcun altro | WooCommerce lo fa di suo: verifica che nessun plugin lo sovrascriva |
| Su Cloudflare: query string nella cache key | Con Ignore Query String la pagina viene servita statica | Caching → Cache Key, o una Page Rule di bypass |
| Nessun redirect che perda la query string | http→https, non-www→www, trailing slash | Verifica con il curl più avanti |
UTM e GA4
Nell’URL di destinazione della campagna, in Ads Manager:
https://www.tuosito.it/pagina
?utm_source=chatgpt
&utm_medium=cpc
&utm_campaign=brand_q4_2026_it_tema
&utm_content=id_annuncio
utm_medium=cpc è la scelta corretta: rende il traffico riconoscibile come a pagamento in GA4. Il rovescio è che lo esclude dal canale nativo AI Assistant, che richiede medium esattamente ai-assistant. Si recupera con un gruppo di canali personalizzato — il ragionamento completo è nella guida tecnica.
In GA4, tre verifiche da tre minuti:
- Amministrazione → Stream di dati → Elenca i referral indesiderati:
chatgpt.comnon deve esserci. - Amministrazione → Impostazioni dati → Gruppi di canali: crea un canale AI Paid con
Sourceche corrisponde achatgpteMediumche corrisponde all’espressione regolare dei medium a pagamento. - Report → Tempo reale, con la dimensione Gruppo di canali predefinito della sessione: apri la landing in incognito con gli UTM e guarda dove finisce.
Sulla costruzione dei parametri vale quanto scritto nella guida ai parametri UTM.
CSP, se ce l’hai
Se hai una Content Security Policy attiva — plugin di security headers, o header impostati dal server — il pixel viene bloccato senza errori visibili finché non autorizzi i domini OpenAI.
Nginx:
add_header Content-Security-Policy "script-src 'self' https://bzrcdn.openai.com; connect-src 'self' https://bzr.openai.com https://bzrcdn.openai.com; img-src 'self' data: https://bzr.openai.com" always;
Apache, in .htaccess:
Header set Content-Security-Policy "script-src 'self' https://bzrcdn.openai.com; connect-src 'self' https://bzr.openai.com https://bzrcdn.openai.com; img-src 'self' data: https://bzr.openai.com"
Le direttive sono da integrare nella tua policy esistente, non da incollare al posto. E non aggiungere 'unsafe-inline' per far passare lo snippet: usa un nonce.

Verifica in dieci minuti
Nell’ordine. Se un passo fallisce, gli altri non hanno senso.
1. oppref sopravvive ai redirect
curl -sIL "https://www.tuosito.it/?oppref=TEST123&utm_source=chatgpt&utm_medium=cpc" \
| grep -iE '^(HTTP/|location:)'
# Atteso: nessun redirect, oppure ogni Location che riporta ?oppref=TEST123
2. Il cookie server viene scritto
curl -sI "https://www.tuosito.it/?oppref=TEST123" | grep -i set-cookie
# Atteso: set-cookie: cgpt_oppref=TEST123; ...
3. Il pixel carica. Apri la landing con ?oppref=TEST123, DevTools → Network, filtra openai: devi vedere oaiq.min.js da bzrcdn.openai.com. Se non c’è, è la CSP o un ad blocker.
4. Il consenso commuta. In console, prima di accettare i cookie: window.cgptConsent deve essere null. Dopo l’accettazione true, e window.cgptQueue.length deve tornare a 0. Dopo la revoca, di nuovo false.
5. La conversione parte. Fai un ordine reale di prova, o invia un form. In Network cerca la richiesta verso bzr.openai.com.
6. La Conversions API risponde. Metti CGPT_CAPI_TEST a true, fai un ordine, apri l’ordine in WooCommerce e guarda le note: se non c’è nessuna nota di errore, la chiamata è andata. Poi rimettilo a false.
7. Il valore è giusto. In Ads Manager → Conversions → View event stream, cerca l’ordine di prova: amount deve essere l’importo × 100. È l’unico controllo che intercetta l’errore più costoso di tutti.
8. GA4 vede il traffico. Tempo reale, dimensione Gruppo di canali predefinito della sessione.
Problemi frequenti
| Sintomo | Causa quasi sempre | Cosa guardare |
|---|---|---|
| Zero conversioni ma il sito vende | oppref perso in un redirect o nella cache | Test 1 e 2 |
| Conversioni doppie | event_id del pixel diverso dall’id CAPI | Che entrambi usino cgpt_event_id() |
| Lead contati il doppio | Attivi sia il Caso B sia il Caso C | Tienine uno solo |
| Valori 100 volte troppo bassi | Importo inviato in euro invece che in centesimi | Test 7 |
| Il pixel non carica | CSP, o un ad blocker sul tuo browser | Console, violazioni CSP |
| Funziona da loggato, non da anonimo | Page cache attiva per gli anonimi | Esclusioni cache |
| Eventi solo sulla home | Plugin di cache che serve HTML statico | Esclusioni cache |
| Nessun evento dopo il consenso | La CMP non chiama cgptGrant() | window.cgptConsent in console |
| Note d’ordine con errore CAPI | Payload rifiutato | Il testo della nota dice quale campo |
Nessun order_created sulla conferma | URL della thank you page senza parametro key | Che l’URL contenga &key=wc_order_... |
| Ordini vecchi mai inviati | Oltre la finestra dei sette giorni | Meta _cgpt_capi_sent = skipped-too-old |
Casi che questa guida non copre
- Checkout su un dominio diverso (booking engine, gestionale esterno, piattaforma di ticketing): il cookie non attraversa il confine di dominio e va propagato o ricongiunto lato server.
- WordPress headless: il pixel va nel frontend, la Conversions API resta dove vive l’ordine.
- Conversioni offline o confermate oltre sette giorni: fuori dalla finestra ammessa, richiedono un disegno diverso.
Sono i tre casi in cui l’attribuzione si rompe in silenzio: la meccanica e le mitigazioni stanno nella guida tecnica al tracciamento delle ChatGPT Ads.
Se preferisci non metterci mano
Progettiamo la misurazione, affianchiamo gli sviluppatori durante l’implementazione e validiamo tutto prima del go-live — oltre a gestire le campagne.
Consulenza e gestione ChatGPT Ads — audit dell’account, disegno del tracciamento, gestione campagne.
Fonti
| 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 |
| Campagne oCPC | developers.openai.com/ads/conversion-optimized-campaigns |
| Gruppo di canali GA4 | support.google.com/analytics/answer/9756891 |
| Eventi DOM di Contact Form 7 | contactform7.com/dom-events |
| Consenso: Complianz | complianz.io — developers guide |
| Consenso: Cookiebot | cookiebot.com — developer |
| Consenso: iubenda | iubenda.com — Cookie Solution JavaScript API |
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