tva
← Insights

Rastreamento Reddit Pixel para Shopify: Implementação Server-Side com Conversions API

O rastreamento do Reddit para e-commerce enfrenta os mesmos desafios de outras plataformas de publicidade: pixels baseados em navegador estão sendo cada vez mais bloqueados por bloqueadores de anúncios, configurações de privacidade e restrições de rastreamento do iOS. De acordo com a documentação oficial do Reddit, a Conversions API oferece rastreamento "mais resiliente" que é "menos suscetível a bloqueadores de anúncios e restrições de navegador."

Este guia mostra como implementar o rastreamento de conversões do Reddit no lado do servidor para lojas Shopify usando a Reddit Conversions API. Você terá uma solução pronta para produção baseada em Docker que envia dados de conversão diretamente do seu servidor para o Reddit, contornando todas as limitações do lado do cliente.

O Reddit recomenda oficialmente usar tanto o Pixel quanto a Conversions API juntos. Da documentação deles: "Para os melhores resultados, recomendamos integrar tanto o Reddit Pixel quanto a Conversions API... A CAPI pode ajudá-lo a obter dados mais precisos e aumentar sua cobertura de conversões."


Sumário

  1. Por Que o Rastreamento Server-Side É Necessário
  2. Visão Geral da Arquitetura
  3. Pré-requisitos
  4. Passo 1: Configuração do Reddit Ads Manager
  5. Passo 2: Construindo o Rastreador de Conversões
  6. Passo 3: Configuração Docker
  7. Passo 4: Proxy Reverso Nginx
  8. Passo 5: Certificado SSL
  9. Passo 6: Configuração de Webhook no Shopify
  10. Passo 7: Testes e Verificação
  11. Solução de Problemas Comuns
  12. Monitoramento e Manutenção

Por Que o Rastreamento Server-Side É Necessário

A Limitação do Pixel no Lado do Cliente

O Reddit Pixel é um trecho de JavaScript que roda no navegador do usuário. Embora funcione para rastreamento básico, ele enfrenta limitações significativas em 2025:

  • Bloqueadores de Anúncios: Extensões do navegador bloqueiam completamente as requisições do pixel
  • Transparência de Rastreamento do iOS: Requer consentimento explícito do usuário
  • Bloqueio de Cookies de Terceiros: Safari e Firefox bloqueiam por padrão
  • Configurações de Privacidade: Navegadores modernos restringem as capacidades de rastreamento

Esses não são problemas teóricos. De acordo com dados do setor, 30-50% dos eventos de conversão podem ser perdidos apenas com o rastreamento no lado do cliente.

A Recomendação Oficial do Reddit

Da documentação da Conversions API do Reddit (fonte):

"A CAPI é mais resiliente à perda de sinal porque opera no lado do servidor, tornando-a menos suscetível a bloqueadores de anúncios e restrições de navegador. Isso leva a uma medição, segmentação e otimização aprimoradas."

A Conversions API envia dados de servidor para servidor. Sem envolvimento do navegador significa sem bloqueio, sem solicitações de consentimento e precisão completa dos dados.

Por Que Você Precisa de Ambos

O Reddit recomenda usar tanto o Pixel quanto a CAPI juntos:

  • Pixel: Captura interações no lado do cliente, IDs de clique, contexto do navegador
  • CAPI: Garante que os dados de conversão cheguem ao Reddit mesmo quando o Pixel é bloqueado
  • Deduplicação: O Reddit lida automaticamente com eventos duplicados quando ambos enviam o mesmo event_id

Este guia implementa o componente CAPI. Você pode adicionar o Pixel separadamente através dos eventos de clientes do Shopify.


Visão Geral da Arquitetura

Como Funciona

A implementação usa webhooks do Shopify para capturar eventos de pedidos no lado do servidor e depois os encaminha para a Conversions API do 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

ComponenteFinalidadeTecnologia
Receptor de WebhookRecebe eventos de pedidos do ShopifyNode.js/Express
Verificação HMACValida a autenticidade do webhookcrypto (SHA-256)
Transformador de EventosConverte dados do Shopify para o formato do RedditServiço personalizado
DeduplicaçãoPrevine eventos duplicadosPostgreSQL
Cliente da API do RedditEnvia eventos para o RedditCliente HTTP com lógica de retry
Proxy ReversoTerminação SSL, roteamentoNginx

Pré-requisitos

Você precisará de:

  • Servidor: VPS Linux com Docker instalado (mínimo 2GB de RAM)
  • Domínio: Subdomínio apontando para o seu servidor (ex.: shopify-events.seudominio.com)
  • Conta Reddit Ads: Conta ativa com Pixel criado
  • Loja Shopify: Acesso de administrador para configurar webhooks
  • Ferramentas: Acesso SSH, conhecimento básico de linha de comando

Passo 1: Configuração do Reddit Ads Manager

Crie Seu Pixel

  1. Acesse Reddit Ads Manager → Events Manager
  2. Clique em "Create New Pixel"
  3. Nomeie seu pixel (ex.: "Shopify Store Conversions")
  4. Copie o Pixel ID (formato: t2_abc123)

Gere o Token de Acesso da Conversions API

  1. No Events Manager, clique no seu pixel
  2. Vá em "Settings" → "Conversions API"
  3. Clique em "Generate Access Token"
  4. Copie o token (ele começa com Bearer ey...)
  5. Importante: Salve este token de forma segura. Você não o verá novamente.

Agora você tem:

  • REDDIT_PIXEL_ID (ex.: t2_abc123)
  • REDDIT_ACCESS_TOKEN (ex.: eyJhbGciOiJSUzI1NiIsImtpZCI...)

Passo 2: Construindo o Rastreador de Conversões

Crie a estrutura de diretórios do projeto:

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 lida com a verificação HMAC e o parsing do corpo da requisição:

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 dados de pedidos do Shopify no formato Reddit CAPI:

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,
  };
}

Detalhe Crítico: A API do Reddit espera value como um inteiro representando o valor em unidades monetárias menores (centavos para USD, centavos para BRL, etc.). Por isso multiplicamos por 100 e arredondamos.

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);
  });
});

Passo 3: Configuração 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

Segurança: Defina permissões restritivas no arquivo .env:

chmod 600 .env

Passo 4: Proxy Reverso Nginx

Crie o arquivo de configuração do 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 o site:

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

Passo 5: Certificado SSL

Instale o Certbot caso ainda não esteja instalado:

apt update
apt install certbot

Crie o diretório webroot:

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

Gere o certificado:

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

Atualize a configuração do 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;
    }
}

Teste e recarregue:

nginx -t
systemctl reload nginx

Verifique se o HTTPS funciona:

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

Você deverá ver:

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

Passo 6: Configuração de Webhook no Shopify

Inicie a Aplicação

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

Verifique os logs:

docker compose logs -f reddit-capi

Você deverá ver: Server running on port 3000

Configure os Webhooks no Shopify

  1. Acesse Shopify Admin → Settings → Notifications
  2. Role para baixo até "Webhooks"
  3. Clique em "Create webhook"

Webhook de Criação de Pedido:

  • Evento: Order creation
  • Formato: JSON
  • URL: https://shopify-events.yourdomain.com/webhooks/shopify
  • Versão da API: Mais recente (2025-01 ou superior)

Clique em "Save webhook"

Copie o Segredo de Assinatura do Webhook:

Após criar o webhook, o Shopify exibe um segredo de assinatura. Copie-o e adicione ao seu arquivo .env:

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

Reinicie o container para aplicar o novo segredo:

docker compose restart reddit-capi

Opcional: Webhook de Criação de Checkout

Repita o processo para eventos de "Checkout creation" se você quiser rastrear carrinhos abandonados como eventos "Lead" no Reddit.


Passo 7: Testes e Verificação

Teste HMAC Sem Assinatura Válida

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

Resposta esperada:

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

Isso confirma que a segurança está funcionando.

Envie um Webhook de Teste pelo Shopify

  1. No Shopify Admin, vá ao webhook que você criou
  2. Clique em "Send test notification"
  3. Observe os logs:
docker compose logs -f reddit-capi

Você deverá 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}

Verifique no Reddit Events Manager

  1. Acesse Reddit Ads Manager → Events Manager
  2. Clique no seu pixel
  3. Vá na aba "Test Events"
  4. Você deverá ver o evento de teste aparecer em poucos minutos

Verifique o Banco de Dados

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;"

Você deverá ver seu evento de teste com status = 'sent'.

Crie um Pedido de Teste Real

Use o recurso "Test order" do Shopify ou crie um pedido real na sua loja de desenvolvimento. O webhook será disparado automaticamente, e você verá a conversão no Reddit em 15-30 minutos.


Solução de Problemas Comuns

Problema: Eventos Não Aparecem no Reddit

Verifique os logs em busca de erros da API do Reddit:

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

Erros comuns:

ErroCausaSolução
"unexpected type number"value enviado como decimal em vez de inteiroVerifique se está multiplicando por 100 e usando Math.round()
"unknown field event_id"Enviando event_id no payload do RedditRemova event_id do redditEvent. Use conversion_id em vez disso
"missing required field"Faltando event_at, event_type ou dados do usuárioVerifique se o transformador está definindo todos os campos obrigatórios
401 UnauthorizedToken de acesso inválidoRegenere o token no Reddit Events Manager
429 Too Many RequestsLimitação de taxaAguarde o período de retry-after (tratado automaticamente)

Problema: Falhas no Webhook do Shopify

Verifique o status do webhook no Shopify:

No Shopify Admin → Settings → Notifications → Webhooks, verifique se o webhook apresenta erros.

Problemas comuns:

  • Falha na verificação HMAC: Incompatibilidade de segredo. Copie o segredo correto do Shopify e atualize o .env
  • Timeout: Servidor não responde em 5 segundos. Verifique se o container está rodando e saudável
  • Erros SSL: Certificado inválido. Verifique com curl -vI https://your-domain.com/health

Problema: "stream is not readable"

Este erro ocorre quando tanto rawBodyCapture quanto express.json() tentam ler o corpo da requisição.

Solução: Remova o middleware express.json() da rota do webhook. A função rawBodyCapture lida tanto com a captura do corpo bruto quanto com o parsing do JSON.

Incorreto:

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

Correto:

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

Problema: Conversões Duplicadas

Se você está usando tanto o Reddit Pixel quanto a CAPI, certifique-se de usar o mesmo conversion_id em ambos.

Implementação do Pixel (na página de agradecimento do Shopify):

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

O Reddit irá automaticamente deduplicar eventos com o mesmo conversion_id.

Problema: Falha na Conexão com o Banco de Dados

# 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

Monitoramento e Manutenção

Monitoramento de Logs

Logs em tempo real:

docker compose logs -f reddit-capi

Buscar por erros:

docker compose logs reddit-capi | grep -i error

Filtrar por tipo de evento:

docker compose logs reddit-capi | grep "Purchase"

Consultas ao Banco de Dados

Eventos recentes:

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 com falha:

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;"

Estatí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;"

Verificação de Saúde

Configure um cron job para monitorar o endpoint de saúde:

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

Renovação do Certificado SSL

Os certificados Let's Encrypt são válidos por 90 dias. O Certbot deve renová-los automaticamente.

Teste de renovação:

certbot renew --dry-run

Verifique a validade do certificado:

certbot certificates

Atualização de Containers

Atualizar após alterações no código:

docker compose down
docker compose up -d --build

Ver status dos containers:

docker compose ps

Reiniciar sem reconstruir:

docker compose restart reddit-capi

Configuração Avançada

Tipos de Eventos Personalizados

O Reddit suporta eventos padrão adicionais. Você pode estender o transformador para lidar com mais webhooks do Shopify:

Visualização de Conteúdo (visualizações de produtos):

// 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',
    },
  };
}

Adicionar ao Carrinho:

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

Rastreamento em Nível de Produto

Inclua detalhes do produto nos metadados do 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),
  })),
},

Suporte a Múltiplas Moedas

A implementação já lida com múltiplas moedas através do campo order.currency do Shopify. Certifique-se de que está sempre convertendo o valor para unidades monetárias menores corretamente:

  • USD, EUR, BRL: Multiplique por 100 (centavos)
  • JPY, KRW: Não é necessário multiplicar (já está em unidades menores)
  • BHD, KWD: Multiplique por 1000 (3 casas decimais)

Para moedas com diferentes casas decimais, atualize o 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 Passos

Esta implementação fornece rastreamento de conversões server-side pronto para produção para o Reddit Ads. A arquitetura é projetada para confiabilidade, com verificação HMAC, deduplicação, lógica de retry e logging abrangente.

Você pode estender essa base com:

  • Tipos de eventos adicionais: Visualizações de produtos, abandono de carrinho, buscas
  • Correspondência aprimorada de usuários: Incluir IDs de clique do Reddit dos parâmetros de URL
  • Alertas: Integrar com ferramentas de monitoramento para eventos com falha
  • Painel de analytics: Visualizar dados de conversão do PostgreSQL

Para referência, o código-fonte completo e a configuração Docker demonstrados aqui são baseados em uma implantação em produção processando pedidos reais do Shopify.