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
- Por qué el seguimiento del lado del servidor es necesario
- Descripción general de la arquitectura
- Requisitos previos
- Paso 1: Configuración del Administrador de Anuncios de Reddit
- Paso 2: Construcción del rastreador de conversiones
- Paso 3: Configuración de Docker
- Paso 4: Proxy inverso Nginx
- Paso 5: Certificado SSL
- Paso 6: Configuración de webhooks de Shopify
- Paso 7: Pruebas y verificación
- Solución de problemas comunes
- 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
| Componente | Propósito | Tecnología |
|---|---|---|
| Receptor de webhooks | Recibe eventos de pedidos de Shopify | Node.js/Express |
| Verificación HMAC | Valida la autenticidad del webhook | crypto (SHA-256) |
| Transformador de eventos | Convierte datos de Shopify al formato de Reddit | Servicio personalizado |
| Deduplicación | Previene eventos duplicados | PostgreSQL |
| Cliente de API de Reddit | Envía eventos a Reddit | Cliente HTTP con lógica de reintentos |
| Proxy inverso | Terminación SSL, enrutamiento | Nginx |
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
- Vaya al Administrador de Anuncios de Reddit → Administrador de Eventos
- Haga clic en "Crear nuevo Pixel"
- Nombre su pixel (p. ej., "Conversiones de Tienda Shopify")
- Copie el ID del Pixel (formato:
t2_abc123)
Generar token de acceso de la API de Conversiones
- En el Administrador de Eventos, haga clic en su pixel
- Vaya a "Configuración" → "API de Conversiones"
- Haga clic en "Generar token de acceso"
- Copie el token (comienza con
Bearer ey...) - 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
- Vaya a Administración de Shopify → Configuración → Notificaciones
- Desplácese hasta "Webhooks"
- 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
- En la Administración de Shopify, vaya al webhook que creó
- Haga clic en "Enviar notificación de prueba"
- 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
- Vaya al Administrador de Anuncios de Reddit → Administrador de Eventos
- Haga clic en su pixel
- Vaya a la pestaña "Eventos de prueba"
- 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:
| Error | Causa | Solución |
|---|---|---|
| "unexpected type number" | value enviado como decimal en lugar de entero | Verifique que está multiplicando por 100 y usando Math.round() |
| "unknown field event_id" | Enviando event_id en el payload de Reddit | Elimine event_id de redditEvent. Use conversion_id en su lugar |
| "missing required field" | Falta event_at, event_type o datos de usuario | Verifique que su transformador está configurando todos los campos requeridos |
| 401 Unauthorized | Token de acceso inválido | Regenere el token en el Administrador de Eventos de Reddit |
| 429 Too Many Requests | Limitación de tasa | Espere 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.