tva
← Insights

Reddit Pixel Tracking per Shopify: implementazione server-side con Conversions API

Il tracking di Reddit per l'e-commerce affronta le stesse sfide delle altre piattaforme pubblicitarie: i pixel basati sul browser vengono sempre più bloccati da ad blocker, impostazioni di privacy e restrizioni di tracciamento iOS. Secondo la documentazione ufficiale di Reddit, la Conversions API fornisce un tracciamento “più resiliente” che è “meno suscettibile ad ad blocker e restrizioni del browser.”

Questa guida Le mostra come implementare il tracking delle conversioni Reddit server-side per i negozi Shopify utilizzando la Reddit Conversions API. Otterrà una soluzione production-ready basata su Docker che invia i dati di conversione direttamente dal Suo server a Reddit, aggirando tutte le limitazioni lato client.

Reddit raccomanda ufficialmente di utilizzare sia il Pixel che la Conversions API insieme. Dalla loro documentazione: “Per i migliori risultati, raccomandiamo di integrare sia il Reddit Pixel che la Conversions API…CAPI può aiutarLa ad ottenere dati più accurati e aumentare la copertura delle conversioni.”


Indice

  1. Perché il tracking server-side è necessario
  2. Panoramica dell'architettura
  3. Prerequisiti
  4. Passo 1: Configurazione di Reddit Ads Manager
  5. Passo 2: Costruzione del Conversion Tracker
  6. Passo 3: Configurazione Docker
  7. Passo 4: Nginx Reverse Proxy
  8. Passo 5: Certificato SSL
  9. Passo 6: Configurazione Webhook di Shopify
  10. Passo 7: Test e verifica
  11. Risoluzione dei problemi comuni
  12. Monitoraggio e manutenzione

Perché il tracking server-side è necessario

La limitazione del Pixel lato client

Il Reddit Pixel è uno snippet JavaScript che viene eseguito nel browser dell'utente. Sebbene funzioni per il tracking di base, nel 2025 affronta limitazioni significative:

  • Ad Blocker: Le estensioni del browser bloccano completamente le richieste pixel
  • iOS App Tracking Transparency: Richiede il consenso esplicito dell'utente
  • Blocco dei cookie di terze parti: Safari e Firefox lo bloccano per impostazione predefinita
  • Impostazioni di privacy: I browser moderni limitano le capacità di tracciamento

Non si tratta di problemi teorici. Secondo i dati del settore, il 30-50% degli eventi di conversione può andare perso con il solo tracking lato client.

La raccomandazione ufficiale di Reddit

Dalla documentazione della Conversions API di Reddit (fonte):

“CAPI è più resiliente alla perdita di segnale perché opera lato server, rendendola meno suscettibile ad ad blocker e restrizioni del browser. Questo porta a una misurazione, targeting e ottimizzazione migliorati.”

La Conversions API invia i dati da server a server. Nessun coinvolgimento del browser significa nessun blocco, nessuna richiesta di consenso e completa accuratezza dei dati.

Perché servono entrambi

Reddit raccomanda di utilizzare sia il Pixel che CAPI insieme:

  • Pixel: Cattura le interazioni lato client, i click ID, il contesto del browser
  • CAPI: Assicura che i dati di conversione raggiungano Reddit anche quando il Pixel è bloccato
  • Deduplicazione: Reddit gestisce automaticamente gli eventi duplicati quando entrambi inviano lo stesso event_id

Questa guida implementa il componente CAPI. Può aggiungere il Pixel separatamente tramite gli eventi cliente di Shopify.


Panoramica dell'architettura

Come funziona

L'implementazione utilizza i webhook di Shopify per catturare gli eventi ordine lato server, poi li inoltra alla Conversions API di Reddit:

Shopify Store (ordine creato)
    |
    v
Shopify Webhook (HTTPS POST)
    |
    v
Nginx Reverse Proxy (terminazione SSL)
    |
    v
Node.js Application (container Docker)
    |
    v
1. Verifica HMAC (sicurezza)
2. Trasformazione evento (formato Shopify -> formato Reddit)
3. Hashing PII (SHA-256)
4. Controllo deduplicazione (PostgreSQL)
5. Invio a Reddit CAPI
6. Log del risultato
    |
    v
Reddit Ads Platform (conversione attribuita)

Componenti

ComponenteScopoTecnologia
Webhook ReceiverRiceve gli eventi ordine di ShopifyNode.js/Express
Verifica HMACValida l'autenticità del webhookcrypto (SHA-256)
Event TransformerConverte i dati Shopify nel formato RedditServizio personalizzato
DeduplicazionePreviene eventi duplicatiPostgreSQL
Reddit API ClientInvia eventi a RedditClient HTTP con logica di retry
Reverse ProxyTerminazione SSL, routingNginx

Prerequisiti

Avrà bisogno di:

  • Server: VPS Linux con Docker installato (minimo 2GB RAM)
  • Dominio: Sottodominio che punta al Suo server (es. shopify-events.yourdomain.com)
  • Account Reddit Ads: Account attivo con Pixel creato
  • Negozio Shopify: Accesso admin per configurare i webhook
  • Strumenti: Accesso SSH, conoscenza base della riga di comando

Passo 1: Configurazione di Reddit Ads Manager

Creare il Pixel

  1. Andare su Reddit Ads Manager → Events Manager
  2. Cliccare su “Create New Pixel”
  3. Dare un nome al pixel (es. “Shopify Store Conversions”)
  4. Copiare il Pixel ID (formato: t2_abc123)

Generare il token di accesso alla Conversions API

  1. In Events Manager, cliccare sul Suo pixel
  2. Andare su “Settings” → “Conversions API”
  3. Cliccare su “Generate Access Token”
  4. Copiare il token (inizia con Bearer ey...)
  5. Importante: Salvi questo token in modo sicuro. Non lo vedrà di nuovo.

Ora dispone di:

  • REDDIT_PIXEL_ID (es. t2_abc123)
  • REDDIT_ACCESS_TOKEN (es. eyJhbGciOiJSUzI1NiIsImtpZCI...)

Passo 2: Costruzione del Conversion Tracker

Creare la struttura della directory del progetto:

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

Questo middleware gestisce la verifica HMAC e il parsing del corpo della richiesta:

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

Trasforma i dati dell'ordine Shopify nel 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,
  };
}

Dettaglio critico: L'API di Reddit si aspetta il value come intero che rappresenta l'importo in unità minori della valuta (centesimi per USD, pence per GBP, ecc.). Per questo moltiplichiamo per 100 e arrotondiamo.

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: Configurazione 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

Sicurezza: Impostare permessi restrittivi sul file .env:

chmod 600 .env

Passo 4: Nginx Reverse Proxy

Creare il file di configurazione 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;
    }
}

Abilitare il sito:

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

Passo 5: Certificato SSL

Installare Certbot se non è già installato:

apt update
apt install certbot

Creare la directory webroot:

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

Generare il certificato:

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

Aggiornare la configurazione Nginx per utilizzare 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;
    }
}

Testare e ricaricare:

nginx -t
systemctl reload nginx

Verificare che HTTPS funzioni:

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

Dovrebbe visualizzare:

  • HTTP/2 200
  • Certificato SSL valido
  • Risposta JSON con {"status":"healthy"}

Passo 6: Configurazione Webhook di Shopify

Avviare l'applicazione

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

Controllare i log:

docker compose logs -f reddit-capi

Dovrebbe visualizzare: Server running on port 3000

Configurare i Webhook in Shopify

  1. Andare su Shopify Admin → Settings → Notifications
  2. Scorrere fino a “Webhooks”
  3. Cliccare su “Create webhook”

Webhook di creazione ordine:

  • Evento: Order creation
  • Formato: JSON
  • URL: https://shopify-events.yourdomain.com/webhooks/shopify
  • Versione API: Ultima (2025-01 o più recente)

Cliccare su “Save webhook”

Copiare il Webhook Signing Secret:

Dopo aver creato il webhook, Shopify mostra un signing secret. Lo copi e lo aggiunga al Suo file .env:

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

Riavviare il container per applicare il nuovo secret:

docker compose restart reddit-capi

Opzionale: Webhook di creazione Checkout

Ripetere il processo per gli eventi “Checkout creation” se desidera tracciare i carrelli abbandonati come eventi “Lead” in Reddit.


Passo 7: Test e verifica

Test HMAC senza firma valida

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

Risposta attesa:

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

Questo conferma che la sicurezza funziona.

Inviare un webhook di test da Shopify

  1. In Shopify Admin, andare al webhook creato
  2. Cliccare su “Send test notification”
  3. Monitorare i log:
docker compose logs -f reddit-capi

Dovrebbe visualizzare:

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}

Verifica in Reddit Events Manager

  1. Andare su Reddit Ads Manager → Events Manager
  2. Cliccare sul Suo pixel
  3. Andare alla scheda “Test Events”
  4. L'evento di test dovrebbe apparire entro pochi minuti

Controllare il database

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

Dovrebbe vedere il Suo evento di test con status = 'sent'.

Creare un ordine di test reale

Utilizzi la funzione “Test order” di Shopify o crei un ordine reale nel Suo store di sviluppo. Il webhook si attiverà automaticamente e vedrà la conversione in Reddit entro 15-30 minuti.


Risoluzione dei problemi comuni

Problema: gli eventi non appaiono in Reddit

Controllare i log per errori dell'API Reddit:

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

Errori comuni:

ErroreCausaSoluzione
“unexpected type number”value inviato come decimale invece che come interoVerificare che si stia moltiplicando per 100 e usando Math.round()
“unknown field event_id”Invio di event_id nel payload RedditRimuovere event_id da redditEvent. Usare conversion_id invece
“missing required field”Manca event_atevent_type o dati utenteVerificare che il transformer imposti tutti i campi obbligatori
401 UnauthorizedToken di accesso non validoRigenerare il token in Reddit Events Manager
429 Too Many RequestsRate limitingAttendere il periodo retry-after (gestito automaticamente)

Problema: errori del Webhook Shopify

Controllare lo stato del webhook Shopify:

In Shopify Admin → Settings → Notifications → Webhooks, controllare se il webhook mostra errori.

Problemi comuni:

  • Verifica HMAC fallita: Discrepanza del secret. Copiare il secret corretto da Shopify e aggiornare il .env
  • Timeout: Il server non risponde entro 5 secondi. Verificare se il container è in esecuzione e funzionante
  • Errori SSL: Certificato non valido. Verificare con curl -vI https://your-domain.com/health

Problema: “stream is not readable”

Questo errore si verifica quando sia rawBodyCapture che express.json() tentano di leggere il corpo della richiesta.

Soluzione: Rimuovere il middleware express.json() dalla rotta webhook. La funzione rawBodyCapture gestisce sia la cattura del corpo raw che il parsing JSON.

Errato:

app.use('/webhooks/shopify', express.json());  // ❌ Non usare questo
app.use('/webhooks/shopify', rawBodyCapture);

Corretto:

app.use('/webhooks/shopify', rawBodyCapture);  // ✅ Gestisce entrambi

Problema: conversioni duplicate

Se sta utilizzando sia il Reddit Pixel che CAPI, si assicuri di usare lo stesso conversion_id in entrambi.

Implementazione Pixel (nella pagina di ringraziamento di Shopify):

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

Reddit deduplicherà automaticamente gli eventi con lo stesso conversion_id.

Problema: connessione al database fallita

# 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

Monitoraggio e manutenzione

Monitoraggio dei log

Log in tempo reale:

docker compose logs -f reddit-capi

Cercare errori:

docker compose logs reddit-capi | grep -i error

Filtrare per tipo di evento:

docker compose logs reddit-capi | grep "Purchase"

Query sul database

Eventi recenti:

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

Eventi falliti:

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

Statistiche degli eventi:

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

Health Check

Configurare un cron job per monitorare l'endpoint di health:

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

Rinnovo del certificato SSL

I certificati Let's Encrypt sono validi per 90 giorni. Certbot dovrebbe rinnovarli automaticamente.

Testare il rinnovo:

certbot renew --dry-run

Controllare la scadenza del certificato:

certbot certificates

Aggiornamenti dei container

Aggiornare dopo modifiche al codice:

docker compose down
docker compose up -d --build

Visualizzare lo stato dei container:

docker compose ps

Riavviare senza ricostruire:

docker compose restart reddit-capi

Configurazione avanzata

Tipi di evento personalizzati

Reddit supporta ulteriori eventi standard. È possibile estendere il transformer per gestire più webhook Shopify:

View Content (visualizzazioni prodotto):

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

Add to Cart:

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

Tracking a livello di prodotto

Includere i dettagli del prodotto nei metadati dell'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),
  })),
},

Supporto multi-valuta

L'implementazione gestisce già il multi-valuta tramite il campo order.currency di Shopify. Si assicuri di convertire sempre il valore nelle unità minori della valuta correttamente:

  • USD, EUR, CAD: Moltiplicare per 100 (centesimi)
  • JPY, KRW: Nessuna moltiplicazione necessaria (già in unità minori)
  • BHD, KWD: Moltiplicare per 1000 (3 cifre decimali)

Per le valute con diverse posizioni decimali, aggiornare il transformer:

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

Prossimi passi

Questa implementazione fornisce un tracking delle conversioni server-side production-ready per Reddit Ads. L'architettura è progettata per l'affidabilità, con verifica HMAC, deduplicazione, logica di retry e logging completo.

È possibile estendere questa base con:

  • Tipi di evento aggiuntivi: Visualizzazioni prodotto, abbandono del carrello, ricerche
  • Corrispondenza utente migliorata: Includere i click ID di Reddit dai parametri URL
  • Alerting: Integrazione con strumenti di monitoraggio per eventi falliti
  • Dashboard di analisi: Visualizzazione dei dati di conversione da PostgreSQL

Come riferimento, il codice sorgente completo e la configurazione Docker dimostrati qui sono basati su un deployment in produzione che elabora ordini Shopify reali.