ChatGPT Ads en Shopify Parte 2: Conversions API con Webhook + Cloudflare Worker

Tiendas Online

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:

¿Qué es la Conversions API (CAPI) de ChatGPT Ads y cómo se instala en Shopify? Es un canal servidor-a-servidor: un Webhook de Shopify avisa a un Cloudflare Worker propio cada vez que se paga un pedido, y ese Worker traduce y envía la confirmación de compra a OpenAI directamente, sin pasar por el navegador del cliente. Por eso nunca falla por bloqueadores de anuncios ni por rechazo de cookies — es el complemento del Custom Pixel de la Parte 1, no un sustituto.

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.

Requisito previo. Esta guía asume que ya existe un pixel creado en Ads Manager (Conversiones → Fuente de datos) con su Pixel ID, tal como se explica en el Paso 0 de la Parte 1. No hace falta tener creado un «evento de conversión» con nombre — eso se deja para la guía de creación de campaña, y no es necesario ni para instalar el Worker ni para verificar que los datos llegan bien.

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:

  1. Recibe el mensaje (JSON) que manda Shopify.
  2. Verifica la firma (que sea de Shopify de verdad, usando una clave secreta).
  3. Traduce los datos de Shopify al formato que entiende OpenAI: importe, productos, moneda.
  4. 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.
  5. Envía todo a OpenAI mediante su Conversions API.
  6. Responde a Shopify que todo salió bien.
Coincidencia de usuario: mismo nivel de detalle que el pixel de la Parte 1. El Worker extrae email, teléfono, nombre, apellido, país, ciudad y código postal directamente del pedido que ya manda Shopify en el webhook — no hace falta pedir nada extra ni activar permisos adicionales. Cada dato se normaliza y se hashea en SHA-256 antes de salir del Worker, igual que en el pixel. Si un pedido no trae alguno de estos datos, simplemente se omite ese campo, sin romper el resto del evento.

Pasos para crearlo

  1. En Cloudflare, Workers & Pages → Create application → «Start with Hello World!».
  2. Pega el código completo del Anexo de este artículo sustituyendo el contenido de ejemplo.
  3. En Settings → Variables and Secrets, añade OPENAI_PIXEL_ID (con el Pixel ID de la cuenta) y OPENAI_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_SECRET se añade más adelante, en el Paso 3, porque todavía no existe.
  4. 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_IDIdentifica la cuenta de anuncios
OPENAI_API_KEYAutoriza al Worker a enviar datos a OpenAI
SHOPIFY_WEBHOOK_SECRETVerifica que el mensaje viene de Shopify
El nombre de cada clave debe copiarse EXACTO. El código busca estas variables por su nombre literal, todo en mayúsculas, con guion bajo, tal cual. Si al crear la variable en Cloudflare escribes algo distinto (minúsculas, un guion en vez de guion bajo, un espacio de más, o le pones otro nombre), el Worker NO se rompe ni avisa con un error visible: simplemente esa variable queda vacía por dentro, y la conversión nunca llega a OpenAI aunque Shopify reciba una respuesta 200 y todo parezca ir bien. Este es, con diferencia, el fallo más habitual y más difícil de detectar de todo el proceso. Revisa los tres nombres letra por letra antes de continuar.

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:

  1. Cloudflare → Observability → Invocations: cada petición recibida por el Worker, con su código de respuesta (200 = éxito).
  2. Ads Manager → Conversiones → Flujo de eventos: eventos en tiempo real mientras está «escuchando» (botón «Iniciar sondeo»).
  3. Ads Manager → Conversiones → Eventos de conversión: conteo total acumulado de conversiones.
El detalle del evento en Ads Manager no muestra teléfono ni nombre todavía. Al abrir el detalle de un evento en Flujo de eventos, la sección «Datos del usuario» solo tiene filas visibles para correo electrónico, ID externo, país, ciudad y código postal — no para teléfono ni nombre, aunque el Worker sí los esté enviando. No es un fallo del código: esa parte de la interfaz de Ads Manager (todavía en Beta) no expone esos campos en pantalla. Si necesitas confirmarlo con certeza, revisa los logs del propio Worker en Cloudflare antes de que salga la petición.

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:

  1. En Flujo de eventos, busca las dos filas de order_created del mismo pedido: una con canal pixel_sdk y otra server_to_server.
  2. 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.
  3. 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».

  1. En Cloudflare, ve a tu Worker → Settings → Variables and Secrets → «+ Add variable».
  2. Key: ONLY_SOURCE_NAME. Value: web. Esta NO va marcada como Secret — no es información sensible, es solo un filtro de texto plano.
  3. 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.

Ojo con las tiendas «headless». Esta regla (Tienda Online = source_name «web») deja de cumplirse si la tienda usa un checkout headless — montado con Hydrogen o la Storefront API, en vez del checkout estándar. En esas tiendas, source_name pasa a ser un número de ID de aplicación en lugar de «web». Antes de aplicar este paso en un cliente nuevo, confirma que su tienda usa el checkout estándar de Shopify (si en el pedido aparece la etiqueta «Online Store» vas bien) y no una implementación headless a medida.
Resumen en una frase: montamos un webhook de Shopify conectado a un pequeño programa propio en la nube de Cloudflare, que avisa a OpenAI cada vez que se confirma un pago — así las campañas se optimizan con datos fiables de ventas reales, sin depender de que el navegador del cliente coopere.

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.

¿Prefieres que te lo instale y verifique yo mismo?Monto el Worker, el Webhook y confirmo que las conversiones llegan correctamente deduplicadas. Contáctame

Anexo — Código completo del Cloudflare Worker

Este código NO se modifica, nunca. Ni un Pixel ID, ni una clave de API, ni ningún dato del cliente aparecen escritos en este código en ningún momento. Todo lo que cambia de una tienda a otra se lee desde fuera, a través de 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.

¿Vendes con Shopify y quieres hacer bien tu tracking? Te ayudo a montar el Píxel, la Conversions API y las campañas completas de ChatGPT Ads, Google Ads o Meta. Hablemos

Deja un comentario