En la Parte 1 de esta serie instalamos el Custom Pixel de Shopify, que avisa a OpenAI desde el navegador del visitante en cada paso de la compra. Ese canal tiene un punto débil: si el visitante rechaza las cookies o usa un bloqueador de anuncios, esos eventos nunca llegan, aunque la persona compre de verdad. En esta Parte 2 montamos el canal que no depende del navegador — la Conversions API — mandando la confirmación de compra directamente desde el servidor de la tienda a OpenAI.
Mira el vídeo completo con la instalación en pantalla, o sigue el paso a paso escrito debajo:
Recordatorio: por qué hace falta esto
La Conversions API resuelve el punto débil del pixel del navegador mandando la confirmación de compra directamente desde el servidor de la tienda a OpenAI, sin pasar por el navegador del cliente en ningún momento. Ese canal nunca falla por bloqueadores ni por rechazo de cookies.
Paso 1 — El Cloudflare Worker (créalo primero)
Un Worker es un pequeño programa que vive en la nube de Cloudflare, siempre activo, esperando peticiones. No requiere servidor propio ni hosting adicional. Lo creamos primero porque el Paso 2 necesita su URL.
Qué hace el Worker, cada vez que llega un pedido pagado:
- Recibe el mensaje (JSON) que manda Shopify.
- Verifica la firma (que sea de Shopify de verdad, usando una clave secreta).
- Traduce los datos de Shopify al formato que entiende OpenAI: importe, productos, moneda.
- Si hay email, teléfono, nombre o ID de cliente, los transforma en un hash (huella digital irreversible) — nunca se envían en texto plano.
- Envía todo a OpenAI mediante su Conversions API.
- Responde a Shopify que todo salió bien.
Pasos para crearlo
- En Cloudflare, Workers & Pages → Create application → «Start with Hello World!».
- Pega el código completo del Anexo de este artículo sustituyendo el contenido de ejemplo.
- En Settings → Variables and Secrets, añade
OPENAI_PIXEL_ID(con el Pixel ID de la cuenta) yOPENAI_API_KEY(como Secret, con la clave de Conversions API generada en Ads Manager → Conversiones → icono de llave → «Gestionar claves de conversión»).SHOPIFY_WEBHOOK_SECRETse añade más adelante, en el Paso 3, porque todavía no existe. - Pulsa «Deploy». Cloudflare te da una URL del tipo
https://<nombre-worker>.<tu-cuenta>.workers.dev/— guárdala, la necesitas en el siguiente paso.
Paso 2 — El Webhook de Shopify (usa la URL del paso anterior)
Un webhook es una notificación automática: cuando pasa algo en Shopify («un pedido se ha pagado»), Shopify manda automáticamente un mensaje con todos los datos del pedido a una URL indicada — la del Worker que acabas de crear.
Configuración aplicada:
- Evento: «Pago de pedido» (orders/paid) — no «Creación de pedido», porque ese se dispara aunque el pedido no se haya cobrado todavía.
- Formato: JSON
- URL: la del Cloudflare Worker obtenida en el Paso 1
- Versión de API: la más reciente estable, evitando «unstable» porque puede cambiar sin avisar.
Al guardar, Shopify muestra una clave de firma (secreto compartido) — cópiala, es lo único que falta.
Paso 3 — Vuelve al Worker y añade la clave de firma
Con la clave de firma de Shopify ya copiada, vuelve a Cloudflare → tu Worker → Settings → Variables and Secrets → «+ Add variable»: SHOPIFY_WEBHOOK_SECRET, marcado como Secret, con ese valor. Guarda — el Worker se redespliega automáticamente con las tres variables completas.
| Variable (Secret en Cloudflare) | Para qué sirve |
|---|---|
| OPENAI_PIXEL_ID | Identifica la cuenta de anuncios |
| OPENAI_API_KEY | Autoriza al Worker a enviar datos a OpenAI |
| SHOPIFY_WEBHOOK_SECRET | Verifica que el mensaje viene de Shopify |
Paso 4 — Probar que todo funciona
Se pueden hacer dos tipos de prueba distintas, y es importante no confundirlas:
- Pedido real de la tienda: un cliente compra de verdad, o se simula con un método de pago manual (transferencia, contra reembolso) sin coste real. Dispara el webhook real.
- «Enviar una prueba» desde Shopify: botón junto a cada webhook que manda un mensaje de prueba con datos ficticios solo para comprobar la conexión — no representa ninguna venta real.
Verificación en tres sitios distintos:
- Cloudflare → Observability → Invocations: cada petición recibida por el Worker, con su código de respuesta (200 = éxito).
- Ads Manager → Conversiones → Flujo de eventos: eventos en tiempo real mientras está «escuchando» (botón «Iniciar sondeo»).
- Ads Manager → Conversiones → Eventos de conversión: conteo total acumulado de conversiones.
Verificar que no se cuenta la compra dos veces
Si además de este canal servidor tienes instalado el Custom Pixel de la Parte 1, el mismo order_created puede llegar por los dos canales. Hay que confirmar que OpenAI los trata como una sola conversión:
- En Flujo de eventos, busca las dos filas de order_created del mismo pedido: una con canal pixel_sdk y otra server_to_server.
- En Eventos de conversión, el contador total debe subir en 1 por ese pedido, no en 2 — la deduplicación funciona por la combinación de Pixel ID + nombre del evento + event_id, que debe coincidir exactamente en ambos canales.
- Importante: el webhook orders/paid solo se dispara cuando el pedido pasa a estado «Pagado». Con métodos de pago manuales que nacen como «Pendiente», hay que entrar al pedido y pulsar «Marcar como pagado» a mano para forzar esa transición.
Paso 5 — Filtrar por canal de venta (opcional, para tiendas multicanal)
Si la tienda vende solo por su propio checkout, este paso no hace falta. Pero muchas tiendas de Shopify reciben pedidos de varios canales a la vez — Tienda Online, Marketplaces (Amazon, Google Shopping, Facebook Shop), TPV físico (POS)… y el webhook orders/paid se dispara igual para todos ellos, sin distinción. Si solo quieres medir las ventas que pasan por tu propia web, hay que filtrarlas dentro del propio Worker.
Cada pedido que llega trae un campo llamado source_name, que indica el canal de origen. El valor que corresponde a la Tienda Online (lo que en el admin de Shopify aparece etiquetado como «Online Store») es literalmente el texto «web».
- En Cloudflare, ve a tu Worker → Settings → Variables and Secrets → «+ Add variable».
- Key:
ONLY_SOURCE_NAME. Value:web. Esta NO va marcada como Secret — no es información sensible, es solo un filtro de texto plano. - Guarda — el Worker se redespliega solo. A partir de ahí, cualquier pedido cuyo source_name no sea exactamente «web» se descarta silenciosamente: Shopify recibe una respuesta 200 igualmente, pero esa venta nunca llega a OpenAI.
El código del Anexo ya incluye este filtro integrado — no hay que añadir nada más al script, solo crear la variable. Si nunca creas ONLY_SOURCE_NAME, el Worker sigue procesando pedidos de todos los canales exactamente igual que antes.
Preguntas frecuentes
¿Necesito desinstalar el Custom Pixel de la Parte 1 si uso la Conversions API?
No. Son complementarios: el pixel cubre el navegador y el servidor cubre lo que el pixel no ve. Usar ambos a la vez da el dato más completo, y OpenAI deduplica automáticamente los eventos repetidos por Pixel ID + nombre del evento + event_id.
¿Qué pasa si escribo mal el nombre de una variable en Cloudflare?
No se rompe nada visible: Shopify sigue recibiendo una respuesta 200 y todo parece ir bien, pero esa variable queda vacía por dentro y la conversión nunca llega a OpenAI. Es el fallo más común y más difícil de detectar; revisa los tres nombres letra por letra.
¿Por qué no veo el teléfono ni el nombre en el detalle del evento en Ads Manager?
Porque esa sección de la interfaz, todavía en Beta, no expone esos campos en pantalla, aunque el Worker sí los esté enviando. Si necesitas confirmarlo, revisa los logs del propio Worker antes de que salga la petición.
¿Este filtro de canal funciona en cualquier tienda Shopify?
No en tiendas con checkout headless (Hydrogen o Storefront API), donde source_name deja de ser «web». Antes de aplicarlo en un cliente nuevo, confirma que su tienda usa el checkout estándar.
Anexo — Código completo del Cloudflare Worker
env.OPENAI_PIXEL_ID, env.OPENAI_API_KEY, env.SHOPIFY_WEBHOOK_SECRET y env.ONLY_SOURCE_NAME — variables configuradas en Cloudflare, no en el código. Por eso este mismo archivo, copiado y pegado tal cual, sirve para cualquier tienda de Shopify nueva.Código JavaScript a desplegar en Cloudflare Workers:
/**
* Cloudflare Worker: Shopify orders/paid -> OpenAI Ads Conversions API
*
* Variables/Secrets a configurar en Cloudflare (Settings > Variables):
* OPENAI_PIXEL_ID (texto normal) -> ej. "Jj6afzmAuKphSBUvfPqwrn"
* OPENAI_API_KEY (Secret, encriptado) -> la clave de Conversions API
* SHOPIFY_WEBHOOK_SECRET (Secret, encriptado) -> la clave de firma del webhook
* ONLY_SOURCE_NAME (texto normal, OPCIONAL) -> ej. "web" para procesar solo
* pedidos de la Tienda Online e ignorar POS/Marketplaces.
* Si no se define, se procesan pedidos de todos los canales.
*/
export default {
async fetch(request, env, ctx) {
if (request.method !== "POST") {
return new Response("Method not allowed", { status: 405 });
}
// 1. Leer el cuerpo crudo (necesario para verificar la firma HMAC)
const rawBody = await request.text();
// 2. Verificar que la peticion viene realmente de Shopify
const hmacHeader = request.headers.get("X-Shopify-Hmac-Sha256");
const isValid = await verifyShopifyWebhook(rawBody, hmacHeader, env.SHOPIFY_WEBHOOK_SECRET);
if (!isValid) {
return new Response("Invalid signature", { status: 401 });
}
const order = JSON.parse(rawBody);
// 2.5. Filtrar por canal de venta (opcional).
if (env.ONLY_SOURCE_NAME && order.source_name !== env.ONLY_SOURCE_NAME) {
return new Response(
JSON.stringify({ ok: true, skipped: true, reason: "source_name_filtered", source_name: order.source_name }),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
}
// 3. Construir el evento en el formato que espera OpenAI Ads
const event = await buildOpenAiEvent(order, request);
// 4. Enviar a la Conversions API de OpenAI
const openaiRes = await fetch(
`https://bzr.openai.com/v1/events?pid=${env.OPENAI_PIXEL_ID}`,
{
method: "POST",
headers: {
"Authorization": `Bearer ${env.OPENAI_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ validate_only: false, events: [event] }),
}
);
const resultText = await openaiRes.text();
// 5. Responder a Shopify rapido (Shopify espera 200 en pocos segundos)
return new Response(
JSON.stringify({ ok: openaiRes.ok, openai_status: openaiRes.status, openai_response: resultText }),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
},
};
/**
* Verifica la firma HMAC-SHA256 que Shopify anade a cada webhook.
*/
async function verifyShopifyWebhook(rawBody, hmacHeader, secret) {
if (!hmacHeader || !secret) return false;
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
enc.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);
const signature = await crypto.subtle.sign("HMAC", key, enc.encode(rawBody));
const computedHmac = btoa(String.fromCharCode(...new Uint8Array(signature)));
return computedHmac === hmacHeader;
}
/**
* Hashea un string en SHA-256 y devuelve el hex, tal como pide OpenAI
* para email_sha256 / external_id_sha256 (minusculas, sin espacios).
*/
async function sha256Hex(value) {
if (!value) return undefined;
const normalized = value.trim().toLowerCase();
const enc = new TextEncoder();
const hashBuffer = await crypto.subtle.digest("SHA-256", enc.encode(normalized));
return [...new Uint8Array(hashBuffer)].map(b => b.toString(16).padStart(2, "0")).join("");
}
/** Deja solo digitos, quita ceros/+ iniciales, tal como pide OpenAI para el telefono. */
function normalizePhone(phone) {
if (!phone) return undefined;
const digits = phone.replace(/[\s().-]/g, "").replace(/^\+/, "").replace(/^0+/, "");
return digits || undefined;
}
/** Minusculas, sin espacios ni puntuacion, conservando acentos/ene, tal como pide OpenAI. */
function normalizeName(name) {
if (!name) return undefined;
return name.trim().toLowerCase().replace(/[^\p{L}\p{N}]/gu, "") || undefined;
}
/**
* Traduce el payload de Shopify (orders/paid) al formato de evento de OpenAI Ads.
*/
async function buildOpenAiEvent(order, request) {
const currency = order.currency || "EUR";
const amountMinorUnits = Math.round(parseFloat(order.total_price || order.current_total_price || "0") * 100);
const contents = (order.line_items || []).map((item) => ({
id: String(item.product_id || item.sku || item.id),
name: item.title,
quantity: item.quantity,
amount: Math.round(parseFloat(item.price || "0") * 100),
currency,
}));
const email = order.email || order.contact_email || null;
const customerId = order.customer && order.customer.id ? String(order.customer.id) : null;
const address = order.billing_address || order.shipping_address || {};
const phone = normalizePhone(order.phone || (order.customer && order.customer.phone) || address.phone);
const firstName = normalizeName(address.first_name || (order.customer && order.customer.first_name));
const lastName = normalizeName(address.last_name || (order.customer && order.customer.last_name));
const user = {};
const emailHash = await sha256Hex(email);
const idHash = await sha256Hex(customerId);
const phoneHash = await sha256Hex(phone);
const firstNameHash = await sha256Hex(firstName);
const lastNameHash = await sha256Hex(lastName);
if (emailHash) user.email_sha256 = emailHash;
if (idHash) user.external_id_sha256 = idHash;
if (phoneHash) user.phone_number_sha256 = phoneHash;
if (firstNameHash) user.first_name_sha256 = firstNameHash;
if (lastNameHash) user.last_name_sha256 = lastNameHash;
if (address.country_code) user.country = address.country_code;
if (address.city) user.city = address.city;
if (address.zip) user.postal_code = address.zip;
return {
id: `order_${order.id}`,
type: "order_created",
timestamp_ms: Date.now(),
action_source: "web",
source_url: `https://${(order.order_status_url ? new URL(order.order_status_url).hostname : "tu-tienda.myshopify.com")}/`,
data: {
type: "contents",
amount: amountMinorUnits,
currency,
contents,
},
...(Object.keys(user).length ? { user } : {}),
};
}Serie ChatGPT Ads en Shopify · Parte 2 de 2. Si todavía no has instalado el Custom Pixel del navegador, empieza por la Parte 1 de esta serie o por el canal de YouTube.
- ShopifyParte 1: Custom Pixel de ChatGPT Ads en Shopify
- ShopifyAutomatiza tareas en Shopify con Manus AI, sin apps
