tva
← Insights

Seguimiento de Reddit Pixel para Shopify: Implementación del lado del servidor con la API de Conversiones

El seguimiento de Reddit para comercio electrónico enfrenta los mismos desafíos que otras plataformas publicitarias: los píxeles basados en el navegador son cada vez más bloqueados por bloqueadores de anuncios, configuraciones de privacidad y restricciones de seguimiento de iOS. Según la documentación oficial de Reddit, la API de Conversiones proporciona un seguimiento "más resiliente" que es "menos susceptible a los bloqueadores de anuncios y las restricciones del navegador".

Esta guía le muestra cómo implementar el seguimiento de conversiones de Reddit del lado del servidor para tiendas Shopify utilizando la API de Conversiones de Reddit. Tendrá una solución lista para producción basada en Docker que envía datos de conversión directamente desde su servidor a Reddit, evitando todas las limitaciones del lado del cliente.

Reddit recomienda oficialmente usar tanto el Pixel como la API de Conversiones juntos. De su documentación: "Para obtener los mejores resultados, recomendamos integrar tanto el Reddit Pixel como la API de Conversiones…CAPI puede ayudarle a obtener datos más precisos y aumentar su cobertura de conversiones".


Índice de contenidos

  1. Por qué el seguimiento del lado del servidor es necesario
  2. Descripción general de la arquitectura
  3. Requisitos previos
  4. Paso 1: Configuración del Administrador de Anuncios de Reddit
  5. Paso 2: Construcción del rastreador de conversiones
  6. Paso 3: Configuración de Docker
  7. Paso 4: Proxy inverso Nginx
  8. Paso 5: Certificado SSL
  9. Paso 6: Configuración de webhooks de Shopify
  10. Paso 7: Pruebas y verificación
  11. Solución de problemas comunes
  12. Monitoreo y mantenimiento

Por qué el seguimiento del lado del servidor es necesario

La limitación del píxel del lado del cliente

El Reddit Pixel es un fragmento de JavaScript que se ejecuta en el navegador del usuario. Si bien funciona para el seguimiento básico, enfrenta limitaciones significativas en 2025:

  • Bloqueadores de anuncios: Las extensiones del navegador bloquean completamente las solicitudes del píxel
  • Transparencia de seguimiento de aplicaciones de iOS: Requiere consentimiento explícito del usuario
  • Bloqueo de cookies de terceros: Safari y Firefox los bloquean por defecto
  • Configuraciones de privacidad: Los navegadores modernos restringen las capacidades de seguimiento

Estos no son problemas teóricos. Según datos de la industria, entre el 30 y el 50% de los eventos de conversión pueden perderse solo con el seguimiento del lado del cliente.

La recomendación oficial de Reddit

De la documentación de la API de Conversiones de Reddit (fuente):

"CAPI es más resiliente a la pérdida de señal porque opera del lado del servidor, lo que la hace menos susceptible a los bloqueadores de anuncios y las restricciones del navegador. Esto conduce a una mejor medición, segmentación y optimización".

La API de Conversiones envía datos de servidor a servidor. Sin intervención del navegador significa que no hay bloqueo, no hay solicitudes de consentimiento y hay una precisión completa de los datos.

Por qué necesita ambos

Reddit recomienda usar tanto el Pixel como CAPI juntos:

  • Pixel: Captura interacciones del lado del cliente, IDs de clic, contexto del navegador
  • CAPI: Asegura que los datos de conversión lleguen a Reddit incluso cuando el Pixel está bloqueado
  • Deduplicación: Reddit maneja automáticamente los eventos duplicados cuando ambos envían el mismo event_id

Esta guía implementa el componente CAPI. Puede agregar el Pixel por separado a través de los eventos de clientes de Shopify.


Descripción general de la arquitectura

Cómo funciona

La implementación utiliza webhooks de Shopify para capturar eventos de pedidos del lado del servidor y luego los reenvía a la API de Conversiones de Reddit:

Shopify Store (order created)
    |
    v
Shopify Webhook (HTTPS POST)
    |
    v
Nginx Reverse Proxy (SSL termination)
    |
    v
Node.js Application (Docker container)
    |
    v
1. HMAC Verification (security)
2. Event Transformation (Shopify -> Reddit format)
3. PII Hashing (SHA-256)
4. Deduplication Check (PostgreSQL)
5. Send to Reddit CAPI
6. Log Result
    |
    v
Reddit Ads Platform (conversion attributed)

Componentes

ComponentePropósitoTecnología
Receptor de webhooksRecibe eventos de pedidos de ShopifyNode.js/Express
Verificación HMACValida la autenticidad del webhookcrypto (SHA-256)
Transformador de eventosConvierte datos de Shopify al formato de RedditServicio personalizado
DeduplicaciónPreviene eventos duplicadosPostgreSQL
Cliente de API de RedditEnvía eventos a RedditCliente HTTP con lógica de reintentos
Proxy inversoTerminación SSL, enrutamientoNginx

Requisitos previos

Necesitará:

  • Servidor: VPS Linux con Docker instalado (2GB de RAM mínimo)
  • Dominio: Subdominio apuntando a su servidor (p. ej., shopify-events.sudominio.com)
  • Cuenta de Reddit Ads: Cuenta activa con Pixel creado
  • Tienda Shopify: Acceso de administrador para configurar webhooks
  • Herramientas: Acceso SSH, conocimientos básicos de línea de comandos

Paso 1: Configuración del Administrador de Anuncios de Reddit

Crear su Pixel

  1. Vaya al Administrador de Anuncios de Reddit → Administrador de Eventos
  2. Haga clic en "Crear nuevo Pixel"
  3. Nombre su pixel (p. ej., "Conversiones de Tienda Shopify")
  4. Copie el ID del Pixel (formato: t2_abc123)

Generar token de acceso de la API de Conversiones

  1. En el Administrador de Eventos, haga clic en su pixel
  2. Vaya a "Configuración" → "API de Conversiones"
  3. Haga clic en "Generar token de acceso"
  4. Copie el token (comienza con Bearer ey...)
  5. Importante: Guarde este token de forma segura. No lo verá de nuevo.

Ahora tiene:

  • REDDIT_PIXEL_ID (p. ej., t2_abc123)
  • REDDIT_ACCESS_TOKEN (p. ej., eyJhbGciOiJSUzI1NiIsImtpZCI...)

Paso 2: Construcción del rastreador de conversiones

Cree la estructura de directorios del proyecto:

mkdir -p /opt/reddit-capi/src/{routes,middleware,services,utils}
cd /opt/reddit-capi

package.json

{
  "name": "reddit-capi",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node src/index.js"
  },
  "dependencies": {
    "express": "^4.18.2",
    "pg": "^8.11.3",
    "winston": "^3.11.0",
    "uuid": "^9.0.1"
  }
}

src/config.js

export const config = {
  port: process.env.PORT || 3000,

  shopify: {
    webhookSecret: process.env.SHOPIFY_WEBHOOK_SECRET,
  },

  reddit: {
    pixelId: process.env.REDDIT_PIXEL_ID,
    accessToken: process.env.REDDIT_ACCESS_TOKEN,
    apiUrl: 'https://ads-api.reddit.com/api/v2.0/conversions/events',
  },

  database: {
    host: process.env.DB_HOST || 'postgres',
    port: process.env.DB_PORT || 5432,
    database: process.env.DB_NAME || 'reddit_capi',
    user: process.env.DB_USER || 'reddit_capi',
    password: process.env.DB_PASSWORD,
  },
};

src/utils/logger.js

import winston from 'winston';

export const logger = winston.createLogger({
  level: 'info',
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.json()
  ),
  transports: [
    new winston.transports.Console({
      format: winston.format.combine(
        winston.format.colorize(),
        winston.format.simple()
      ),
    }),
  ],
});

src/middleware/shopifyVerify.js

Este middleware maneja la verificación HMAC y el análisis del cuerpo de la solicitud:

import crypto from 'crypto';
import { config } from '../config.js';
import { logger } from '../utils/logger.js';

// Read raw body for HMAC verification and parse JSON
export function rawBodyCapture(req, res, next) {
  let data = '';

  req.on('data', (chunk) => {
    data += chunk;
  });

  req.on('end', () => {
    // Store raw body for HMAC verification
    req.rawBody = data;

    // Parse JSON and set as req.body
    // Important: Don't use express.json() middleware - it consumes the stream
    try {
      req.body = JSON.parse(data);
    } catch (error) {
      logger.error('Failed to parse JSON body:', error);
      return res.status(400).json({ error: 'Invalid JSON' });
    }

    next();
  });
}

export function verifyShopifyWebhook(req, res, next) {
  const hmacHeader = req.get('X-Shopify-Hmac-SHA256');

  if (!hmacHeader) {
    logger.warn('Missing HMAC header');
    return res.status(401).json({ error: 'Unauthorized - Missing HMAC' });
  }

  // Calculate HMAC using raw body
  const hash = crypto
    .createHmac('sha256', config.shopify.webhookSecret)
    .update(req.rawBody)
    .digest('base64');

  if (hash !== hmacHeader) {
    logger.warn('HMAC verification failed');
    return res.status(401).json({ error: 'Unauthorized - Invalid HMAC' });
  }

  logger.info('Webhook HMAC verified', {
    topic: req.get('X-Shopify-Topic'),
  });

  next();
}

src/services/crypto.js

import crypto from 'crypto';

export function hashEmail(email) {
  if (!email) return null;

  // Normalize: lowercase and trim
  const normalized = email.toLowerCase().trim();

  // SHA-256 hash
  return crypto.createHash('sha256').update(normalized).digest('hex');
}

export function hashPhone(phone) {
  if (!phone) return null;

  // Remove all non-digits
  const normalized = phone.replace(/\D/g, '');

  return crypto.createHash('sha256').update(normalized).digest('hex');
}

src/services/shopifyTransformer.js

Transforma los datos de pedidos de Shopify al formato CAPI de Reddit:

import { v4 as uuidv4 } from 'uuid';
import { hashEmail, hashPhone } from './crypto.js';

export function transformOrderToPurchase(order) {
  const eventId = `shopify_order_${order.id}_${Date.now()}`;

  const redditEvent = {
    event_at: order.created_at,
    event_type: {
      tracking_type: 'Purchase',
    },
    user: {
      // Hash PII for privacy
      email: hashEmail(order.email),
      ...(order.phone && { phone_number: hashPhone(order.phone) }),

      // IP and User Agent if available
      ...(order.browser_ip && { ip_address: order.browser_ip }),
      ...(order.client_details?.user_agent && {
        user_agent: order.client_details.user_agent
      }),
    },
    event_metadata: {
      // Reddit requires value as integer in minor currency units (cents)
      value: Math.round((parseFloat(order.total_price) || 0) * 100),

      // Unique ID for deduplication
      conversion_id: String(order.id),

      currency: order.currency,

      // Optional: Product details
      item_count: order.line_items?.length || 0,

      // Custom data for tracking
      order_number: order.order_number,
    },
  };

  return {
    event_id: eventId,
    event_type: 'Purchase',
    shopify_id: String(order.id),
    reddit_payload: redditEvent,
  };
}

export function transformCheckoutToLead(checkout) {
  const eventId = `shopify_checkout_${checkout.id}_${Date.now()}`;

  const redditEvent = {
    event_at: checkout.created_at,
    event_type: {
      tracking_type: 'Lead',
    },
    user: {
      email: hashEmail(checkout.email),
      ...(checkout.phone && { phone_number: hashPhone(checkout.phone) }),
    },
    event_metadata: {
      value: Math.round((parseFloat(checkout.total_price) || 0) * 100),
      conversion_id: String(checkout.id),
      currency: checkout.currency,
    },
  };

  return {
    event_id: eventId,
    event_type: 'Lead',
    shopify_id: String(checkout.id),
    reddit_payload: redditEvent,
  };
}

Detalle crítico: La API de Reddit espera value como un entero que representa la cantidad en unidades monetarias menores (centavos para USD, peniques para GBP, etc.). Por eso multiplicamos por 100 y redondeamos.

src/services/deduplication.js

import pg from 'pg';
import { config } from '../config.js';
import { logger } from '../utils/logger.js';

const { Pool } = pg;

const pool = new Pool(config.database);

export async function isDuplicate(eventId) {
  const result = await pool.query(
    'SELECT event_id FROM events WHERE event_id = $1',
    [eventId]
  );

  return result.rows.length > 0;
}

export async function saveEvent(eventData) {
  const {
    event_id,
    event_type,
    shopify_id,
    shopify_payload,
    reddit_payload,
  } = eventData;

  await pool.query(
    `INSERT INTO events (event_id, event_type, shopify_id, shopify_payload, reddit_payload, status)
     VALUES ($1, $2, $3, $4, $5, 'pending')`,
    [
      event_id,
      event_type,
      shopify_id,
      JSON.stringify(shopify_payload),
      JSON.stringify(reddit_payload),
    ]
  );

  logger.info('Event saved to database', { event_id, event_type });
}

export async function markEventSent(eventId, response) {
  await pool.query(
    `UPDATE events
     SET status = 'sent', sent_at = NOW(), reddit_response = $2
     WHERE event_id = $1`,
    [eventId, JSON.stringify(response)]
  );
}

export async function markEventFailed(eventId, error) {
  await pool.query(
    `UPDATE events
     SET status = 'failed', error_message = $2
     WHERE event_id = $1`,
    [eventId, error.message]
  );
}

src/services/redditCapi.js

import { config } from '../config.js';
import { logger } from '../utils/logger.js';

export async function sendToReddit(eventData) {
  const payload = {
    events: [eventData],
  };

  logger.info('Sending event to Reddit CAPI', {
    tracking_type: eventData.event_type.tracking_type,
    conversion_id: eventData.event_metadata.conversion_id,
  });

  // Retry logic: 3 attempts with exponential backoff
  let lastError;

  for (let attempt = 1; attempt <= 3; attempt++) {
    try {
      const response = await fetch(config.reddit.apiUrl, {
        method: 'POST',
        headers: {
          'Authorization': `Bearer ${config.reddit.accessToken}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(payload),
      });

      const responseText = await response.text();

      if (response.ok) {
        logger.info('Reddit CAPI success', {
          status: response.status,
          attempt,
        });

        return {
          success: true,
          status: response.status,
          response: responseText,
        };
      }

      // Handle rate limiting
      if (response.status === 429) {
        const retryAfter = parseInt(response.headers.get('Retry-After') || '60');
        logger.warn(`Rate limited by Reddit, retry after ${retryAfter}s`);

        await sleep(retryAfter * 1000);
        continue;
      }

      // Log error but retry
      logger.error('Reddit API error', {
        status: response.status,
        response: responseText,
        attempt,
      });

      lastError = new Error(`Reddit API error: ${response.status} - ${responseText}`);

      // Exponential backoff: 1s, 2s, 4s
      if (attempt < 3) {
        await sleep(Math.pow(2, attempt - 1) * 1000);
      }

    } catch (error) {
      logger.error('Network error sending to Reddit', {
        error: error.message,
        attempt,
      });

      lastError = error;

      if (attempt < 3) {
        await sleep(Math.pow(2, attempt - 1) * 1000);
      }
    }
  }

  throw lastError;
}

function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

src/routes/webhooks.js

import express from 'express';
import { logger } from '../utils/logger.js';
import * as shopifyTransformer from '../services/shopifyTransformer.js';
import * as deduplication from '../services/deduplication.js';
import * as redditCapi from '../services/redditCapi.js';

const router = express.Router();

router.post('/webhooks/shopify', async (req, res) => {
  const topic = req.get('X-Shopify-Topic');
  const shopDomain = req.get('X-Shopify-Shop-Domain');

  logger.info('Received Shopify webhook', { topic, shopDomain });

  try {
    let eventData;

    // Route based on webhook topic
    switch (topic) {
      case 'orders/create':
        eventData = shopifyTransformer.transformOrderToPurchase(req.body);
        break;

      case 'checkouts/create':
        eventData = shopifyTransformer.transformCheckoutToLead(req.body);
        break;

      default:
        logger.info('Unsupported webhook topic', { topic });
        return res.status(200).json({
          status: 'ignored',
          message: `Topic ${topic} not supported`,
        });
    }

    // Check for duplicates
    if (await deduplication.isDuplicate(eventData.event_id)) {
      logger.info('Duplicate event detected', { event_id: eventData.event_id });
      return res.status(200).json({ status: 'duplicate' });
    }

    // Save to database
    await deduplication.saveEvent({
      ...eventData,
      shopify_payload: req.body,
    });

    // Send to Reddit CAPI
    try {
      const result = await redditCapi.sendToReddit(eventData.reddit_payload);

      await deduplication.markEventSent(eventData.event_id, result);

      return res.status(200).json({
        status: 'success',
        event_id: eventData.event_id,
      });

    } catch (error) {
      await deduplication.markEventFailed(eventData.event_id, error);

      // Still return 200 to Shopify so it doesn't retry
      return res.status(200).json({
        status: 'failed',
        event_id: eventData.event_id,
        error: error.message,
      });
    }

  } catch (error) {
    logger.error('Error processing webhook', { error: error.message });

    return res.status(500).json({
      error: 'Internal server error',
      message: error.message,
    });
  }
});

export default router;

src/routes/health.js

import express from 'express';
import pg from 'pg';
import { config } from '../config.js';

const router = express.Router();
const { Pool } = pg;
const pool = new Pool(config.database);

router.get('/health', async (req, res) => {
  try {
    // Check database connection
    await pool.query('SELECT 1');

    res.json({
      status: 'healthy',
      timestamp: new Date().toISOString(),
      uptime: process.uptime(),
      database: 'connected',
    });
  } catch (error) {
    res.status(503).json({
      status: 'unhealthy',
      error: error.message,
    });
  }
});

export default router;

src/index.js

import express from 'express';
import { config } from './config.js';
import { logger } from './utils/logger.js';
import { rawBodyCapture, verifyShopifyWebhook } from './middleware/shopifyVerify.js';
import healthRouter from './routes/health.js';
import webhooksRouter from './routes/webhooks.js';

const app = express();

// Health check (no authentication)
app.use('/', healthRouter);

// Shopify webhooks (with HMAC verification)
// Important: rawBodyCapture must come before verifyShopifyWebhook
app.use('/webhooks/shopify', rawBodyCapture);
app.use('/webhooks/shopify', verifyShopifyWebhook);
app.use('/', webhooksRouter);

const server = app.listen(config.port, () => {
  logger.info(`Server running on port ${config.port}`);
});

// Graceful shutdown
process.on('SIGTERM', () => {
  logger.info('SIGTERM received, closing server');
  server.close(() => {
    logger.info('Server closed');
    process.exit(0);
  });
});

Paso 3: Configuración de Docker

Dockerfile

FROM node:18-alpine

WORKDIR /app

# Install dependencies
COPY package*.json ./
RUN npm ci --only=production

# Copy application code
COPY src ./src

# Run as non-root user
USER node

EXPOSE 3000

CMD ["node", "src/index.js"]

docker-compose.yml

version: '3.8'

services:
  reddit-capi:
    build: .
    ports:
      # Bind only to localhost - not publicly accessible
      - "127.0.0.1:3000:3000"
    environment:
      - PORT=3000
      - SHOPIFY_WEBHOOK_SECRET=${SHOPIFY_WEBHOOK_SECRET}
      - REDDIT_PIXEL_ID=${REDDIT_PIXEL_ID}
      - REDDIT_ACCESS_TOKEN=${REDDIT_ACCESS_TOKEN}
      - DB_HOST=postgres
      - DB_PORT=5432
      - DB_NAME=reddit_capi
      - DB_USER=reddit_capi
      - DB_PASSWORD=${DB_PASSWORD}
    depends_on:
      - postgres
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/health"]
      interval: 30s
      timeout: 10s
      retries: 3

  postgres:
    image: postgres:16-alpine
    environment:
      - POSTGRES_DB=reddit_capi
      - POSTGRES_USER=reddit_capi
      - POSTGRES_PASSWORD=${DB_PASSWORD}
    volumes:
      - pgdata:/var/lib/postgresql/data
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql
    restart: unless-stopped

volumes:
  pgdata:

init.sql

CREATE TABLE IF NOT EXISTS events (
  event_id VARCHAR(255) PRIMARY KEY,
  event_type VARCHAR(50) NOT NULL,
  shopify_id VARCHAR(255) NOT NULL,
  shopify_payload JSONB,
  reddit_payload JSONB,
  reddit_response JSONB,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  sent_at TIMESTAMP,
  status VARCHAR(20) DEFAULT 'pending',
  error_message TEXT
);

CREATE INDEX idx_events_status ON events(status);
CREATE INDEX idx_events_created_at ON events(created_at);
CREATE INDEX idx_events_shopify_id ON events(shopify_id);

.env

# Shopify Configuration
SHOPIFY_WEBHOOK_SECRET=your_shopify_webhook_secret_here

# Reddit Configuration
REDDIT_PIXEL_ID=t2_your_pixel_id
REDDIT_ACCESS_TOKEN=your_reddit_access_token_here

# Database Configuration
DB_PASSWORD=generate_strong_password_here

Seguridad: Establezca permisos restrictivos en el archivo .env:

chmod 600 .env

Paso 4: Proxy inverso Nginx

Cree el archivo de configuración de Nginx:

# /etc/nginx/sites-available/shopify-events.yourdomain.com.conf

server {
    listen 80;
    server_name shopify-events.yourdomain.com;

    # Let's Encrypt ACME challenge
    location /.well-known/acme-challenge/ {
        root /var/www/html;
    }

    # Redirect all other HTTP traffic to HTTPS
    location / {
        return 301 https://$host$request_uri;
    }
}

Habilite el sitio:

ln -s /etc/nginx/sites-available/shopify-events.yourdomain.com.conf /etc/nginx/sites-enabled/
nginx -t
systemctl reload nginx

Paso 5: Certificado SSL

Instale Certbot si aún no está instalado:

apt update
apt install certbot

Cree el directorio webroot:

mkdir -p /var/www/html/.well-known/acme-challenge

Genere el certificado:

certbot certonly --webroot \
  -w /var/www/html \
  -d shopify-events.yourdomain.com

Actualice la configuración de Nginx para usar SSL:

# Add this server block to /etc/nginx/sites-available/shopify-events.yourdomain.com.conf

server {
    listen 443 ssl http2;
    server_name shopify-events.yourdomain.com;

    ssl_certificate /etc/letsencrypt/live/shopify-events.yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/shopify-events.yourdomain.com/privkey.pem;

    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers HIGH:!aNULL:!MD5;
    ssl_prefer_server_ciphers on;
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 10m;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto https;

        proxy_http_version 1.1;
        proxy_set_header Connection "";

        // Timeouts for webhook processing
        proxy_connect_timeout 60s;
        proxy_send_timeout 60s;
        proxy_read_timeout 60s;
    }
}

Pruebe y recargue:

nginx -t
systemctl reload nginx

Verifique que HTTPS funciona:

curl -vI https://shopify-events.yourdomain.com/health

Debería ver:

  • HTTP/2 200
  • Certificado SSL válido
  • Respuesta JSON con {"status":"healthy"}

Paso 6: Configuración de webhooks de Shopify

Iniciar la aplicación

cd /opt/reddit-capi
docker compose up -d

Revise los registros:

docker compose logs -f reddit-capi

Debería ver: Server running on port 3000

Configurar webhooks en Shopify

  1. Vaya a Administración de Shopify → Configuración → Notificaciones
  2. Desplácese hasta "Webhooks"
  3. Haga clic en "Crear webhook"

Webhook de creación de pedidos:

  • Evento: Creación de pedido
  • Formato: JSON
  • URL: https://shopify-events.yourdomain.com/webhooks/shopify
  • Versión de API: Última (2025-01 o más reciente)

Haga clic en "Guardar webhook"

Copie el secreto de firma del webhook:

Después de crear el webhook, Shopify muestra un secreto de firma. Cópielo y agréguelo a su archivo .env:

echo 'SHOPIFY_WEBHOOK_SECRET=your_actual_secret_here' >> /opt/reddit-capi/.env

Reinicie el contenedor para aplicar el nuevo secreto:

docker compose restart reddit-capi

Opcional: Webhook de creación de checkout

Repita el proceso para eventos de "Creación de checkout" si desea rastrear carritos abandonados como eventos "Lead" en Reddit.


Paso 7: Pruebas y verificación

Probar HMAC sin firma válida

curl https://shopify-events.yourdomain.com/webhooks/shopify

Respuesta esperada:

{"error":"Unauthorized - Missing HMAC"}

Esto confirma que la seguridad está funcionando.

Enviar webhook de prueba desde Shopify

  1. En la Administración de Shopify, vaya al webhook que creó
  2. Haga clic en "Enviar notificación de prueba"
  3. Observe los registros:
docker compose logs -f reddit-capi

Debería ver:

INFO: Webhook HMAC verified {"topic":"orders/create"}
INFO: Received Shopify webhook {"topic":"orders/create","shopDomain":"your-store.myshopify.com"}
INFO: Event saved to database {"event_id":"shopify_order_...","event_type":"Purchase"}
INFO: Sending event to Reddit CAPI {"tracking_type":"Purchase","conversion_id":"..."}
INFO: Reddit CAPI success {"status":200,"attempt":1}

Verificar en el Administrador de Eventos de Reddit

  1. Vaya al Administrador de Anuncios de Reddit → Administrador de Eventos
  2. Haga clic en su pixel
  3. Vaya a la pestaña "Eventos de prueba"
  4. Debería ver el evento de prueba aparecer en unos minutos

Verificar base de datos

docker compose exec postgres psql -U reddit_capi -d reddit_capi -c \
  "SELECT event_id, event_type, status, sent_at FROM events ORDER BY created_at DESC LIMIT 5;"

Debería ver su evento de prueba con status = 'sent'.

Crear un pedido de prueba real

Use la función "Pedido de prueba" de Shopify o cree un pedido real en su tienda de desarrollo. El webhook se activará automáticamente y verá la conversión en Reddit en 15-30 minutos.


Solución de problemas comunes

Problema: Los eventos no aparecen en Reddit

Revise los registros en busca de errores de la API de Reddit:

docker compose logs reddit-capi | grep -i "reddit api error"

Errores comunes:

ErrorCausaSolución
"unexpected type number"value enviado como decimal en lugar de enteroVerifique que está multiplicando por 100 y usando Math.round()
"unknown field event_id"Enviando event_id en el payload de RedditElimine event_id de redditEvent. Use conversion_id en su lugar
"missing required field"Falta event_atevent_type o datos de usuarioVerifique que su transformador está configurando todos los campos requeridos
401 UnauthorizedToken de acceso inválidoRegenere el token en el Administrador de Eventos de Reddit
429 Too Many RequestsLimitación de tasaEspere el período de reintento (se maneja automáticamente)

Problema: Fallos en los webhooks de Shopify

Verifique el estado del webhook de Shopify:

En Administración de Shopify → Configuración → Notificaciones → Webhooks, verifique si el webhook muestra errores.

Problemas comunes:

  • Verificación HMAC fallida: Discrepancia en el secreto. Copie el secreto correcto de Shopify y actualice .env
  • Tiempo de espera agotado: El servidor no responde en 5 segundos. Verifique si el contenedor está ejecutándose y en buen estado
  • Errores SSL: Certificado inválido. Verifique con curl -vI https://su-dominio.com/health

Problema: "stream is not readable"

Este error ocurre cuando tanto rawBodyCapture como express.json() intentan leer el cuerpo de la solicitud.

Solución: Elimine el middleware express.json() de la ruta del webhook. La función rawBodyCapture maneja tanto la captura del cuerpo sin procesar como el análisis JSON.

Incorrecto:

app.use('/webhooks/shopify', express.json());  // ❌ Don't use this
app.use('/webhooks/shopify', rawBodyCapture);

Correcto:

app.use('/webhooks/shopify', rawBodyCapture);  // ✅ Handles both

Problema: Conversiones duplicadas

Si está usando tanto el Reddit Pixel como CAPI, asegúrese de usar el mismo conversion_id en ambos.

Implementación del Pixel (en la página de agradecimiento de Shopify):

rdt('track', 'Purchase', {
  transactionId: '{{ order.id }}',  // Must match conversion_id in CAPI
  value: {{ order.total_price }},
  currency: '{{ order.currency }}'
});

Reddit deduplicará automáticamente los eventos con el mismo conversion_id.

Problema: Fallo en la conexión a la base de datos

# Check if PostgreSQL is running
docker compose ps postgres

# Test connection
docker compose exec postgres pg_isready -U reddit_capi

# Check logs
docker compose logs postgres

Monitoreo y mantenimiento

Monitoreo de registros

Registros en tiempo real:

docker compose logs -f reddit-capi

Buscar errores:

docker compose logs reddit-capi | grep -i error

Filtrar por tipo de evento:

docker compose logs reddit-capi | grep "Purchase"

Consultas a la base de datos

Eventos recientes:

docker compose exec postgres psql -U reddit_capi -d reddit_capi -c \
  "SELECT event_id, event_type, status, created_at, sent_at
   FROM events
   ORDER BY created_at DESC
   LIMIT 20;"

Eventos fallidos:

docker compose exec postgres psql -U reddit_capi -d reddit_capi -c \
  "SELECT event_id, event_type, error_message, created_at
   FROM events
   WHERE status = 'failed'
   ORDER BY created_at DESC;"

Estadísticas de eventos:

docker compose exec postgres psql -U reddit_capi -d reddit_capi -c \
  "SELECT event_type, status, COUNT(*) as count
   FROM events
   GROUP BY event_type, status
   ORDER BY event_type, status;"

Verificación de salud

Configure una tarea cron para monitorear el endpoint de salud:

*/5 * * * * curl -sf https://shopify-events.yourdomain.com/health > /dev/null || echo "Reddit CAPI health check failed" | mail -s "Alert" your@email.com

Renovación del certificado SSL

Los certificados de Let's Encrypt son válidos por 90 días. Certbot debería renovarlos automáticamente.

Probar renovación:

certbot renew --dry-run

Verificar vencimiento del certificado:

certbot certificates

Actualizaciones de contenedores

Actualizar después de cambios en el código:

docker compose down
docker compose up -d --build

Ver estado del contenedor:

docker compose ps

Reiniciar sin reconstruir:

docker compose restart reddit-capi

Configuración avanzada

Tipos de eventos personalizados

Reddit soporta eventos estándar adicionales. Puede extender el transformador para manejar más webhooks de Shopify:

Ver contenido (vistas de producto):

// Add to shopifyTransformer.js
export function transformProductView(product) {
  return {
    event_at: new Date().toISOString(),
    event_type: {
      tracking_type: 'ViewContent',
    },
    user: { /* user data */ },
    event_metadata: {
      conversion_id: `product_${product.id}_${Date.now()}`,
      item_id: String(product.id),
      value: Math.round(parseFloat(product.price) * 100),
      currency: 'USD',
    },
  };
}

Añadir al carrito:

case 'carts/update':
  eventData = transformCartUpdate(req.body);
  break;

Seguimiento a nivel de producto

Incluya detalles del producto en los metadatos del evento:

event_metadata: {
  value: Math.round((parseFloat(order.total_price) || 0) * 100),
  conversion_id: String(order.id),
  currency: order.currency,

  // Product details
  products: order.line_items.map(item => ({
    id: String(item.product_id),
    name: item.title,
    quantity: item.quantity,
    price: Math.round(parseFloat(item.price) * 100),
  })),
},

Soporte multi-moneda

La implementación ya maneja múltiples monedas a través del campo order.currency de Shopify. Asegúrese de convertir siempre el valor a unidades monetarias menores correctamente:

  • USD, EUR, CAD: Multiplicar por 100 (centavos)
  • JPY, KRW: No se necesita multiplicación (ya están en unidades menores)
  • BHD, KWD: Multiplicar por 1000 (3 decimales)

Para monedas con diferentes posiciones decimales, actualice el transformador:

function getMinorUnitMultiplier(currency) {
  const zeroDecimalCurrencies = ['JPY', 'KRW', 'CLP'];
  const threeDecimalCurrencies = ['BHD', 'KWD', 'OMR'];

  if (zeroDecimalCurrencies.includes(currency)) return 1;
  if (threeDecimalCurrencies.includes(currency)) return 1000;
  return 100;
}

// In transformer:
value: Math.round(parseFloat(order.total_price) * getMinorUnitMultiplier(order.currency))

Próximos pasos

Esta implementación proporciona un seguimiento de conversiones del lado del servidor listo para producción para Reddit Ads. La arquitectura está diseñada para la fiabilidad, con verificación HMAC, deduplicación, lógica de reintentos y registro completo.

Puede ampliar esta base con:

  • Tipos de eventos adicionales: Vistas de productos, abandono de carritos, búsquedas
  • Coincidencia de usuarios mejorada: Incluir IDs de clic de Reddit de los parámetros de URL
  • Alertas: Integrar con herramientas de monitoreo para eventos fallidos
  • Panel de análisis: Visualizar datos de conversión desde PostgreSQL

Como referencia, el código fuente completo y la configuración de Docker demostrados aquí se basan en un despliegue en producción que procesa pedidos reales de Shopify.