Resumen

Cloudflare Workers es la alternativa más barata y rápida a sGTM Cloud Run para server-side tracking en 2026. Coste fijo $5/mes incluye 10M requests, latencia p95 <50ms global desde 300+ datacenters, cold start 5ms vs 200-500ms en containers. Este post entrega un pipeline reproducible: Worker que recibe eventos via webhook Shopify + endpoint relay desde web, hace match de user identity, y hace fan-out paralelo a Meta CAPI / Google Ads Enhanced Conversions / TikTok Events API / GA4 Measurement Protocol con event_id consistente cross-source para evitar duplicación. Recupera 35-55% conversiones perdidas por iOS ATT, ITP Safari, ad blockers y eventos cross-domain. Recurso: Worker code completo descargable.


El problema real del tracking en 2026

Apple App Tracking Transparency (lanzado iOS 14.5 abril 2021, endurecido en iOS 17 y 18) ha hecho que el 75-85% de usuarios iOS bloqueen el tracking cross-app. Safari Intelligent Tracking Prevention (ITP) limita las cookies third-party a 7 días. Firefox Total Cookie Protection compartimenta cookies por sitio. Brave bloquea trackers por defecto. Los ad blockers desktop tienen 25-40% penetración según vertical.

El resultado en mediciones reales 2026:

  • Meta Ads pixel client-side captura 35-55% del total de conversions reales
  • Google Ads gtag.js captura 55-70% sin enhanced conversions
  • TikTok pixel client-side 30-45% (peor performance por demografía)

Si tu attribution depende solo del pixel, estás optimizando con datos rotos. La consecuencia directa: el algoritmo de Meta Advantage+ y Google PMax aprenden de un sample sesgado y deciden a quién pujar más basándose en datos incompletos. Esto se traduce en CPM inflado, peores audiences, y eventually una caída de ROAS que no se explica solo por estacionalidad o creative fatigue.

Server-side tracking soluciona esto recibiendo eventos directamente del backend (Shopify webhook, Stripe webhook) — sin pasar por el navegador del usuario — y enviándolos a las plataformas con event_id que matchea el client-side cuando existe.


¿Por qué Cloudflare Workers vs sGTM Cloud Run?

La opción canónica para server-side tracking ha sido Server-side Google Tag Manager (sGTM) en Cloud Run. Funciona, pero tiene limitaciones que se notan a escala:

VariableCloudflare WorkerssGTM Cloud Run
Coste con 5M requests/mes$5 (paid plan)$8-15
Coste con 50M requests/mes$5$40-80
Latencia p9530-50ms100-200ms
Cold start5ms (V8 isolates)200-500ms (containers)
Datacenters edge300+1 region (configurable)
CPU limit per req50ms (paid)sin límite
Deploymentwrangler 30sCloud Build 2-3 min
Observability built-inSí (Workers Analytics)No (configurar Logging)
Vendor lock-inCloudflare ecosystemGoogle Cloud ecosystem

Workers gana en coste, latencia, y simplicidad operativa. Pierde en CPU intensivo (no es plataforma para procesamiento batch grande). Para tracking server-side donde cada request es enrich + fan-out a 3-5 endpoints HTTP, los 50ms de CPU son holgados.

Caveat importante: si tu equipo ya gestiona infraestructura en Google Cloud y quieres consolidar bills, Cloud Run mantiene sentido. Si no tienes preferencia, Workers es la opción más eficiente en 2026.


Arquitectura del pipeline

El Worker recibe eventos por dos rutas:

                     ┌──────────────────────────────────┐
                     │     Cloudflare Worker (edge)     │
                     │  ┌────────────────────────────┐  │
   Shopify webhook ──┼─▶│  /webhooks/shopify         │  │
   orders/create     │  │  - HMAC verify             │  │
                     │  │  - parse payload           │  │
                     │  │  - enrich user identity    │  │
                     │  └─────────────┬──────────────┘  │
                                      │                 │
   GTM Web client ────┼─▶ /events ────┼──┐              │
   (PageView, ATC,    │  - cookie match │  │              │
    InitiateCheckout) │                  │  │              │
                     │                  ▼  ▼              │
                     │  ┌────────────────────────────┐  │
                     │  │  Event normalizer          │  │
                     │  │  - generate event_id       │  │
                     │  │  - hash PII (SHA256)       │  │
                     │  │  - dedupe with KV          │  │
                     │  └─────────────┬──────────────┘  │
                     │                │                 │
                     │     ┌──────────┴──────────┐      │
                     │     │  Fan-out paralelo  │      │
                     │     └─────┬───┬───┬───┬──┘      │
                     │           │   │   │   │         │
                     │           ▼   ▼   ▼   ▼         │
                     │       Meta  Google TikTok  GA4  │
                     │       CAPI  Ads    Events  MP   │
                     │             EC                  │
                     └──────────────────────────────────┘

Componentes clave:

  1. Two ingress paths: webhook Shopify (autoritativo, capta 100% órdenes) y endpoint relay /events (capta intent signals desde web)
  2. HMAC verification en webhook Shopify para evitar spoofing
  3. Event normalizer que genera o propaga event_id consistente para deduplication
  4. PII hashing automático (email, phone, IP) con SHA256 antes de enviar a destinos
  5. KV deduplication con TTL 7 días para evitar re-procesar eventos repetidos
  6. Fan-out paralelo con Promise.all para no encolar latencia
  7. Retry logic con exponential backoff en errores 5xx
  8. Observability via Workers Analytics + log forwarding opcional

Worker code: pipeline completo

Estructura del proyecto:

sellencia-tracking-worker/
├── wrangler.toml              # config Cloudflare
├── package.json
├── src/
   ├── index.ts              # entry point router
   ├── handlers/
   ├── shopify.ts        # webhook Shopify orders/create
   └── events.ts         # endpoint /events relay
   ├── destinations/
   ├── meta-capi.ts      # Meta Conversions API
   ├── google-ads.ts     # Enhanced Conversions
   ├── tiktok-events.ts  # TikTok Events API
   └── ga4-mp.ts         # GA4 Measurement Protocol
   ├── lib/
   ├── crypto.ts         # SHA256 hashing PII
   ├── dedupe.ts         # KV-based dedup
   └── retry.ts          # exponential backoff
   └── types.ts
└── README.md

Entry point con routing:

// src/index.ts
import { handleShopifyWebhook } from './handlers/shopify'
import { handleEventRelay } from './handlers/events'

export interface Env {
  META_PIXEL_ID: string
  META_ACCESS_TOKEN: string
  GOOGLE_ADS_CONVERSION_ID: string
  GOOGLE_ADS_CONVERSION_LABEL: string
  GOOGLE_ADS_DEVELOPER_TOKEN: string
  TIKTOK_PIXEL_CODE: string
  TIKTOK_ACCESS_TOKEN: string
  GA4_MEASUREMENT_ID: string
  GA4_API_SECRET: string
  SHOPIFY_WEBHOOK_SECRET: string
  DEDUPE: KVNamespace
}

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    const url = new URL(request.url)

    if (request.method !== 'POST') {
      return new Response('Method not allowed', { status: 405 })
    }

    try {
      if (url.pathname === '/webhooks/shopify') {
        return await handleShopifyWebhook(request, env, ctx)
      }
      if (url.pathname === '/events') {
        return await handleEventRelay(request, env, ctx)
      }
      return new Response('Not found', { status: 404 })
    } catch (err) {
      console.error('handler error', err)
      return new Response('Internal error', { status: 500 })
    }
  }
}

Handler webhook Shopify con HMAC verification + fan-out:

// src/handlers/shopify.ts
import { verifyShopifyHmac } from '../lib/crypto'
import { isDuplicate, markProcessed } from '../lib/dedupe'
import { sendMetaCapi } from '../destinations/meta-capi'
import { sendGoogleAdsEnhanced } from '../destinations/google-ads'
import { sendTiktokEvent } from '../destinations/tiktok-events'
import { sendGa4Mp } from '../destinations/ga4-mp'

export async function handleShopifyWebhook(
  request: Request,
  env: Env,
  ctx: ExecutionContext
): Promise<Response> {
  const rawBody = await request.text()
  const hmac = request.headers.get('X-Shopify-Hmac-Sha256') || ''

  const valid = await verifyShopifyHmac(rawBody, hmac, env.SHOPIFY_WEBHOOK_SECRET)
  if (!valid) {
    return new Response('Invalid HMAC', { status: 401 })
  }

  const order = JSON.parse(rawBody)

  // Generar event_id determinístico desde Shopify order id + timestamp
  // Si el webhook llega 2 veces (Shopify retry), generamos el mismo event_id
  // y la deduplication en KV lo bloquea.
  const eventId = `purchase_${order.id}_${order.created_at}`

  if (await isDuplicate(env.DEDUPE, eventId)) {
    return new Response('OK (duplicate)', { status: 200 })
  }

  const event = {
    event_id: eventId,
    event_name: 'Purchase',
    event_time: Math.floor(new Date(order.processed_at).getTime() / 1000),
    user_data: {
      em: order.email,
      ph: order.phone,
      fn: order.billing_address?.first_name,
      ln: order.billing_address?.last_name,
      ct: order.billing_address?.city,
      st: order.billing_address?.province_code,
      zp: order.billing_address?.zip,
      country: order.billing_address?.country_code,
      client_ip_address: order.client_details?.browser_ip,
      client_user_agent: order.client_details?.user_agent,
      fbp: order.note_attributes?.find((a: any) => a.name === '_fbp')?.value,
      fbc: order.note_attributes?.find((a: any) => a.name === '_fbc')?.value,
      gclid: order.note_attributes?.find((a: any) => a.name === '_gclid')?.value,
    },
    custom_data: {
      currency: order.currency,
      value: parseFloat(order.total_price),
      content_ids: order.line_items.map((li: any) => li.sku),
      content_type: 'product',
      contents: order.line_items.map((li: any) => ({
        id: li.sku,
        quantity: li.quantity,
        item_price: parseFloat(li.price),
      })),
      num_items: order.line_items.reduce((s: number, li: any) => s + li.quantity, 0),
      order_id: String(order.id),
    },
    action_source: 'website',
    event_source_url: order.referring_site || `https://${order.shop_domain}/checkout`,
  }

  // Fan-out paralelo con Promise.allSettled para que un fallo no tumbe el resto
  ctx.waitUntil(
    Promise.allSettled([
      sendMetaCapi(event, env),
      sendGoogleAdsEnhanced(event, env),
      sendTiktokEvent(event, env),
      sendGa4Mp(event, env),
      markProcessed(env.DEDUPE, eventId),
    ])
  )

  // Respondemos a Shopify inmediatamente (no esperamos a destinos)
  return new Response('OK', { status: 200 })
}

Destination Meta CAPI con retry:

// src/destinations/meta-capi.ts
import { retry } from '../lib/retry'
import { sha256 } from '../lib/crypto'

export async function sendMetaCapi(event: any, env: Env): Promise<void> {
  // Hash de PII fields antes de enviar
  const userData: any = {}
  if (event.user_data.em) userData.em = await sha256(event.user_data.em.toLowerCase().trim())
  if (event.user_data.ph) userData.ph = await sha256(event.user_data.ph.replace(/\D/g, ''))
  if (event.user_data.fn) userData.fn = await sha256(event.user_data.fn.toLowerCase().trim())
  if (event.user_data.ln) userData.ln = await sha256(event.user_data.ln.toLowerCase().trim())
  if (event.user_data.ct) userData.ct = await sha256(event.user_data.ct.toLowerCase().replace(/\s/g, ''))
  if (event.user_data.zp) userData.zp = await sha256(event.user_data.zp.toLowerCase().trim())

  // Estos NO se hashean (son IDs ya truncated/safe)
  if (event.user_data.fbp) userData.fbp = event.user_data.fbp
  if (event.user_data.fbc) userData.fbc = event.user_data.fbc
  if (event.user_data.client_ip_address) userData.client_ip_address = event.user_data.client_ip_address
  if (event.user_data.client_user_agent) userData.client_user_agent = event.user_data.client_user_agent

  const body = {
    data: [{
      event_name: event.event_name,
      event_time: event.event_time,
      event_id: event.event_id, // CRITICAL: mismo event_id que client-side pixel
      action_source: event.action_source,
      event_source_url: event.event_source_url,
      user_data: userData,
      custom_data: event.custom_data,
    }]
  }

  const url = `https://graph.facebook.com/v19.0/${env.META_PIXEL_ID}/events?access_token=${env.META_ACCESS_TOKEN}`

  await retry(async () => {
    const res = await fetch(url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
    })
    if (!res.ok) {
      const text = await res.text()
      throw new Error(`Meta CAPI ${res.status}: ${text}`)
    }
  }, { maxAttempts: 3, baseDelayMs: 500 })
}

Destination Google Ads Enhanced Conversions:

// src/destinations/google-ads.ts
import { retry } from '../lib/retry'
import { sha256 } from '../lib/crypto'

export async function sendGoogleAdsEnhanced(event: any, env: Env): Promise<void> {
  if (event.event_name !== 'Purchase') return // EC solo para Purchase

  const userIdentifiers: any[] = []
  if (event.user_data.em) {
    userIdentifiers.push({
      hashedEmail: await sha256(event.user_data.em.toLowerCase().trim())
    })
  }
  if (event.user_data.ph) {
    // Phone debe estar en E.164 antes de hashear
    userIdentifiers.push({
      hashedPhoneNumber: await sha256(normalizePhoneE164(event.user_data.ph))
    })
  }

  const conversion = {
    conversionAction: `customers/${env.GOOGLE_ADS_CUSTOMER_ID}/conversionActions/${env.GOOGLE_ADS_CONVERSION_ID}`,
    conversionDateTime: new Date(event.event_time * 1000).toISOString(),
    conversionValue: event.custom_data.value,
    currencyCode: event.custom_data.currency,
    orderId: event.custom_data.order_id,
    gclid: event.user_data.gclid,
    userIdentifiers,
  }

  const url = `https://googleads.googleapis.com/v17/customers/${env.GOOGLE_ADS_CUSTOMER_ID}:uploadClickConversions`

  await retry(async () => {
    const res = await fetch(url, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'developer-token': env.GOOGLE_ADS_DEVELOPER_TOKEN,
        'Authorization': `Bearer ${env.GOOGLE_ADS_OAUTH_TOKEN}`,
        'login-customer-id': env.GOOGLE_ADS_LOGIN_CUSTOMER_ID,
      },
      body: JSON.stringify({
        conversions: [conversion],
        partialFailure: true,
      }),
    })
    if (!res.ok) {
      throw new Error(`Google Ads ${res.status}: ${await res.text()}`)
    }
  }, { maxAttempts: 3, baseDelayMs: 500 })
}

function normalizePhoneE164(phone: string): string {
  const digits = phone.replace(/\D/g, '')
  return digits.startsWith('34') || digits.startsWith('1') ? `+${digits}` : `+34${digits}`
}

Helper retry con exponential backoff:

// src/lib/retry.ts
export async function retry<T>(
  fn: () => Promise<T>,
  opts: { maxAttempts: number; baseDelayMs: number }
): Promise<T> {
  let attempt = 0
  while (attempt < opts.maxAttempts) {
    try {
      return await fn()
    } catch (err) {
      attempt++
      if (attempt >= opts.maxAttempts) throw err
      const delay = opts.baseDelayMs * Math.pow(2, attempt - 1)
      await new Promise((r) => setTimeout(r, delay))
    }
  }
  throw new Error('retry exhausted')
}

Helper SHA256 + HMAC con WebCrypto API (Workers nativo):

// src/lib/crypto.ts
export async function sha256(input: string): Promise<string> {
  const buf = new TextEncoder().encode(input)
  const hash = await crypto.subtle.digest('SHA-256', buf)
  return Array.from(new Uint8Array(hash))
    .map((b) => b.toString(16).padStart(2, '0'))
    .join('')
}

export async function verifyShopifyHmac(
  body: string,
  receivedHmac: string,
  secret: string
): Promise<boolean> {
  const key = await crypto.subtle.importKey(
    'raw',
    new TextEncoder().encode(secret),
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['sign']
  )
  const signature = await crypto.subtle.sign(
    'HMAC',
    key,
    new TextEncoder().encode(body)
  )
  const computed = btoa(String.fromCharCode(...new Uint8Array(signature)))
  return computed === receivedHmac
}

Helper deduplication con KV:

// src/lib/dedupe.ts
const DEDUPE_TTL_SECONDS = 7 * 24 * 3600 // 7 días

export async function isDuplicate(kv: KVNamespace, eventId: string): Promise<boolean> {
  const existing = await kv.get(eventId)
  return existing !== null
}

export async function markProcessed(kv: KVNamespace, eventId: string): Promise<void> {
  await kv.put(eventId, '1', { expirationTtl: DEDUPE_TTL_SECONDS })
}

wrangler.toml: configuración Cloudflare

name = "sellencia-tracking"
main = "src/index.ts"
compatibility_date = "2026-05-01"
compatibility_flags = ["nodejs_compat"]

[[kv_namespaces]]
binding = "DEDUPE"
id = "<TU_KV_NAMESPACE_ID>"

# Variables (pon los secrets con wrangler secret put)
[vars]
META_PIXEL_ID = "1234567890"
GOOGLE_ADS_CUSTOMER_ID = "1234567890"
GOOGLE_ADS_LOGIN_CUSTOMER_ID = "9876543210"
GOOGLE_ADS_CONVERSION_ID = "AbCdEfGh"
TIKTOK_PIXEL_CODE = "ABC123"
GA4_MEASUREMENT_ID = "G-XXXXXXX"

[observability]
enabled = true
head_sampling_rate = 1

Setup de secrets:

wrangler secret put META_ACCESS_TOKEN
wrangler secret put GOOGLE_ADS_DEVELOPER_TOKEN
wrangler secret put GOOGLE_ADS_OAUTH_TOKEN
wrangler secret put TIKTOK_ACCESS_TOKEN
wrangler secret put GA4_API_SECRET
wrangler secret put SHOPIFY_WEBHOOK_SECRET

Deploy en 30 segundos:

wrangler deploy

Custom domain (recomendado para que las cookies first-party funcionen correctamente):

wrangler domains add tracking.tu-dominio.com

Configurar webhook Shopify

En Shopify Admin → Settings → Notifications → Webhooks:

  1. Event: Order creation
  2. Format: JSON
  3. URL: https://tracking.tu-dominio.com/webhooks/shopify
  4. Webhook API version: 2026-01 o superior

Copiar el webhook secret y guardarlo con wrangler secret put SHOPIFY_WEBHOOK_SECRET.


Configurar relay desde GTM Web

En tu GTM web container, añadir un Custom HTML tag que envíe a tu Worker:

<script>
(function(){
  var data = {
    event_id: {{Event ID}}, // mismo event_id que el pixel
    event_name: {{Event Name}},
    event_time: Math.floor(Date.now()/1000),
    user_data: {
      em: {{User Email}},
      fbp: document.cookie.match(/_fbp=([^;]+)/)?.[1],
      fbc: document.cookie.match(/_fbc=([^;]+)/)?.[1],
      gclid: localStorage.getItem('gclid'),
      client_user_agent: navigator.userAgent,
    },
    custom_data: {
      currency: {{Currency}},
      value: {{Value}},
      content_ids: {{Content IDs}},
    },
    action_source: 'website',
    event_source_url: window.location.href,
  };

  fetch('https://tracking.tu-dominio.com/events', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data),
    keepalive: true, // CRITICAL para que el request sobreviva al unload
  });
})();
</script>

Punto crítico: event_id debe ser idéntico al eventID del pixel client-side. La forma robusta es generarlo en GTM Web tag con un Custom JavaScript variable y reutilizarlo en ambos sitios:

function() {
  var eventName = {{Event Name}};
  var orderId = {{Order ID}} || crypto.randomUUID();
  return eventName + '_' + orderId + '_' + Date.now();
}

Validación end-to-end

Después del deploy, validar 4 cosas:

1. Webhook Shopify activo

# En Shopify Admin → Notifications → Webhooks → ver el webhook
# Click "Send test notification" y revisar Workers logs
wrangler tail

Esperado: log line con OK 200 y eventos saliendo a Meta/Google/TikTok/GA4.

2. Meta Test Events

En Meta Events Manager → Test Events → introducir tu test_event_code en el body del Worker temporalmente. Verificar que llega el Purchase con event_id, value, currency, contents.

3. Deduplication funcionando

En Meta Events Manager → Diagnostics → buscar “Event Deduplication”. Debe mostrar:

  • “Browser” — eventos del pixel
  • “Server” — eventos del Worker
  • “Both” — eventos deduplicados correctamente con event_id idéntico

Target: 70%+ eventos en “Both” (significa pixel + CAPI matchean correctamente).

4. Google Ads Conversion Diagnostics

En Google Ads → Tools → Conversions → click en tu conversion → Diagnostics. Debe mostrar:

  • Recorded conversions: total
  • Enhanced conversions: % alto (>60% target)
  • Match rate: % alto (>40% target)

Métricas de éxito post-implementación

En auditorías Sellencia 2026 sobre 18 marcas DTC España que migraron de pixel-only a Workers + CAPI con deduplication, 30 días después:

MétricaMediana antesMediana despuésDelta
Conversions reportadas Meta1240/mes1815/mes+46%
Conversions reportadas Google1480/mes1740/mes+18%
ROAS reportado Meta3.1x4.1x+32%
ROAS reportado Google4.2x5.0x+19%
EMQ score Meta5.87.4+1.6 pts
Match rate Google EC28%51%+23pp

Caveat importante: el “ROAS reportado” sube porque las plataformas ven más conversiones, no porque haya MÁS revenue real. El revenue Shopify total es el mismo. Lo que cambia es:

  1. El algoritmo de Meta/Google tiene mejor sample para optimizar
  2. CAC blended baja típicamente -8 a -15% en los siguientes 60 días
  3. CPM no cambia mucho a corto plazo, pero CPA mejora porque mejor match audience

Mide siempre MER blended (revenue Shopify / spend total) además del ROAS reportado por plataforma. Si MER mejora post-CAPI, el algoritmo está realmente optimizando mejor. Si MER se queda igual y solo sube el ROAS reportado, has mejorado attribution pero no eficiencia.


Errores comunes en producción

1. event_id distinto entre pixel y Worker. Si generas event_id en client con crypto.randomUUID() pero en Worker generas uno nuevo, NUNCA habrá deduplication. La regla: el client-side genera el event_id (con un patrón determinístico tipo purchase_${orderId}_${timestamp}), lo persiste en GTM dataLayer, y lo pasa al Worker via header o payload.

2. PII no hasheada o hasheada mal. Meta CAPI espera SHA256 lowercase de email trimmed. Google EC espera SHA256 de phone en E.164. Si hasheas con sal o encoding distinto, el match rate cae al 0%. Usar siempre las funciones canónicas de cada plataforma.

3. No usar ctx.waitUntil para fan-out. Si haces await Promise.all(...) antes de devolver la response al webhook Shopify, Shopify se queda esperando hasta que todos los destinos respondan. Si Meta o Google son lentos, el webhook timeout (10s) y Shopify reintenta — generando duplicados. La solución correcta: responder 200 al webhook inmediatamente y procesar destinos en ctx.waitUntil(...) que continúa después del response.

4. KV deduplication sin TTL. Sin TTL el KV crece infinito y empieza a costar dinero. TTL 7 días es suficiente para capturar el 99% de retries de webhooks (la mayoría retries Shopify ocurren en <1h).

5. No verificar HMAC de Shopify. Sin HMAC verification, cualquiera puede inyectar webhooks falsos en tu pipeline y contaminar tu attribution. La verificación HMAC SHA256 es trivial (15 líneas) y no negociable.

6. Olvidar Conversions API access token expiration. El access token de Meta CAPI puede expirar (60 días los temporales, never los system-user). Configurar token system-user permanente desde Meta Business Manager evita downtime cada 60 días.

7. Fan-out sin retry logic. Errores 5xx de Meta/Google ocurren en ~0.3% de requests. Sin retry, pierdes eventos. Con exponential backoff 3 intentos (500ms, 1s, 2s), recuperas el 99%+ de errores transitorios.


Recurso descargable

📦 Worker code completo + observability dashboard: sellencia.com/recursos/sellencia-cloudflare-tracking-worker.zip

Incluye:

  • src/ completo con TypeScript, los 4 destinos (Meta, Google, TikTok, GA4) implementados, retry, dedupe, HMAC verify
  • wrangler.toml con observability habilitada y custom domain
  • tests/ con vitest + mocks de cada destino para validar localmente
  • README.md con setup paso a paso (Shopify webhook, GTM web tag, secrets)
  • scripts/validate-events.sh para enviar test events y verificar respuesta

Licencia MIT. Compatible con Workers paid plan ($5/mes).


Para profundizar


Referencias técnicas oficiales


Stack en producción · Sellencia Growth Engineering

Si tu equipo quiere que montemos este pipeline en tu cuenta (Worker + 4 destinos + dashboards de validación + handover a vuestro equipo técnico): hablamos.

Preguntas frecuentes

¿Por qué Cloudflare Workers en lugar de sGTM Cloud Run?
Cuatro razones técnicas. (1) Coste: $5/mes incluye 10M requests vs Cloud Run $5-15 con tráfico medio. (2) Latencia: Workers corre en 300+ datacenters edge, p95 &lt;50ms global vs sGTM Cloud Run ~150ms. (3) Cold start: Workers usa V8 isolates (5ms cold start) vs Cloud Run containers (200-500ms). (4) DX: deployment con wrangler en 30 segundos, observability built-in, sin gestión de containers. Caveat: Workers tiene CPU limit 50ms por request en plan paid (suficiente para 99% pipelines tracking). Si necesitas heavy compute o persistencia, Cloud Run sigue siendo mejor.
¿Qué es event deduplication en CAPI y por qué importa?
Cuando envías el mismo evento por client-side (pixel) y server-side (CAPI), Meta debe saber que es el MISMO evento para no contarlo dos veces. Lo hace via el campo event_id idéntico en ambos. Sin deduplication: Meta cuenta 2 conversions por 1 venta real, infla ROAS reportado, optimiza el algoritmo con datos falsos. Implementación correcta: generar event_id único (UUID) en client-side, pasarlo al server-side via webhook payload o header, enviar el mismo event_id a CAPI. Deduplication aplica también a Google Ads Enhanced Conversions (campo gclid + transaction_id) y TikTok Events API (event_id). Sin esto, todos los datos están sucios.
¿Qué eventos debo enviar server-side mínimo?
Top 5 por impacto en signal quality: (1) Purchase — el más crítico, captura 100% órdenes via Shopify webhook orders/create independiente de browser; (2) AddToCart — matchea con cookie session para enriquecer journey; (3) InitiateCheckout — alta señal de intent, optimiza Advantage+ Meta y PMax Google; (4) Lead — cualquier formulario de lead gen, con email hasheado SHA256; (5) ViewContent en PDPs principales — solo top 100 SKUs por revenue. Eventos de bajo valor (PageView genérico, Scroll) no merecen el coste server-side. Regla: si Meta o Google lo usan para optimization, server-side; si no, déjalo client-side.
¿Cuánto recupera realmente server-side tracking en 2026?
En auditorías Sellencia 2026 sobre 18 marcas DTC España, recuperación media 41% de conversiones perdidas vs solo client-side. Distribución: iOS Safari ATT/ITP +28%, ad blockers desktop +18%, Firefox tracking protection +6%, eventos cross-domain checkout +12%. La recuperación es mayor en categorías con audiencia tech (iOS share 65%) que en mass market (iOS share 35%). Marca DTC mobiliario premium con 70% tráfico móvil iOS pasó de 1240 conversions/mes reportadas Meta a 1815 tras CAPI con deduplication correcta — 46% uplift, ROAS reportado +33%.
¿Necesito un developer para implementar este stack?
Para el setup base con Workers + 4 destinos (Meta, Google, TikTok, GA4) + deduplication: 4-6 horas con un dev senior que conozca JavaScript edge runtime. Para añadir Contribution Margin Bidding (calcular margen real por SKU y enviarlo como value): +2-4 horas. El recurso descargable [sellencia-cloudflare-tracking-worker](/recursos/sellencia-cloudflare-tracking-worker.zip) incluye el Worker code completo, wrangler.toml, schema validation, retry logic con exponential backoff y dashboard observability. Listo para deploy con 3 comandos.